diff options
| author | rinsuki <428rinsuki+git@gmail.com> | 2018-05-17 07:52:24 +0900 |
|---|---|---|
| committer | rinsuki <428rinsuki+git@gmail.com> | 2018-05-17 07:52:24 +0900 |
| commit | 829b4012e6dc14eb64a3d8f60826fe9b6a41b40d (patch) | |
| tree | 42ac37f323db349dca9316e6fdb39fc33b860686 /src | |
| parent | add yarn.lock to gitignore (diff) | |
| parent | Update deliver.ts (diff) | |
| download | misskey-829b4012e6dc14eb64a3d8f60826fe9b6a41b40d.tar.gz misskey-829b4012e6dc14eb64a3d8f60826fe9b6a41b40d.tar.bz2 misskey-829b4012e6dc14eb64a3d8f60826fe9b6a41b40d.zip | |
Merge branch 'master' into fix/yarn-lock-ignore
Diffstat (limited to 'src')
351 files changed, 7441 insertions, 4205 deletions
diff --git a/src/build/i18n.ts b/src/build/i18n.ts index a6cc6c38ff..6c0f633ad9 100644 --- a/src/build/i18n.ts +++ b/src/build/i18n.ts @@ -7,7 +7,7 @@ import locale from '../../locales'; export default class Replacer { private lang: string; - public pattern = /%i18n:([a-z_\-@\.\!]+?)%/g; + public pattern = /%i18n:([a-z0-9_\-@\.\!]+?)%/g; constructor(lang: string) { this.lang = lang; diff --git a/src/cafy-id.ts b/src/cafy-id.ts new file mode 100644 index 0000000000..310b1eb20b --- /dev/null +++ b/src/cafy-id.ts @@ -0,0 +1,29 @@ +import * as mongo from 'mongodb'; +import { Query } from 'cafy'; + +export const isAnId = x => mongo.ObjectID.isValid(x); +export const isNotAnId = x => !isAnId(x); + +/** + * ID + */ +export default class ID extends Query<mongo.ObjectID> { + constructor() { + super(); + + this.transform = v => { + if (isAnId(v) && !mongo.ObjectID.prototype.isPrototypeOf(v)) { + return new mongo.ObjectID(v); + } else { + return v; + } + }; + + this.push(v => { + if (!mongo.ObjectID.prototype.isPrototypeOf(v) && isNotAnId(v)) { + return new Error('must-be-an-id'); + } + return true; + }); + } +} diff --git a/src/client/app/auth/views/form.vue b/src/client/app/auth/views/form.vue index b323907eb0..152b900429 100644 --- a/src/client/app/auth/views/form.vue +++ b/src/client/app/auth/views/form.vue @@ -94,13 +94,13 @@ export default Vue.extend({ margin 0 auto -38px auto border solid 5px #fff border-radius 100% - box-shadow 0 2px 2px rgba(0, 0, 0, 0.1) + box-shadow 0 2px 2px rgba(#000, 0.1) > .app padding 44px 16px 0 16px color #555 background #eee - box-shadow 0 2px 2px rgba(0, 0, 0, 0.1) inset + box-shadow 0 2px 2px rgba(#000, 0.1) inset &:after content '' diff --git a/src/client/app/auth/views/index.vue b/src/client/app/auth/views/index.vue index e1e1b265e1..0fcd9bfe53 100644 --- a/src/client/app/auth/views/index.vue +++ b/src/client/app/auth/views/index.vue @@ -94,7 +94,7 @@ export default Vue.extend({ margin 0 auto text-align center background #fff - box-shadow 0px 4px 16px rgba(0, 0, 0, 0.2) + box-shadow 0px 4px 16px rgba(#000, 0.2) > .fetching margin 0 diff --git a/src/client/app/base.pug b/src/client/app/base.pug index 32a95a6c99..c182fd6f64 100644 --- a/src/client/app/base.pug +++ b/src/client/app/base.pug @@ -1,3 +1,5 @@ +block vars + doctype html != '\n<!-- Thank you for using Misskey! @syuilo -->\n' @@ -9,9 +11,17 @@ html meta(name='application-name' content='Misskey') meta(name='theme-color' content=themeColor) meta(name='referrer' content='origin') + meta(property='og:site_name' content='Misskey') link(rel='manifest' href='/manifest.json') - title Misskey + title + block title + | Misskey + + block desc + meta(name='description' content='A SNS') + + block meta style include ./../../../built/client/assets/init.css diff --git a/src/client/app/boot.js b/src/client/app/boot.js index a0709842b9..35d02cf9c5 100644 --- a/src/client/app/boot.js +++ b/src/client/app/boot.js @@ -11,14 +11,12 @@ 'use strict'; -// Chromeで確認したことなのですが、constやletを用いたとしても -// グローバルなスコープで定数/変数を定義するとwindowのプロパティ -// としてそれがアクセスできるようになる訳ではありませんが、普通に -// コンソールから定数/変数名を入力するとアクセスできてしまいます。 -// ブロック内に入れてスコープを非グローバル化するとそれが防げます -// (Chrome以外のブラウザでは検証していません) -{ - if (localStorage.getItem('shouldFlush') == 'true') refresh(); +(function() { + // キャッシュ削除要求があれば従う + if (localStorage.getItem('shouldFlush') == 'true') { + refresh(); + return; + } // Get the current url information const url = new URL(location.href); @@ -62,6 +60,11 @@ app = isMobile ? 'mobile' : 'desktop'; } + // Dark/Light + if (localStorage.getItem('darkmode') == 'true') { + document.documentElement.setAttribute('data-darkmode', 'true'); + } + // Script version const ver = localStorage.getItem('v') || VERSION; @@ -72,11 +75,16 @@ const raw = (localStorage.getItem('useRawScript') == 'true' && isDebug) || ENV != 'production'; + // Get salt query + const salt = localStorage.getItem('salt') + ? '?salt=' + localStorage.getItem('salt') + : ''; + // Load an app script // Note: 'async' make it possible to load the script asyncly. // 'defer' make it possible to run the script when the dom loaded. const script = document.createElement('script'); - script.setAttribute('src', `/assets/${app}.${ver}.${lang}.${raw ? 'raw' : 'min'}.js`); + script.setAttribute('src', `/assets/${app}.${ver}.${lang}.${raw ? 'raw' : 'min'}.js${salt}`); script.setAttribute('async', 'true'); script.setAttribute('defer', 'true'); head.appendChild(script); @@ -97,8 +105,8 @@ const meta = await res.json(); // Compare versions - if (meta.version != ver) { - localStorage.setItem('v', meta.version); + if (meta.clientVersion != ver) { + localStorage.setItem('v', meta.clientVersion); alert( 'Misskeyの新しいバージョンがあります。ページを再度読み込みします。' + @@ -112,6 +120,9 @@ function refresh() { localStorage.setItem('shouldFlush', 'false'); + // Random + localStorage.setItem('salt', Math.random().toString()); + // Clear cache (serive worker) try { navigator.serviceWorker.controller.postMessage('clear'); @@ -126,4 +137,4 @@ // Force reload location.reload(true); } -} +})(); diff --git a/src/client/app/common/define-widget.ts b/src/client/app/common/define-widget.ts index 7b98c0903f..0b2bc36566 100644 --- a/src/client/app/common/define-widget.ts +++ b/src/client/app/common/define-widget.ts @@ -18,61 +18,65 @@ export default function<T extends object>(data: { default: false } }, + computed: { id(): string { return this.widget.id; + }, + + props(): T { + return this.widget.data; } }, + data() { return { - props: data.props ? data.props() : {} as T, - bakedOldProps: null, - preventSave: false + bakedOldProps: null }; }, + created() { - if (this.props) { - Object.keys(this.props).forEach(prop => { - if (this.widget.data.hasOwnProperty(prop)) { - this.props[prop] = this.widget.data[prop]; - } - }); - } + this.mergeProps(); + + this.$watch('props', () => { + this.mergeProps(); + }); this.bakeProps(); + }, + + methods: { + bakeProps() { + this.bakedOldProps = JSON.stringify(this.props); + }, - this.$watch('props', newProps => { - if (this.preventSave) { - this.preventSave = false; - this.bakeProps(); - return; + mergeProps() { + if (data.props) { + const defaultProps = data.props(); + Object.keys(defaultProps).forEach(prop => { + if (!this.props.hasOwnProperty(prop)) { + Vue.set(this.props, prop, defaultProps[prop]); + } + }); } - if (this.bakedOldProps == JSON.stringify(newProps)) return; + }, + + save() { + if (this.bakedOldProps == JSON.stringify(this.props)) return; this.bakeProps(); if (this.isMobile) { (this as any).api('i/update_mobile_home', { id: this.id, - data: newProps - }).then(() => { - (this as any).os.i.clientSettings.mobileHome.find(w => w.id == this.id).data = newProps; + data: this.props }); } else { (this as any).api('i/update_home', { id: this.id, - data: newProps - }).then(() => { - (this as any).os.i.clientSettings.home.find(w => w.id == this.id).data = newProps; + data: this.props }); } - }, { - deep: true - }); - }, - methods: { - bakeProps() { - this.bakedOldProps = JSON.stringify(this.props); } } }); diff --git a/src/client/app/common/scripts/check-for-update.ts b/src/client/app/common/scripts/check-for-update.ts index 20ce64ea85..1e303017eb 100644 --- a/src/client/app/common/scripts/check-for-update.ts +++ b/src/client/app/common/scripts/check-for-update.ts @@ -1,9 +1,9 @@ -import MiOS from '../mios'; +import MiOS from '../../mios'; import { version as current } from '../../config'; export default async function(mios: MiOS, force = false, silent = false) { const meta = await mios.getMeta(force); - const newer = meta.version; + const newer = meta.clientVersion; if (newer != current) { localStorage.setItem('should-refresh', 'true'); diff --git a/src/client/app/common/scripts/streaming/channel.ts b/src/client/app/common/scripts/streaming/channel.ts index cab5f4edb4..be68ec0997 100644 --- a/src/client/app/common/scripts/streaming/channel.ts +++ b/src/client/app/common/scripts/streaming/channel.ts @@ -1,5 +1,5 @@ import Stream from './stream'; -import MiOS from '../../mios'; +import MiOS from '../../../mios'; /** * Channel stream connection diff --git a/src/client/app/common/scripts/streaming/drive.ts b/src/client/app/common/scripts/streaming/drive.ts index 7ff85b5946..50fff05737 100644 --- a/src/client/app/common/scripts/streaming/drive.ts +++ b/src/client/app/common/scripts/streaming/drive.ts @@ -1,6 +1,6 @@ import Stream from './stream'; import StreamManager from './stream-manager'; -import MiOS from '../../mios'; +import MiOS from '../../../mios'; /** * Drive stream connection diff --git a/src/client/app/common/scripts/streaming/global-timeline.ts b/src/client/app/common/scripts/streaming/global-timeline.ts index 452ddbac03..a639f1595c 100644 --- a/src/client/app/common/scripts/streaming/global-timeline.ts +++ b/src/client/app/common/scripts/streaming/global-timeline.ts @@ -1,6 +1,6 @@ import Stream from './stream'; import StreamManager from './stream-manager'; -import MiOS from '../../mios'; +import MiOS from '../../../mios'; /** * Global timeline stream connection diff --git a/src/client/app/common/scripts/streaming/home.ts b/src/client/app/common/scripts/streaming/home.ts index 73f2c5302c..32685f3c2c 100644 --- a/src/client/app/common/scripts/streaming/home.ts +++ b/src/client/app/common/scripts/streaming/home.ts @@ -2,7 +2,7 @@ import * as merge from 'object-assign-deep'; import Stream from './stream'; import StreamManager from './stream-manager'; -import MiOS from '../../mios'; +import MiOS from '../../../mios'; /** * Home stream connection @@ -25,10 +25,31 @@ export class HomeStream extends Stream { console.log('I updated:', i); } merge(me, i); + + // キャッシュ更新 + os.bakeMe(); + }); + + this.on('clientSettingUpdated', x => { + os.store.commit('settings/set', { + key: x.key, + value: x.value + }); + }); + + this.on('home_updated', x => { + if (x.home) { + os.store.commit('settings/setHome', x.home); + } else { + os.store.commit('settings/setHomeWidget', { + id: x.id, + data: x.data + }); + } }); // トークンが再生成されたとき - // このままではAPIが利用できないので強制的にサインアウトさせる + // このままではMisskeyが利用できないので強制的にサインアウトさせる this.on('my_token_regenerated', () => { alert('%i18n:!common.my-token-regenerated%'); os.signout(); diff --git a/src/client/app/common/scripts/streaming/local-timeline.ts b/src/client/app/common/scripts/streaming/local-timeline.ts index 3d04e05cd4..2834262bdc 100644 --- a/src/client/app/common/scripts/streaming/local-timeline.ts +++ b/src/client/app/common/scripts/streaming/local-timeline.ts @@ -1,6 +1,6 @@ import Stream from './stream'; import StreamManager from './stream-manager'; -import MiOS from '../../mios'; +import MiOS from '../../../mios'; /** * Local timeline stream connection diff --git a/src/client/app/common/scripts/streaming/messaging-index.ts b/src/client/app/common/scripts/streaming/messaging-index.ts index 84e2174ec4..addcccb952 100644 --- a/src/client/app/common/scripts/streaming/messaging-index.ts +++ b/src/client/app/common/scripts/streaming/messaging-index.ts @@ -1,6 +1,6 @@ import Stream from './stream'; import StreamManager from './stream-manager'; -import MiOS from '../../mios'; +import MiOS from '../../../mios'; /** * Messaging index stream connection diff --git a/src/client/app/common/scripts/streaming/messaging.ts b/src/client/app/common/scripts/streaming/messaging.ts index c1b5875cfb..a59377d867 100644 --- a/src/client/app/common/scripts/streaming/messaging.ts +++ b/src/client/app/common/scripts/streaming/messaging.ts @@ -1,5 +1,5 @@ import Stream from './stream'; -import MiOS from '../../mios'; +import MiOS from '../../../mios'; /** * Messaging stream connection diff --git a/src/client/app/common/scripts/streaming/othello-game.ts b/src/client/app/common/scripts/streaming/othello-game.ts index b85af8f72b..9e36f647bb 100644 --- a/src/client/app/common/scripts/streaming/othello-game.ts +++ b/src/client/app/common/scripts/streaming/othello-game.ts @@ -1,5 +1,5 @@ import Stream from './stream'; -import MiOS from '../../mios'; +import MiOS from '../../../mios'; export class OthelloGameStream extends Stream { constructor(os: MiOS, me, game) { diff --git a/src/client/app/common/scripts/streaming/othello.ts b/src/client/app/common/scripts/streaming/othello.ts index f5d47431cd..8f4f217e39 100644 --- a/src/client/app/common/scripts/streaming/othello.ts +++ b/src/client/app/common/scripts/streaming/othello.ts @@ -1,6 +1,6 @@ import StreamManager from './stream-manager'; import Stream from './stream'; -import MiOS from '../../mios'; +import MiOS from '../../../mios'; export class OthelloStream extends Stream { constructor(os: MiOS, me) { diff --git a/src/client/app/common/scripts/streaming/server.ts b/src/client/app/common/scripts/streaming/server.ts index 3d35ef4d9d..2ea4239288 100644 --- a/src/client/app/common/scripts/streaming/server.ts +++ b/src/client/app/common/scripts/streaming/server.ts @@ -1,6 +1,6 @@ import Stream from './stream'; import StreamManager from './stream-manager'; -import MiOS from '../../mios'; +import MiOS from '../../../mios'; /** * Server stream connection diff --git a/src/client/app/common/scripts/streaming/stream.ts b/src/client/app/common/scripts/streaming/stream.ts index 3912186ad3..fefa8e5ced 100644 --- a/src/client/app/common/scripts/streaming/stream.ts +++ b/src/client/app/common/scripts/streaming/stream.ts @@ -2,7 +2,7 @@ import { EventEmitter } from 'eventemitter3'; import * as uuid from 'uuid'; import * as ReconnectingWebsocket from 'reconnecting-websocket'; import { wsUrl } from '../../../config'; -import MiOS from '../../mios'; +import MiOS from '../../../mios'; /** * Misskey stream connection diff --git a/src/client/app/common/scripts/streaming/user-list.ts b/src/client/app/common/scripts/streaming/user-list.ts new file mode 100644 index 0000000000..30a52b98dd --- /dev/null +++ b/src/client/app/common/scripts/streaming/user-list.ts @@ -0,0 +1,17 @@ +import Stream from './stream'; +import MiOS from '../../mios'; + +export class UserListStream extends Stream { + constructor(os: MiOS, me, listId) { + super(os, 'user-list', { + i: me.token, + listId + }); + + (this as any).on('_connected_', () => { + this.send({ + i: me.token + }); + }); + } +} diff --git a/src/client/app/common/views/components/autocomplete.vue b/src/client/app/common/views/components/autocomplete.vue index 5c8f61a2a2..84173d20b5 100644 --- a/src/client/app/common/views/components/autocomplete.vue +++ b/src/client/app/common/views/components/autocomplete.vue @@ -234,7 +234,7 @@ export default Vue.extend({ margin-top calc(1em + 8px) overflow hidden background #fff - border solid 1px rgba(0, 0, 0, 0.1) + border solid 1px rgba(#000, 0.1) border-radius 4px transition top 0.1s ease, left 0.1s ease @@ -253,7 +253,7 @@ export default Vue.extend({ white-space nowrap overflow hidden font-size 0.9em - color rgba(0, 0, 0, 0.8) + color rgba(#000, 0.8) cursor default &, * @@ -285,10 +285,10 @@ export default Vue.extend({ .name margin 0 8px 0 0 - color rgba(0, 0, 0, 0.8) + color rgba(#000, 0.8) .username - color rgba(0, 0, 0, 0.3) + color rgba(#000, 0.3) > .emojis > li @@ -298,10 +298,10 @@ export default Vue.extend({ width 24px .name - color rgba(0, 0, 0, 0.8) + color rgba(#000, 0.8) .alias margin 0 0 0 8px - color rgba(0, 0, 0, 0.3) + color rgba(#000, 0.3) </style> diff --git a/src/client/app/common/views/components/avatar.vue b/src/client/app/common/views/components/avatar.vue new file mode 100644 index 0000000000..a4648c272e --- /dev/null +++ b/src/client/app/common/views/components/avatar.vue @@ -0,0 +1,42 @@ +<template> + <router-link class="mk-avatar" :to="user | userPage" :title="user | acct" :target="target" :style="style" v-if="disablePreview"></router-link> + <router-link class="mk-avatar" :to="user | userPage" :title="user | acct" :target="target" :style="style" v-else v-user-preview="user.id"></router-link> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + props: { + user: { + type: Object, + required: true + }, + target: { + required: false, + default: null + }, + disablePreview: { + required: false, + default: false + } + }, + computed: { + style(): any { + return { + backgroundColor: this.user.avatarColor ? `rgb(${ this.user.avatarColor.join(',') })` : null, + backgroundImage: `url(${ this.user.avatarUrl }?thumbnail)`, + borderRadius: (this as any).clientSettings.circleIcons ? '100%' : null + }; + } + } +}); +</script> + +<style lang="stylus" scoped> +.mk-avatar + display inline-block + vertical-align bottom + background-size cover + background-position center center + transition border-radius 1s ease +</style> diff --git a/src/client/app/common/views/components/google.vue b/src/client/app/common/views/components/google.vue new file mode 100644 index 0000000000..92817d3c1f --- /dev/null +++ b/src/client/app/common/views/components/google.vue @@ -0,0 +1,67 @@ +<template> +<div class="mk-google"> + <input type="search" v-model="query" :placeholder="q"> + <button @click="search">検索</button> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + props: ['q'], + data() { + return { + query: null + }; + }, + mounted() { + this.query = this.q; + }, + methods: { + search() { + window.open(`https://www.google.com/?#q=${this.query}`, '_blank'); + } + } +}); +</script> + +<style lang="stylus" scoped> +root(isDark) + display flex + margin 8px 0 + + > input + flex-shrink 1 + padding 10px + width 100% + height 40px + font-family sans-serif + font-size 16px + color isDark ? #dee4e8 : #55595c + background isDark ? #191b22 : #fff + border solid 1px isDark ? #495156 : #dadada + border-radius 4px 0 0 4px + + &:hover + border-color isDark ? #777c86 : #b0b0b0 + + > button + flex-shrink 0 + padding 0 16px + border solid 1px isDark ? #495156 : #dadada + border-left none + border-radius 0 4px 4px 0 + + &:hover + background-color isDark ? #2e3440 : #eee + + &:active + box-shadow 0 2px 4px rgba(#000, 0.15) inset + +.mk-google[data-darkmode] + root(true) + +.mk-google:not([data-darkmode]) + root(false) + +</style> diff --git a/src/client/app/common/views/components/index.ts b/src/client/app/common/views/components/index.ts index 6bfe43a800..69fed00c74 100644 --- a/src/client/app/common/views/components/index.ts +++ b/src/client/app/common/views/components/index.ts @@ -3,6 +3,7 @@ import Vue from 'vue'; import signin from './signin.vue'; import signup from './signup.vue'; import forkit from './forkit.vue'; +import avatar from './avatar.vue'; import nav from './nav.vue'; import noteHtml from './note-html'; import poll from './poll.vue'; @@ -28,6 +29,7 @@ import welcomeTimeline from './welcome-timeline.vue'; Vue.component('mk-signin', signin); Vue.component('mk-signup', signup); Vue.component('mk-forkit', forkit); +Vue.component('mk-avatar', avatar); Vue.component('mk-nav', nav); Vue.component('mk-note-html', noteHtml); Vue.component('mk-poll', poll); diff --git a/src/client/app/common/views/components/media-list.vue b/src/client/app/common/views/components/media-list.vue index 64172ad0b4..ff9d5e1022 100644 --- a/src/client/app/common/views/components/media-list.vue +++ b/src/client/app/common/views/components/media-list.vue @@ -2,7 +2,7 @@ <div class="mk-media-list" :data-count="mediaList.length"> <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 /> + <mk-media-image :image="media" :key="media.id" v-else :raw="raw"/> </template> </div> </template> @@ -11,7 +11,14 @@ import Vue from 'vue'; export default Vue.extend({ - props: ['mediaList'], + props: { + mediaList: { + required: true + }, + raw: { + default: false + } + } }); </script> @@ -23,7 +30,7 @@ export default Vue.extend({ @media (max-width 500px) height 192px - + &[data-count="1"] grid-template-rows 1fr &[data-count="2"] @@ -40,7 +47,7 @@ export default Vue.extend({ &[data-count="4"] grid-template-columns 1fr 1fr grid-template-rows 1fr 1fr - + :nth-child(1) grid-column 1 / 2 grid-row 1 / 2 @@ -53,5 +60,5 @@ export default Vue.extend({ :nth-child(4) grid-column 2 / 3 grid-row 2 / 3 - + </style> diff --git a/src/client/app/common/views/components/messaging-room.message.vue b/src/client/app/common/views/components/messaging-room.message.vue index afd700e777..ba0ab3209f 100644 --- a/src/client/app/common/views/components/messaging-room.message.vue +++ b/src/client/app/common/views/components/messaging-room.message.vue @@ -1,8 +1,6 @@ <template> <div class="message" :data-is-me="isMe"> - <router-link class="avatar-anchor" :to="message.user | userPage" :title="message.user | acct" target="_blank"> - <img class="avatar" :src="`${message.user.avatarUrl}?thumbnail&size=80`" alt=""/> - </router-link> + <mk-avatar class="avatar" :user="message.user" target="_blank"/> <div class="content"> <div class="balloon" :data-no-text="message.text == null"> <p class="read" v-if="isMe && message.isRead">%i18n:@is-read%</p> @@ -67,20 +65,14 @@ export default Vue.extend({ padding 10px 12px 10px 12px background-color transparent - > .avatar-anchor + > .avatar display block position absolute top 10px - - > .avatar - display block - min-width 54px - min-height 54px - max-width 54px - max-height 54px - margin 0 - border-radius 8px - transition all 0.1s ease + width 54px + height 54px + border-radius 8px + transition all 0.1s ease > .content @@ -134,7 +126,7 @@ export default Vue.extend({ bottom -4px left -12px margin 0 - color rgba(0, 0, 0, 0.5) + color rgba(#000, 0.5) font-size 11px > .content @@ -146,7 +138,7 @@ export default Vue.extend({ overflow hidden overflow-wrap break-word font-size 1em - color rgba(0, 0, 0, 0.5) + color rgba(#000, 0.5) > .text display block @@ -155,7 +147,7 @@ export default Vue.extend({ overflow hidden overflow-wrap break-word font-size 1em - color rgba(0, 0, 0, 0.8) + color rgba(#000, 0.8) & + .file > a @@ -195,13 +187,13 @@ export default Vue.extend({ display block margin 2px 0 0 0 font-size 10px - color rgba(0, 0, 0, 0.4) + color rgba(#000, 0.4) > [data-fa] margin-left 4px &:not([data-is-me]) - > .avatar-anchor + > .avatar left 12px > .content @@ -225,7 +217,7 @@ export default Vue.extend({ text-align left &[data-is-me] - > .avatar-anchor + > .avatar right 12px > .content diff --git a/src/client/app/common/views/components/messaging-room.vue b/src/client/app/common/views/components/messaging-room.vue index 38202d7581..a45114e6bb 100644 --- a/src/client/app/common/views/components/messaging-room.vue +++ b/src/client/app/common/views/components/messaging-room.vue @@ -256,7 +256,7 @@ export default Vue.extend({ padding 16px 8px 8px 8px text-align center font-size 0.8em - color rgba(0, 0, 0, 0.4) + color rgba(#000, 0.4) [data-fa] margin-right 4px @@ -267,7 +267,7 @@ export default Vue.extend({ padding 16px 8px 8px 8px text-align center font-size 0.8em - color rgba(0, 0, 0, 0.4) + color rgba(#000, 0.4) [data-fa] margin-right 4px @@ -278,7 +278,7 @@ export default Vue.extend({ padding 16px text-align center font-size 0.8em - color rgba(0, 0, 0, 0.4) + color rgba(#000, 0.4) [data-fa] margin-right 4px @@ -289,14 +289,14 @@ export default Vue.extend({ padding 0 12px line-height 24px color #fff - background rgba(0, 0, 0, 0.3) + background rgba(#000, 0.3) border-radius 12px &:hover - background rgba(0, 0, 0, 0.4) + background rgba(#000, 0.4) &:active - background rgba(0, 0, 0, 0.5) + background rgba(#000, 0.5) &.fetching cursor wait @@ -322,7 +322,7 @@ export default Vue.extend({ left 0 right 0 margin 0 auto - background rgba(0, 0, 0, 0.1) + background rgba(#000, 0.1) > span display inline-block @@ -330,7 +330,7 @@ export default Vue.extend({ padding 0 16px //font-weight bold line-height 32px - color rgba(0, 0, 0, 0.3) + color rgba(#000, 0.3) background #fff > footer diff --git a/src/client/app/common/views/components/messaging.vue b/src/client/app/common/views/components/messaging.vue index f74d9643eb..11f9c366d4 100644 --- a/src/client/app/common/views/components/messaging.vue +++ b/src/client/app/common/views/components/messaging.vue @@ -13,7 +13,7 @@ @click="navigate(user)" tabindex="-1" > - <img class="avatar" :src="`${user.avatarUrl}?thumbnail&size=32`" alt=""/> + <mk-avatar class="avatar" :user="user"/> <span class="name">{{ user | userName }}</span> <span class="username">@{{ user | acct }}</span> </li> @@ -31,7 +31,7 @@ :key="message.id" > <div> - <img class="avatar" :src="`${isMe(message) ? message.recipient.avatarUrl : message.user.avatarUrl}?thumbnail&size=64`" alt=""/> + <mk-avatar class="avatar" :user="isMe(message) ? message.recipient : message.user"/> <header> <span class="name">{{ isMe(message) ? message.recipient : message.user | userName }}</span> <span class="username">@{{ isMe(message) ? message.recipient : message.user | acct }}</span> @@ -169,7 +169,7 @@ export default Vue.extend({ <style lang="stylus" scoped> @import '~const.styl' -.mk-messaging +root(isDark) &[data-compact] font-size 0.8em @@ -205,11 +205,11 @@ export default Vue.extend({ z-index 1 width 100% background #fff - box-shadow 0 0px 2px rgba(0, 0, 0, 0.2) + box-shadow 0 0px 2px rgba(#000, 0.2) > .form padding 8px - background #f7f7f7 + background isDark ? #282c37 : #f7f7f7 > label display block @@ -241,13 +241,14 @@ export default Vue.extend({ line-height 38px color #000 outline none - border solid 1px #eee + background isDark ? #191b22 : #fff + border solid 1px isDark ? #495156 : #eee border-radius 5px box-shadow none transition color 0.5s ease, border 0.5s ease &:hover - border solid 1px #ddd + border solid 1px isDark ? #b0b0b0 : #ddd transition border 0.2s ease &:focus @@ -278,7 +279,7 @@ export default Vue.extend({ vertical-align top white-space nowrap overflow hidden - color rgba(0, 0, 0, 0.8) + color rgba(#000, 0.8) text-decoration none transition none cursor pointer @@ -317,32 +318,32 @@ export default Vue.extend({ margin 0 8px 0 0 /*font-weight bold*/ font-weight normal - color rgba(0, 0, 0, 0.8) + color rgba(#000, 0.8) .username font-weight normal - color rgba(0, 0, 0, 0.3) + color rgba(#000, 0.3) > .history > a display block text-decoration none - background #fff - border-bottom solid 1px #eee + background isDark ? #282c37 : #fff + border-bottom solid 1px isDark ? #1c2023 : #eee * pointer-events none user-select none &:hover - background #fafafa + background isDark ? #1e2129 : #fafafa > .avatar filter saturate(200%) &:active - background #eee + background isDark ? #14161b : #eee &[data-is-read] &[data-is-me] @@ -382,17 +383,17 @@ export default Vue.extend({ overflow hidden text-overflow ellipsis font-size 1em - color rgba(0, 0, 0, 0.9) + color isDark ? #fff : rgba(#000, 0.9) font-weight bold transition all 0.1s ease > .username margin 0 8px - color rgba(0, 0, 0, 0.5) + color isDark ? #606984 : rgba(#000, 0.5) > .mk-time margin 0 0 0 auto - color rgba(0, 0, 0, 0.5) + color isDark ? #606984 : rgba(#000, 0.5) font-size 80% > .avatar @@ -412,10 +413,10 @@ export default Vue.extend({ overflow hidden overflow-wrap break-word font-size 1.1em - color rgba(0, 0, 0, 0.8) + color isDark ? #fff : rgba(#000, 0.8) .me - color rgba(0, 0, 0, 0.4) + color isDark ? rgba(#fff, 0.7) : rgba(#000, 0.4) > .image display block @@ -460,4 +461,10 @@ export default Vue.extend({ > .avatar margin 0 12px 0 0 +.mk-messaging[data-darkmode] + root(true) + +.mk-messaging:not([data-darkmode]) + root(false) + </style> diff --git a/src/client/app/common/views/components/nav.vue b/src/client/app/common/views/components/nav.vue index 2295957928..cd1f99288a 100644 --- a/src/client/app/common/views/components/nav.vue +++ b/src/client/app/common/views/components/nav.vue @@ -2,16 +2,10 @@ <span class="mk-nav"> <a :href="aboutUrl">%i18n:@about%</a> <i>・</i> - <a :href="statsUrl">%i18n:@stats%</a> - <i>・</i> - <a :href="statusUrl">%i18n:@status%</a> - <i>・</i> - <a href="http://zawazawa.jp/misskey/">%i18n:@wiki%</a> - <i>・</i> - <a href="https://github.com/syuilo/misskey/blob/master/DONORS.md">%i18n:@donors%</a> - <i>・</i> <a href="https://github.com/syuilo/misskey">%i18n:@repository%</a> <i>・</i> + <a href="https://github.com/syuilo/misskey/issues/new" target="_blank">%i18n:@feedback%</a> + <i>・</i> <a :href="devUrl">%i18n:@develop%</a> <i>・</i> <a href="https://twitter.com/misskey_xyz" target="_blank">Follow us on %fa:B twitter%</a> diff --git a/src/client/app/common/views/components/note-html.ts b/src/client/app/common/views/components/note-html.ts index 24e750a671..f86b50659e 100644 --- a/src/client/app/common/views/components/note-html.ts +++ b/src/client/app/common/views/components/note-html.ts @@ -4,6 +4,7 @@ import parse from '../../../../../text/parse'; import getAcct from '../../../../../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), [] @@ -97,7 +98,9 @@ export default Vue.component('mk-note-html', { }, token.content); case 'code': - return createElement('pre', [ + return createElement('pre', { + class: 'code' + }, [ createElement('code', { domProps: { innerHTML: token.html @@ -132,10 +135,24 @@ export default Vue.component('mk-note-html', { }, text2.replace(/\n/g, ' ')); } + case 'title': + return createElement('div', { + attrs: { + class: 'title' + } + }, token.title); + case 'emoji': const emoji = emojilib.lib[token.emoji]; return createElement('span', emoji ? emoji.char : token.content); + case 'search': + return createElement(MkGoogle, { + props: { + q: token.query + } + }); + default: console.log('unknown ast type:', token.type); } @@ -144,7 +161,7 @@ export default Vue.component('mk-note-html', { const _els = []; els.forEach((el, i) => { if (el.tag == 'br') { - if (els[i - 1].tag != 'div') { + if (!['div', 'pre'].includes(els[i - 1].tag)) { _els.push(el); } } else { diff --git a/src/client/app/common/views/components/note-menu.vue b/src/client/app/common/views/components/note-menu.vue index 877d2c16bb..88dc22aaf4 100644 --- a/src/client/app/common/views/components/note-menu.vue +++ b/src/client/app/common/views/components/note-menu.vue @@ -2,6 +2,7 @@ <div class="mk-note-menu"> <div class="backdrop" ref="backdrop" @click="close"></div> <div class="popover" :class="{ compact }" ref="popover"> + <button @click="favorite">%i18n:@favorite%</button> <button v-if="note.userId == os.i.id" @click="pin">%i18n:@pin%</button> <a v-if="note.uri" :href="note.uri" target="_blank">%i18n:@remote%</a> </div> @@ -58,6 +59,14 @@ export default Vue.extend({ }); }, + favorite() { + (this as any).api('notes/favorites/create', { + noteId: this.note.id + }).then(() => { + this.$destroy(); + }); + }, + close() { (this.$refs.backdrop as any).style.pointerEvents = 'none'; anime({ @@ -96,7 +105,7 @@ $border-color = rgba(27, 31, 35, 0.15) z-index 10000 width 100% height 100% - background rgba(0, 0, 0, 0.1) + background rgba(#000, 0.1) opacity 0 > .popover @@ -142,6 +151,7 @@ $border-color = rgba(27, 31, 35, 0.15) > a display block padding 8px 16px + width 100% &:hover color $theme-color-foreground diff --git a/src/client/app/common/views/components/othello.vue b/src/client/app/common/views/components/othello.vue index 8f7d9dfd6a..a0971c45b4 100644 --- a/src/client/app/common/views/components/othello.vue +++ b/src/client/app/common/views/components/othello.vue @@ -31,7 +31,7 @@ <section v-if="invitations.length > 0"> <h2>対局の招待があります!:</h2> <div class="invitation" v-for="i in invitations" tabindex="-1" @click="accept(i)"> - <img :src="`${i.parent.avatarUrl}?thumbnail&size=32`" alt=""> + <mk-avatar class="avatar" :user="i.parent"/> <span class="name"><b>{{ i.parent.name }}</b></span> <span class="username">@{{ i.parent.username }}</span> <mk-time :time="i.createdAt"/> @@ -40,8 +40,8 @@ <section v-if="myGames.length > 0"> <h2>自分の対局</h2> <a class="game" v-for="g in myGames" tabindex="-1" @click.prevent="go(g)" :href="`/othello/${g.id}`"> - <img :src="`${g.user1.avatarUrl}?thumbnail&size=32`" alt=""> - <img :src="`${g.user2.avatarUrl}?thumbnail&size=32`" alt=""> + <mk-avatar class="avatar" :user="g.user1"/> + <mk-avatar class="avatar" :user="g.user2"/> <span><b>{{ g.user1.name }}</b> vs <b>{{ g.user2.name }}</b></span> <span class="state">{{ g.isEnded ? '終了' : '進行中' }}</span> </a> @@ -49,8 +49,8 @@ <section v-if="games.length > 0"> <h2>みんなの対局</h2> <a class="game" v-for="g in games" tabindex="-1" @click.prevent="go(g)" :href="`/othello/${g.id}`"> - <img :src="`${g.user1.avatarUrl}?thumbnail&size=32`" alt=""> - <img :src="`${g.user2.avatarUrl}?thumbnail&size=32`" alt=""> + <mk-avatar class="avatar" :user="g.user1"/> + <mk-avatar class="avatar" :user="g.user2"/> <span><b>{{ g.user1.name }}</b> vs <b>{{ g.user2.name }}</b></span> <span class="state">{{ g.isEnded ? '終了' : '進行中' }}</span> </a> @@ -271,8 +271,9 @@ export default Vue.extend({ &:active background #eee - > img - vertical-align bottom + > .avatar + width 32px + height 32px border-radius 100% > span @@ -301,8 +302,9 @@ export default Vue.extend({ &:active background #eee - > img - vertical-align bottom + > .avatar + width 32px + height 32px border-radius 100% > span diff --git a/src/client/app/common/views/components/poll-editor.vue b/src/client/app/common/views/components/poll-editor.vue index 189172679b..95bcba996e 100644 --- a/src/client/app/common/views/components/poll-editor.vue +++ b/src/client/app/common/views/components/poll-editor.vue @@ -69,7 +69,7 @@ export default Vue.extend({ <style lang="stylus" scoped> @import '~const.styl' -.mk-poll-editor +root(isDark) padding 8px > .caution @@ -102,6 +102,8 @@ export default Vue.extend({ padding 6px 8px width 300px font-size 14px + color isDark ? #fff : #000 + background isDark ? #191b22 : #fff border solid 1px rgba($theme-color, 0.1) border-radius 4px @@ -139,4 +141,10 @@ export default Vue.extend({ &:active color darken($theme-color, 30%) +.mk-poll-editor[data-darkmode] + root(true) + +.mk-poll-editor:not([data-darkmode]) + root(false) + </style> diff --git a/src/client/app/common/views/components/poll.vue b/src/client/app/common/views/components/poll.vue index 1834d4ddc2..46e41cbcdb 100644 --- a/src/client/app/common/views/components/poll.vue +++ b/src/client/app/common/views/components/poll.vue @@ -68,7 +68,7 @@ export default Vue.extend({ <style lang="stylus" scoped> @import '~const.styl' -.mk-poll +root(isDark) > ul display block @@ -81,16 +81,17 @@ export default Vue.extend({ margin 4px 0 padding 4px 8px width 100% - border solid 1px #eee + color isDark ? #fff : #000 + border solid 1px isDark ? #5e636f : #eee border-radius 4px overflow hidden cursor pointer &:hover - background rgba(0, 0, 0, 0.05) + background rgba(#000, 0.05) &:active - background rgba(0, 0, 0, 0.1) + background rgba(#000, 0.1) > .backdrop position absolute @@ -108,6 +109,8 @@ export default Vue.extend({ margin-left 4px > p + color isDark ? #a3aebf : #000 + a color inherit @@ -121,4 +124,10 @@ export default Vue.extend({ &:active background transparent +.mk-poll[data-darkmode] + root(true) + +.mk-poll:not([data-darkmode]) + root(false) + </style> diff --git a/src/client/app/common/views/components/reaction-picker.vue b/src/client/app/common/views/components/reaction-picker.vue index 267eeb3a14..e2c8a6ed3f 100644 --- a/src/client/app/common/views/components/reaction-picker.vue +++ b/src/client/app/common/views/components/reaction-picker.vue @@ -110,7 +110,7 @@ export default Vue.extend({ $border-color = rgba(27, 31, 35, 0.15) -.mk-reaction-picker +root(isDark) position initial > .backdrop @@ -120,13 +120,14 @@ $border-color = rgba(27, 31, 35, 0.15) z-index 10000 width 100% height 100% - background rgba(0, 0, 0, 0.1) + background isDark ? rgba(#000, 0.4) : rgba(#000, 0.1) opacity 0 > .popover + $bgcolor = isDark ? #2c303c : #fff position absolute z-index 10001 - background #fff + background $bgcolor border 1px solid $border-color border-radius 4px box-shadow 0 3px 12px rgba(27, 31, 35, 0.15) @@ -159,15 +160,15 @@ $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 $bgcolor > p display block margin 0 padding 8px 10px font-size 14px - color #586069 - border-bottom solid 1px #e1e4e8 + color isDark ? #d6dce2 : #586069 + border-bottom solid 1px isDark ? #1c2023 : #e1e4e8 > div padding 4px @@ -182,10 +183,16 @@ $border-color = rgba(27, 31, 35, 0.15) border-radius 2px &:hover - background #eee + background isDark ? #252731 : #eee &:active background $theme-color box-shadow inset 0 0.15em 0.3em rgba(27, 31, 35, 0.15) +.mk-reaction-picker[data-darkmode] + root(true) + +.mk-reaction-picker:not([data-darkmode]) + root(false) + </style> diff --git a/src/client/app/common/views/components/reactions-viewer.vue b/src/client/app/common/views/components/reactions-viewer.vue index 1afcf525d2..97cb6be17c 100644 --- a/src/client/app/common/views/components/reactions-viewer.vue +++ b/src/client/app/common/views/components/reactions-viewer.vue @@ -1,15 +1,15 @@ <template> <div class="mk-reactions-viewer"> <template v-if="reactions"> - <span v-if="reactions.like"><mk-reaction-icon reaction='like'/><span>{{ reactions.like }}</span></span> - <span v-if="reactions.love"><mk-reaction-icon reaction='love'/><span>{{ reactions.love }}</span></span> - <span v-if="reactions.laugh"><mk-reaction-icon reaction='laugh'/><span>{{ reactions.laugh }}</span></span> - <span v-if="reactions.hmm"><mk-reaction-icon reaction='hmm'/><span>{{ reactions.hmm }}</span></span> - <span v-if="reactions.surprise"><mk-reaction-icon reaction='surprise'/><span>{{ reactions.surprise }}</span></span> - <span v-if="reactions.congrats"><mk-reaction-icon reaction='congrats'/><span>{{ reactions.congrats }}</span></span> - <span v-if="reactions.angry"><mk-reaction-icon reaction='angry'/><span>{{ reactions.angry }}</span></span> - <span v-if="reactions.confused"><mk-reaction-icon reaction='confused'/><span>{{ reactions.confused }}</span></span> - <span v-if="reactions.pudding"><mk-reaction-icon reaction='pudding'/><span>{{ reactions.pudding }}</span></span> + <span v-if="reactions.like"><mk-reaction-icon reaction="like"/><span>{{ reactions.like }}</span></span> + <span v-if="reactions.love"><mk-reaction-icon reaction="love"/><span>{{ reactions.love }}</span></span> + <span v-if="reactions.laugh"><mk-reaction-icon reaction="laugh"/><span>{{ reactions.laugh }}</span></span> + <span v-if="reactions.hmm"><mk-reaction-icon reaction="hmm"/><span>{{ reactions.hmm }}</span></span> + <span v-if="reactions.surprise"><mk-reaction-icon reaction="surprise"/><span>{{ reactions.surprise }}</span></span> + <span v-if="reactions.congrats"><mk-reaction-icon reaction="congrats"/><span>{{ reactions.congrats }}</span></span> + <span v-if="reactions.angry"><mk-reaction-icon reaction="angry"/><span>{{ reactions.angry }}</span></span> + <span v-if="reactions.confused"><mk-reaction-icon reaction="confused"/><span>{{ reactions.confused }}</span></span> + <span v-if="reactions.pudding"><mk-reaction-icon reaction="pudding"/><span>{{ reactions.pudding }}</span></span> </template> </div> </template> @@ -27,9 +27,10 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> -.mk-reactions-viewer - border-top dashed 1px #eee - border-bottom dashed 1px #eee +root(isDark) + $borderColor = isDark ? #5e6673 : #eee + border-top dashed 1px $borderColor + border-bottom dashed 1px $borderColor margin 4px 0 &:empty @@ -44,6 +45,12 @@ export default Vue.extend({ > span margin-left 4px font-size 1.2em - color #444 + color isDark ? #d1d5dc : #444 + +.mk-reactions-viewer[data-darkmode] + root(true) + +.mk-reactions-viewer:not([data-darkmode]) + root(false) </style> diff --git a/src/client/app/common/views/components/signin.vue b/src/client/app/common/views/components/signin.vue index 25f90a2f13..7fb9fc3fd4 100644 --- a/src/client/app/common/views/components/signin.vue +++ b/src/client/app/common/views/components/signin.vue @@ -91,7 +91,7 @@ export default Vue.extend({ width 100% line-height 44px font-size 1em - color rgba(0, 0, 0, 0.7) + color rgba(#000, 0.7) background #fff outline none border solid 1px #eee @@ -117,7 +117,7 @@ export default Vue.extend({ margin -6px 0 0 0 width 100% font-size 1.2em - color rgba(0, 0, 0, 0.5) + color rgba(#000, 0.5) outline none border none border-radius 0 diff --git a/src/client/app/common/views/components/signup.vue b/src/client/app/common/views/components/signup.vue index 33a559ff8f..516979acd0 100644 --- a/src/client/app/common/views/components/signup.vue +++ b/src/client/app/common/views/components/signup.vue @@ -234,13 +234,13 @@ export default Vue.extend({ color #333 !important background #fff !important outline none - border solid 1px rgba(0, 0, 0, 0.1) + border solid 1px rgba(#000, 0.1) border-radius 4px box-shadow 0 0 0 114514px #fff inset transition all .3s ease &:hover - border-color rgba(0, 0, 0, 0.2) + border-color rgba(#000, 0.2) transition all .1s ease &:focus diff --git a/src/client/app/common/views/components/stream-indicator.vue b/src/client/app/common/views/components/stream-indicator.vue index 93758102de..d573db32e6 100644 --- a/src/client/app/common/views/components/stream-indicator.vue +++ b/src/client/app/common/views/components/stream-indicator.vue @@ -73,7 +73,7 @@ export default Vue.extend({ padding 6px 12px font-size 0.9em color #fff - background rgba(0, 0, 0, 0.8) + background rgba(#000, 0.8) border-radius 4px > p diff --git a/src/client/app/common/views/components/switch.vue b/src/client/app/common/views/components/switch.vue index 19a4adc3de..32caab638a 100644 --- a/src/client/app/common/views/components/switch.vue +++ b/src/client/app/common/views/components/switch.vue @@ -87,7 +87,7 @@ export default Vue.extend({ <style lang="stylus" scoped> @import '~const.styl' -.mk-switch +root(isDark) display flex margin 12px 0 cursor pointer @@ -121,11 +121,12 @@ export default Vue.extend({ &:hover > .label > span - color #2e3338 + color isDark ? #fff : #2e3338 > .button - background #ced2da - border-color #ced2da + $color = isDark ? #15181d : #ced2da + background $color + border-color $color > input position absolute @@ -147,14 +148,16 @@ export default Vue.extend({ border-radius 14px > .button + $color = isDark ? #1c1f25 : #dcdfe6 + display inline-block margin 0 width 40px min-width 40px height 20px min-height 20px - background #dcdfe6 - border 1px solid #dcdfe6 + background $color + border 1px solid $color outline none border-radius 10px transition inherit @@ -179,12 +182,18 @@ export default Vue.extend({ > span display block line-height 20px - color #4a535a + color isDark ? #c4ccd2 : #4a535a transition inherit > p margin 0 //font-size 90% - color #9daab3 + color isDark ? #78858e : #9daab3 + +.mk-switch[data-darkmode] + root(true) + +.mk-switch:not([data-darkmode]) + root(false) </style> diff --git a/src/client/app/common/views/components/twitter-setting.vue b/src/client/app/common/views/components/twitter-setting.vue index 6ca1037aba..ab07e6d09a 100644 --- a/src/client/app/common/views/components/twitter-setting.vue +++ b/src/client/app/common/views/components/twitter-setting.vue @@ -50,8 +50,6 @@ export default Vue.extend({ <style lang="stylus" scoped> .mk-twitter-setting - color #4a535a - .account border solid 1px #e1e8ed border-radius 4px diff --git a/src/client/app/common/views/components/url-preview.vue b/src/client/app/common/views/components/url-preview.vue index fd25480f61..3bae6e5078 100644 --- a/src/client/app/common/views/components/url-preview.vue +++ b/src/client/app/common/views/components/url-preview.vue @@ -2,8 +2,8 @@ <iframe v-if="youtubeId" type="text/html" height="250" :src="`https://www.youtube.com/embed/${youtubeId}?origin=${misskeyUrl}`" frameborder="0"/> -<div v-else> - <a class="mk-url-preview" :href="url" target="_blank" :title="url" v-if="!fetching"> +<div v-else class="mk-url-preview"> + <a :href="url" target="_blank" :title="url" v-if="!fetching"> <div class="thumbnail" v-if="thumbnail" :style="`background-image: url(${thumbnail})`"></div> <article> <header> @@ -45,7 +45,7 @@ export default Vue.extend({ } else if (url.hostname == 'youtu.be') { this.youtubeId = url.pathname; } else { - fetch('/url?url=' + this.url).then(res => { + fetch('/url?url=' + encodeURIComponent(this.url)).then(res => { res.json().then(info => { this.title = info.title; this.description = info.description; @@ -65,78 +65,85 @@ export default Vue.extend({ iframe width 100% -.mk-url-preview - display block - font-size 16px - border solid 1px #eee - border-radius 4px - overflow hidden +root(isDark) + > a + display block + font-size 16px + border solid 1px isDark ? #191b1f : #eee + border-radius 4px + overflow hidden - &:hover - text-decoration none - border-color #ddd + &:hover + text-decoration none + border-color isDark ? #4f5561 : #ddd - > article > header > h1 - text-decoration underline + > article > header > h1 + text-decoration underline - > .thumbnail - position absolute - width 100px - height 100% - background-position center - background-size cover + > .thumbnail + position absolute + width 100px + height 100% + background-position center + background-size cover + + & + article + left 100px + width calc(100% - 100px) - & + article - left 100px - width calc(100% - 100px) + > article + padding 16px - > article - padding 16px + > header + margin-bottom 8px - > header - margin-bottom 8px + > h1 + margin 0 + font-size 1em + color isDark ? #d6dae0 : #555 - > h1 + > p margin 0 - font-size 1em - color #555 + color isDark ? #a4aab3 : #777 + font-size 0.8em - > p - margin 0 - color #777 - font-size 0.8em + > footer + margin-top 8px + height 16px - > footer - margin-top 8px - height 16px + > img + display inline-block + width 16px + height 16px + margin-right 4px + vertical-align top - > img - display inline-block - width 16px - height 16px - margin-right 4px - vertical-align top + > p + display inline-block + margin 0 + color isDark ? #b0b4bf : #666 + font-size 0.8em + line-height 16px + vertical-align top - > p - display inline-block - margin 0 - color #666 - font-size 0.8em - line-height 16px - vertical-align top + @media (max-width 500px) + font-size 8px + border none - @media (max-width 500px) - font-size 8px - border none + > .thumbnail + width 70px - > .thumbnail - width 70px + & + article + left 70px + width calc(100% - 70px) - & + article - left 70px - width calc(100% - 70px) + > article + padding 8px - > article - padding 8px +.mk-url-preview[data-darkmode] + root(true) + +.mk-url-preview:not([data-darkmode]) + root(false) </style> diff --git a/src/client/app/common/views/components/visibility-chooser.vue b/src/client/app/common/views/components/visibility-chooser.vue new file mode 100644 index 0000000000..50f0877ae9 --- /dev/null +++ b/src/client/app/common/views/components/visibility-chooser.vue @@ -0,0 +1,223 @@ +<template> +<div class="mk-visibility-chooser"> + <div class="backdrop" ref="backdrop" @click="close"></div> + <div class="popover" :class="{ compact }" ref="popover"> + <div @click="choose('public')" :class="{ active: v == 'public' }"> + <div>%fa:globe%</div> + <div> + <span>公開</span> + </div> + </div> + <div @click="choose('home')" :class="{ active: v == 'home' }"> + <div>%fa:home%</div> + <div> + <span>ホーム</span> + <span>ホームタイムラインにのみ公開</span> + </div> + </div> + <div @click="choose('followers')" :class="{ active: v == 'followers' }"> + <div>%fa:unlock%</div> + <div> + <span>フォロワー</span> + <span>自分のフォロワーにのみ公開</span> + </div> + </div> + <div @click="choose('specified')" :class="{ active: v == 'specified' }"> + <div>%fa:envelope%</div> + <div> + <span>ダイレクト</span> + <span>指定したユーザーにのみ公開</span> + </div> + </div> + <div @click="choose('private')" :class="{ active: v == 'private' }"> + <div>%fa:lock%</div> + <div> + <span>非公開</span> + </div> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import * as anime from 'animejs'; + +export default Vue.extend({ + props: ['source', 'compact', 'v'], + mounted() { + this.$nextTick(() => { + const popover = this.$refs.popover as any; + + const rect = this.source.getBoundingClientRect(); + const width = popover.offsetWidth; + const height = popover.offsetHeight; + + let left; + let top; + + if (this.compact) { + const x = rect.left + window.pageXOffset + (this.source.offsetWidth / 2); + const y = rect.top + window.pageYOffset + (this.source.offsetHeight / 2); + left = (x - (width / 2)); + top = (y - (height / 2)); + } else { + const x = rect.left + window.pageXOffset + (this.source.offsetWidth / 2); + const y = rect.top + window.pageYOffset + this.source.offsetHeight; + left = (x - (width / 2)); + top = y; + } + + if (left + width > window.innerWidth) { + left = window.innerWidth - width; + } + + popover.style.left = left + 'px'; + popover.style.top = top + 'px'; + + anime({ + targets: this.$refs.backdrop, + opacity: 1, + duration: 100, + easing: 'linear' + }); + + anime({ + targets: this.$refs.popover, + opacity: 1, + scale: [0.5, 1], + duration: 500 + }); + }); + }, + methods: { + choose(visibility) { + this.$emit('chosen', visibility); + this.$destroy(); + }, + close() { + (this.$refs.backdrop as any).style.pointerEvents = 'none'; + anime({ + targets: this.$refs.backdrop, + opacity: 0, + duration: 200, + easing: 'linear' + }); + + (this.$refs.popover as any).style.pointerEvents = 'none'; + anime({ + targets: this.$refs.popover, + opacity: 0, + scale: 0.5, + duration: 200, + easing: 'easeInBack', + complete: () => this.$destroy() + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +$border-color = rgba(27, 31, 35, 0.15) + +root(isDark) + position initial + + > .backdrop + position fixed + top 0 + left 0 + z-index 10000 + width 100% + height 100% + background isDark ? rgba(#000, 0.4) : rgba(#000, 0.1) + opacity 0 + + > .popover + $bgcolor = isDark ? #2c303c : #fff + position absolute + z-index 10001 + width 240px + padding 8px 0 + background $bgcolor + border 1px solid $border-color + border-radius 4px + box-shadow 0 3px 12px rgba(27, 31, 35, 0.15) + transform scale(0.5) + opacity 0 + + $balloon-size = 10px + + &:not(.compact) + margin-top $balloon-size + transform-origin center -($balloon-size) + + &:before + content "" + display block + position absolute + top -($balloon-size * 2) + left s('calc(50% - %s)', $balloon-size) + border-top solid $balloon-size transparent + border-left solid $balloon-size transparent + border-right solid $balloon-size transparent + border-bottom solid $balloon-size $border-color + + &:after + content "" + display block + position absolute + top -($balloon-size * 2) + 1.5px + left s('calc(50% - %s)', $balloon-size) + border-top solid $balloon-size transparent + border-left solid $balloon-size transparent + border-right solid $balloon-size transparent + border-bottom solid $balloon-size $bgcolor + + > div + display flex + padding 8px 14px + font-size 12px + color isDark ? #fff : #666 + cursor pointer + + &:hover + background isDark ? #252731 : #eee + + &:active + background isDark ? #21242b : #ddd + + &.active + color $theme-color-foreground + background $theme-color + + > * + user-select none + pointer-events none + + > *:first-child + display flex + justify-content center + align-items center + margin-right 10px + + > *:last-child + flex 1 1 auto + + > span:first-child + display block + font-weight bold + + > span:last-child:not(:first-child) + opacity 0.6 + +.mk-visibility-chooser[data-darkmode] + root(true) + +.mk-visibility-chooser:not([data-darkmode]) + root(false) + +</style> diff --git a/src/client/app/common/views/components/welcome-timeline.vue b/src/client/app/common/views/components/welcome-timeline.vue index a80bc04f7f..6fadb030c3 100644 --- a/src/client/app/common/views/components/welcome-timeline.vue +++ b/src/client/app/common/views/components/welcome-timeline.vue @@ -1,9 +1,7 @@ <template> <div class="mk-welcome-timeline"> <div v-for="note in notes"> - <router-link class="avatar-anchor" :to="note.user | userPage" v-user-preview="note.user.id"> - <img class="avatar" :src="`${note.user.avatarUrl}?thumbnail&size=96`" alt="avatar"/> - </router-link> + <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> @@ -62,25 +60,22 @@ export default Vue.extend({ overflow-wrap break-word font-size .9em color #4C4C4C - border-bottom 1px solid rgba(0, 0, 0, 0.05) + border-bottom 1px solid rgba(#000, 0.05) &:after content "" display block clear both - > .avatar-anchor + > .avatar display block float left position -webkit-sticky position sticky top 16px - - > img - display block - width 42px - height 42px - border-radius 6px + width 42px + height 42px + border-radius 6px > .body float right diff --git a/src/client/app/common/views/widgets/access-log.vue b/src/client/app/common/views/widgets/access-log.vue index 637ba328c6..8652e35645 100644 --- a/src/client/app/common/views/widgets/access-log.vue +++ b/src/client/app/common/views/widgets/access-log.vue @@ -61,6 +61,7 @@ export default define({ } else { this.props.design++; } + this.save(); } } }); @@ -78,7 +79,7 @@ export default define({ color #555 &:nth-child(odd) - background rgba(0, 0, 0, 0.025) + background rgba(#000, 0.025) > b margin-right 4px diff --git a/src/client/app/common/views/widgets/broadcast.vue b/src/client/app/common/views/widgets/broadcast.vue index 96d1d0ef3a..75b1d60524 100644 --- a/src/client/app/common/views/widgets/broadcast.vue +++ b/src/client/app/common/views/widgets/broadcast.vue @@ -68,6 +68,7 @@ export default define({ } else { this.props.design++; } + this.save(); } } }); diff --git a/src/client/app/common/views/widgets/calendar.vue b/src/client/app/common/views/widgets/calendar.vue index 03f69a7597..41e9253784 100644 --- a/src/client/app/common/views/widgets/calendar.vue +++ b/src/client/app/common/views/widgets/calendar.vue @@ -73,6 +73,7 @@ export default define({ } else { this.props.design++; } + this.save(); }, tick() { const now = new Date(); @@ -109,11 +110,11 @@ export default define({ <style lang="stylus" scoped> @import '~const.styl' -.mkw-calendar +root(isDark) padding 16px 0 - color #777 - background #fff - border solid 1px rgba(0, 0, 0, 0.075) + color isDark ? #c5ced6 :#777 + background isDark ? #282C37 : #fff + border solid 1px rgba(#000, 0.075) border-radius 6px &[data-special='on-new-years-day'] @@ -126,7 +127,7 @@ export default define({ &[data-mobile] border none border-radius 8px - box-shadow 0 0 0 1px rgba(0, 0, 0, 0.2) + box-shadow 0 0 0 1px rgba(#000, 0.2) &:after content "" @@ -171,7 +172,7 @@ export default define({ margin 0 0 2px 0 font-size 12px line-height 18px - color #888 + color isDark ? #7a8692 : #888 > b margin-left 2px @@ -179,7 +180,7 @@ export default define({ > .meter width 100% overflow hidden - background #eee + background isDark ? #1c1f25 : #eee border-radius 8px > .val @@ -198,4 +199,10 @@ export default define({ > .meter > .val background #41ddde +.mkw-calendar[data-darkmode] + root(true) + +.mkw-calendar:not([data-darkmode]) + root(false) + </style> diff --git a/src/client/app/common/views/widgets/donation.vue b/src/client/app/common/views/widgets/donation.vue index 6b5a6697ed..e35462611d 100644 --- a/src/client/app/common/views/widgets/donation.vue +++ b/src/client/app/common/views/widgets/donation.vue @@ -19,9 +19,9 @@ export default define({ </script> <style lang="stylus" scoped> -.mkw-donation - background #fff - border solid 1px #ead8bb +root(isDark) + background isDark ? #282c37 : #fff + border solid 1px isDark ? #c3831c : #ead8bb border-radius 6px > article @@ -30,7 +30,7 @@ export default define({ > h1 margin 0 0 5px 0 font-size 1em - color #888 + color isDark ? #b2bac1 : #888 > [data-fa] margin-right 0.25em @@ -40,13 +40,13 @@ export default define({ z-index 1 margin 0 font-size 0.8em - color #999 + color isDark ? #a1a6ab : #999 &[data-mobile] border none background #ead8bb border-radius 8px - box-shadow 0 0 0 1px rgba(0, 0, 0, 0.2) + box-shadow 0 0 0 1px rgba(#000, 0.2) > article > h1 @@ -55,4 +55,10 @@ export default define({ > p color #777d71 +.mkw-donation[data-darkmode] + root(true) + +.mkw-donation:not([data-darkmode]) + root(false) + </style> diff --git a/src/client/app/common/views/widgets/nav.vue b/src/client/app/common/views/widgets/nav.vue index 7bd5a7832f..0cbf7c158e 100644 --- a/src/client/app/common/views/widgets/nav.vue +++ b/src/client/app/common/views/widgets/nav.vue @@ -1,7 +1,7 @@ <template> <div class="mkw-nav"> <mk-widget-container> - <div :class="$style.body"> + <div class="mkw-nav--body"> <mk-nav/> </div> </mk-widget-container> @@ -15,17 +15,24 @@ export default define({ }); </script> -<style lang="stylus" module> -.body - padding 16px - font-size 12px - color #aaa - background #fff +<style lang="stylus" scoped> +root(isDark) + .mkw-nav--body + padding 16px + font-size 12px + color isDark ? #9aa4b3 : #aaa + background isDark ? #282c37 : #fff - a - color #999 + a + color isDark ? #9aa4b3 : #999 - i - color #ccc + i + color isDark ? #9aa4b3 : #ccc + +.mkw-nav[data-darkmode] + root(true) + +.mkw-nav:not([data-darkmode]) + root(false) </style> diff --git a/src/client/app/common/views/widgets/photo-stream.vue b/src/client/app/common/views/widgets/photo-stream.vue index c51d932bd1..ae5924bb10 100644 --- a/src/client/app/common/views/widgets/photo-stream.vue +++ b/src/client/app/common/views/widgets/photo-stream.vue @@ -59,6 +59,8 @@ export default define({ } else { this.props.design++; } + + this.save(); } } }); diff --git a/src/client/app/common/views/widgets/rss.vue b/src/client/app/common/views/widgets/rss.vue index 4d74b2f7a4..b5339add0b 100644 --- a/src/client/app/common/views/widgets/rss.vue +++ b/src/client/app/common/views/widgets/rss.vue @@ -4,9 +4,11 @@ <template slot="header">%fa:rss-square%RSS</template> <button slot="func" title="設定" @click="setting">%fa:cog%</button> - <p :class="$style.fetching" v-if="fetching">%fa:spinner .pulse .fw%%i18n:common.loading%<mk-ellipsis/></p> - <div :class="$style.feed" v-else> - <a v-for="item in items" :href="item.link" target="_blank">{{ item.title }}</a> + <div class="mkw-rss--body"> + <p class="fetching" v-if="fetching">%fa:spinner .pulse .fw%%i18n:common.loading%<mk-ellipsis/></p> + <div class="feed" v-else> + <a v-for="item in items" :href="item.link" target="_blank">{{ item.title }}</a> + </div> </div> </mk-widget-container> </div> @@ -38,6 +40,7 @@ export default define({ methods: { func() { this.props.compact = !this.props.compact; + this.save(); }, fetch() { fetch(`https://api.rss2json.com/v1/api.json?rss_url=${this.url}`, { @@ -56,38 +59,46 @@ export default define({ }); </script> -<style lang="stylus" module> -.feed - padding 12px 16px - font-size 0.9em +<style lang="stylus" scoped> +root(isDark) + .mkw-rss--body + .feed + padding 12px 16px + font-size 0.9em - > a - display block - padding 4px 0 - color #666 - border-bottom dashed 1px #eee + > a + display block + padding 4px 0 + color isDark ? #9aa4b3 : #666 + border-bottom dashed 1px isDark ? #1c2023 : #eee - &:last-child - border-bottom none + &:last-child + border-bottom none -.fetching - margin 0 - padding 16px - text-align center - color #aaa + .fetching + margin 0 + padding 16px + text-align center + color #aaa - > [data-fa] - margin-right 4px + > [data-fa] + margin-right 4px -&[data-mobile] - .feed - padding 0 - font-size 1em + &[data-mobile] + .feed + padding 0 + font-size 1em - > a - padding 8px 16px + > a + padding 8px 16px - &:nth-child(even) - background rgba(0, 0, 0, 0.05) + &:nth-child(even) + background rgba(#000, 0.05) + +.mkw-rss[data-darkmode] + root(true) + +.mkw-rss:not([data-darkmode]) + root(false) </style> diff --git a/src/client/app/common/views/widgets/server.cpu-memory.vue b/src/client/app/common/views/widgets/server.cpu-memory.vue index d75a142568..fbd36b255a 100644 --- a/src/client/app/common/views/widgets/server.cpu-memory.vue +++ b/src/client/app/common/views/widgets/server.cpu-memory.vue @@ -100,7 +100,7 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> -.cpu-memory +root(isDark) > svg display block padding 10px @@ -115,7 +115,7 @@ export default Vue.extend({ > text font-size 5px - fill rgba(0, 0, 0, 0.55) + fill isDark ? rgba(#fff, 0.55) : rgba(#000, 0.55) > tspan opacity 0.5 @@ -124,4 +124,11 @@ export default Vue.extend({ content "" display block clear both + +.cpu-memory[data-darkmode] + root(true) + +.cpu-memory:not([data-darkmode]) + root(false) + </style> diff --git a/src/client/app/common/views/widgets/server.cpu.vue b/src/client/app/common/views/widgets/server.cpu.vue index 596c856da8..b9748bdf7c 100644 --- a/src/client/app/common/views/widgets/server.cpu.vue +++ b/src/client/app/common/views/widgets/server.cpu.vue @@ -38,7 +38,7 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> -.cpu +root(isDark) > .pie padding 10px height 100px @@ -52,7 +52,7 @@ export default Vue.extend({ > p margin 0 font-size 12px - color #505050 + color isDark ? #a8b4bd : #505050 &:first-child font-weight bold @@ -65,4 +65,10 @@ export default Vue.extend({ display block clear both +.cpu[data-darkmode] + root(true) + +.cpu:not([data-darkmode]) + root(false) + </style> diff --git a/src/client/app/common/views/widgets/server.disk.vue b/src/client/app/common/views/widgets/server.disk.vue index 2af1982a96..5c7e9678de 100644 --- a/src/client/app/common/views/widgets/server.disk.vue +++ b/src/client/app/common/views/widgets/server.disk.vue @@ -46,7 +46,7 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> -.disk +root(isDark) > .pie padding 10px height 100px @@ -60,7 +60,7 @@ export default Vue.extend({ > p margin 0 font-size 12px - color #505050 + color isDark ? #a8b4bd : #505050 &:first-child font-weight bold @@ -73,4 +73,10 @@ export default Vue.extend({ display block clear both +.disk[data-darkmode] + root(true) + +.disk:not([data-darkmode]) + root(false) + </style> diff --git a/src/client/app/common/views/widgets/server.memory.vue b/src/client/app/common/views/widgets/server.memory.vue index 834a62671d..9212f2271f 100644 --- a/src/client/app/common/views/widgets/server.memory.vue +++ b/src/client/app/common/views/widgets/server.memory.vue @@ -46,7 +46,7 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> -.memory +root(isDark) > .pie padding 10px height 100px @@ -60,7 +60,7 @@ export default Vue.extend({ > p margin 0 font-size 12px - color #505050 + color isDark ? #a8b4bd : #505050 &:first-child font-weight bold @@ -73,4 +73,10 @@ export default Vue.extend({ display block clear both +.memory[data-darkmode] + root(true) + +.memory:not([data-darkmode]) + root(false) + </style> diff --git a/src/client/app/common/views/widgets/server.pie.vue b/src/client/app/common/views/widgets/server.pie.vue index ce2cff1d00..d557c52ea5 100644 --- a/src/client/app/common/views/widgets/server.pie.vue +++ b/src/client/app/common/views/widgets/server.pie.vue @@ -45,7 +45,7 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> -svg +root(isDark) display block height 100% @@ -56,6 +56,12 @@ svg > text font-size 0.15px - fill rgba(0, 0, 0, 0.6) + fill isDark ? rgba(#fff, 0.6) : rgba(#000, 0.6) + +svg[data-darkmode] + root(true) + +svg:not([data-darkmode]) + root(false) </style> diff --git a/src/client/app/common/views/widgets/server.vue b/src/client/app/common/views/widgets/server.vue index 2fbc07adf0..2fdd60499b 100644 --- a/src/client/app/common/views/widgets/server.vue +++ b/src/client/app/common/views/widgets/server.vue @@ -68,6 +68,7 @@ export default define({ } else { this.props.view++; } + this.save(); }, func() { if (this.props.design == 2) { @@ -75,6 +76,7 @@ export default define({ } else { this.props.design++; } + this.save(); } } }); diff --git a/src/client/app/common/views/widgets/slideshow.vue b/src/client/app/common/views/widgets/slideshow.vue index ad32299f37..459b24a32f 100644 --- a/src/client/app/common/views/widgets/slideshow.vue +++ b/src/client/app/common/views/widgets/slideshow.vue @@ -64,6 +64,7 @@ export default define({ } else { this.props.size++; } + this.save(); this.applySize(); }, @@ -111,6 +112,7 @@ export default define({ choose() { (this as any).apis.chooseDriveFolder().then(folder => { this.props.folder = folder ? folder.id : null; + this.save(); this.fetch(); }); } @@ -122,13 +124,13 @@ export default define({ .mkw-slideshow overflow hidden background #fff - border solid 1px rgba(0, 0, 0, 0.075) + border solid 1px rgba(#000, 0.075) border-radius 6px &[data-mobile] border none border-radius 8px - box-shadow 0 0 0 1px rgba(0, 0, 0, 0.2) + box-shadow 0 0 0 1px rgba(#000, 0.2) > div width 100% diff --git a/src/client/app/common/views/widgets/tips.vue b/src/client/app/common/views/widgets/tips.vue index bdecc068e1..08e665f92f 100644 --- a/src/client/app/common/views/widgets/tips.vue +++ b/src/client/app/common/views/widgets/tips.vue @@ -17,7 +17,7 @@ const tips = [ 'ドライブでファイルをドラッグしてフォルダ移動できます', 'ドライブでフォルダをドラッグしてフォルダ移動できます', 'ホームは設定からカスタマイズできます', - 'MisskeyはMIT Licenseです', + 'MisskeyはAGPLv3です', 'タイムマシンウィジェットを利用すると、簡単に過去のタイムラインに遡れます', '投稿の ... をクリックして、投稿をユーザーページにピン留めできます', 'ドライブの容量は(デフォルトで)1GBです', diff --git a/src/client/app/desktop/api/update-avatar.ts b/src/client/app/desktop/api/update-avatar.ts index dc89adeb86..8ddaebc072 100644 --- a/src/client/app/desktop/api/update-avatar.ts +++ b/src/client/app/desktop/api/update-avatar.ts @@ -1,4 +1,4 @@ -import OS from '../../common/mios'; +import OS from '../../mios'; import { apiUrl } from '../../config'; import CropWindow from '../views/components/crop-window.vue'; import ProgressDialog from '../views/components/progress-dialog.vue'; diff --git a/src/client/app/desktop/api/update-banner.ts b/src/client/app/desktop/api/update-banner.ts index bc3f783e35..1a5da272bd 100644 --- a/src/client/app/desktop/api/update-banner.ts +++ b/src/client/app/desktop/api/update-banner.ts @@ -1,4 +1,4 @@ -import OS from '../../common/mios'; +import OS from '../../mios'; import { apiUrl } from '../../config'; import CropWindow from '../views/components/crop-window.vue'; import ProgressDialog from '../views/components/progress-dialog.vue'; @@ -95,7 +95,7 @@ export default (os: OS) => { multiple: false, title: '%fa:image%バナーにする画像を選択' }); - + return selectedFile .then(cropImage) .then(setBanner) diff --git a/src/client/app/desktop/script.ts b/src/client/app/desktop/script.ts index b3152e708b..2658a86b95 100644 --- a/src/client/app/desktop/script.ts +++ b/src/client/app/desktop/script.ts @@ -2,6 +2,7 @@ * Desktop Client */ +import Vue from 'vue'; import VueRouter from 'vue-router'; // Style @@ -24,8 +25,10 @@ import updateBanner from './api/update-banner'; import MkIndex from './views/pages/index.vue'; import MkUser from './views/pages/user/user.vue'; +import MkFavorites from './views/pages/favorites.vue'; import MkSelectDrive from './views/pages/selectdrive.vue'; import MkDrive from './views/pages/drive.vue'; +import MkUserList from './views/pages/user-list.vue'; import MkHomeCustomize from './views/pages/home-customize.vue'; import MkMessagingRoom from './views/pages/messaging-room.vue'; import MkNote from './views/pages/note.vue'; @@ -49,9 +52,11 @@ init(async (launch) => { routes: [ { path: '/', name: 'index', component: MkIndex }, { path: '/i/customize-home', component: MkHomeCustomize }, + { path: '/i/favorites', component: MkFavorites }, { path: '/i/messaging/:user', component: MkMessagingRoom }, { path: '/i/drive', component: MkDrive }, { path: '/i/drive/folder/:folder', component: MkDrive }, + { path: '/i/lists/:list', component: MkUserList }, { path: '/selectdrive', component: MkSelectDrive }, { path: '/search', component: MkSearch }, { path: '/othello', component: MkOthello }, diff --git a/src/client/app/desktop/style.styl b/src/client/app/desktop/style.styl index 49f71fbde7..ea48fbee3d 100644 --- a/src/client/app/desktop/style.styl +++ b/src/client/app/desktop/style.styl @@ -44,6 +44,26 @@ html height 100% background #f7f7f7 + &[data-darkmode] + background #191B22 + + &, * + &::-webkit-scrollbar-track + background-color #282C37 + + &::-webkit-scrollbar + width 6px + height 6px + + &::-webkit-scrollbar-thumb + background-color #454954 + + &:hover + background-color #535660 + + &:active + background-color $theme-color + body display flex flex-direction column diff --git a/src/client/app/desktop/ui.styl b/src/client/app/desktop/ui.styl index 5a8d1718e2..b66c8f4025 100644 --- a/src/client/app/desktop/ui.styl +++ b/src/client/app/desktop/ui.styl @@ -123,3 +123,59 @@ textarea.ui font-size 90% font-weight bold color rgba(#373a3c, 0.9) + +html[data-darkmode] + button.ui + .button.ui + color #fff + background linear-gradient(to bottom, #313543 0%, #282c37 100%) + border-color #1c2023 + + &:hover + background linear-gradient(to bottom, #2c2f3c 0%, #22262f 100%) + border-color #151a1d + + &:active + background #22262f + border-color #151a1d + + &.primary + color $theme-color-foreground + background linear-gradient(to bottom, lighten($theme-color, 25%) 0%, lighten($theme-color, 10%) 100%) + border solid 1px lighten($theme-color, 15%) + + &:hover:not(:disabled) + background linear-gradient(to bottom, lighten($theme-color, 8%) 0%, darken($theme-color, 8%) 100%) + border-color $theme-color + + &:active:not(:disabled) + background $theme-color + border-color $theme-color + + input:not([type]).ui + input[type='text'].ui + input[type='password'].ui + input[type='email'].ui + input[type='date'].ui + input[type='number'].ui + textarea.ui + display block + padding 10px + width 100% + height 40px + font-family sans-serif + font-size 16px + color #dee4e8 + background #191b22 + border solid 1px #495156 + border-radius 4px + + &:hover + border-color #b0b0b0 + + &:focus + border-color $theme-color + + .ui.from.group + > p:first-child + color #c0c7cc diff --git a/src/client/app/desktop/views/components/activity.calendar.vue b/src/client/app/desktop/views/components/activity.calendar.vue index 8b43536c2b..e488571070 100644 --- a/src/client/app/desktop/views/components/activity.calendar.vue +++ b/src/client/app/desktop/views/components/activity.calendar.vue @@ -61,6 +61,6 @@ svg &.day &:hover - fill rgba(0, 0, 0, 0.05) + fill rgba(#000, 0.05) </style> diff --git a/src/client/app/desktop/views/components/activity.vue b/src/client/app/desktop/views/components/activity.vue index ea33bf9ff6..bd952c39d2 100644 --- a/src/client/app/desktop/views/components/activity.vue +++ b/src/client/app/desktop/views/components/activity.vue @@ -1,14 +1,15 @@ <template> -<div class="mk-activity" :data-melt="design == 2"> - <template v-if="design == 0"> - <p class="title">%fa:chart-bar%%i18n:@title%</p> - <button @click="toggle" title="%i18n:@toggle%">%fa:sort%</button> - </template> - <p class="fetching" v-if="fetching">%fa:spinner .pulse .fw%%i18n:common.loading%<mk-ellipsis/></p> - <template v-else> - <x-calendar v-show="view == 0" :data="[].concat(activity)"/> - <x-chart v-show="view == 1" :data="[].concat(activity)"/> - </template> +<div class="mk-activity"> + <mk-widget-container :show-header="design == 0" :naked="design == 2"> + <template slot="header">%fa:chart-bar%%i18n:@title%</template> + <button slot="func" title="%i18n:@toggle%" @click="toggle">%fa:sort%</button> + + <p :class="$style.fetching" v-if="fetching">%fa:spinner .pulse .fw%%i18n:common.loading%<mk-ellipsis/></p> + <template v-else> + <x-calendar v-show="view == 0" :data="[].concat(activity)"/> + <x-chart v-show="view == 1" :data="[].concat(activity)"/> + </template> + </mk-widget-container> </div> </template> @@ -64,53 +65,14 @@ export default Vue.extend({ }); </script> -<style lang="stylus" scoped> -.mk-activity - background #fff - border solid 1px rgba(0, 0, 0, 0.075) - border-radius 6px - - &[data-melt] - background transparent !important - border none !important - - > .title - z-index 1 - margin 0 - padding 0 16px - line-height 42px - font-size 0.9em - font-weight bold - color #888 - box-shadow 0 1px rgba(0, 0, 0, 0.07) - - > [data-fa] - margin-right 4px - - > button - position absolute - z-index 2 - top 0 - right 0 - padding 0 - width 42px - font-size 0.9em - line-height 42px - color #ccc - - &:hover - color #aaa - - &:active - color #999 - - > .fetching - margin 0 - padding 16px - text-align center - color #aaa +<style lang="stylus" module> +.fetching + margin 0 + padding 16px + text-align center + color #aaa - > [data-fa] - margin-right 4px + > [data-fa] + margin-right 4px </style> diff --git a/src/client/app/desktop/views/components/calendar.vue b/src/client/app/desktop/views/components/calendar.vue index a99b48d195..1d8cc4f3a9 100644 --- a/src/client/app/desktop/views/components/calendar.vue +++ b/src/client/app/desktop/views/components/calendar.vue @@ -133,10 +133,10 @@ export default Vue.extend({ <style lang="stylus" scoped> @import '~const.styl' -.mk-calendar - color #777 - background #fff - border solid 1px rgba(0, 0, 0, 0.075) +root(isDark) + color isDark ? #c5ced6 : #777 + background isDark ? #282C37 : #fff + border solid 1px rgba(#000, 0.075) border-radius 6px &[data-melt] @@ -152,7 +152,7 @@ export default Vue.extend({ font-size 0.9em font-weight bold color #888 - box-shadow 0 1px rgba(0, 0, 0, 0.07) + box-shadow 0 1px rgba(#000, 0.07) > [data-fa] margin-right 4px @@ -214,10 +214,10 @@ export default Vue.extend({ border-radius 6px &:hover > div - background rgba(0, 0, 0, 0.025) + background rgba(#000, 0.025) &:active > div - background rgba(0, 0, 0, 0.05) + background rgba(#000, 0.05) &[data-is-donichi] color #ef95a0 @@ -233,10 +233,10 @@ export default Vue.extend({ font-weight bold > div - background rgba(0, 0, 0, 0.025) + background rgba(#000, 0.025) &:active > div - background rgba(0, 0, 0, 0.05) + background rgba(#000, 0.05) &[data-today] > div @@ -249,4 +249,10 @@ export default Vue.extend({ &:active > div background darken($theme-color, 10%) +.mk-calendar[data-darkmode] + root(true) + +.mk-calendar:not([data-darkmode]) + root(false) + </style> diff --git a/src/client/app/desktop/views/components/context-menu.menu.vue b/src/client/app/desktop/views/components/context-menu.menu.vue index 6359dbf1b4..843604a059 100644 --- a/src/client/app/desktop/views/components/context-menu.menu.vue +++ b/src/client/app/desktop/views/components/context-menu.menu.vue @@ -31,7 +31,7 @@ export default Vue.extend({ <style lang="stylus" scoped> @import '~const.styl' -.menu +root(isDark) $width = 240px $item-height = 38px $padding = 10px @@ -46,7 +46,7 @@ export default Vue.extend({ &.divider margin-top $padding padding-top $padding - border-top solid 1px #eee + border-top solid 1px isDark ? #1c2023 : #eee &.nest > p @@ -75,7 +75,7 @@ export default Vue.extend({ margin 0 padding 0 32px 0 38px line-height $item-height - color #868C8C + color isDark ? #c8cece : #868C8C text-decoration none cursor pointer @@ -104,11 +104,17 @@ export default Vue.extend({ left $width margin-top -($padding) width $width - background #fff + background isDark ? #282c37 :#fff border-radius 0 4px 4px 4px - box-shadow 2px 2px 8px rgba(0, 0, 0, 0.2) + box-shadow 2px 2px 8px rgba(#000, 0.2) transition visibility 0s linear 0.2s +.menu[data-darkmode] + root(true) + +.menu:not([data-darkmode]) + root(false) + </style> <style lang="stylus" module> diff --git a/src/client/app/desktop/views/components/context-menu.vue b/src/client/app/desktop/views/components/context-menu.vue index 8bd9945840..60a33f9c93 100644 --- a/src/client/app/desktop/views/components/context-menu.vue +++ b/src/client/app/desktop/views/components/context-menu.vue @@ -54,7 +54,7 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> -.context-menu +root(isDark) $width = 240px $item-height = 38px $padding = 10px @@ -66,9 +66,15 @@ export default Vue.extend({ z-index 4096 width $width font-size 0.8em - background #fff + background isDark ? #282c37 : #fff border-radius 0 4px 4px 4px - box-shadow 2px 2px 8px rgba(0, 0, 0, 0.2) + box-shadow 2px 2px 8px rgba(#000, 0.2) opacity 0 +.context-menu[data-darkmode] + root(true) + +.context-menu:not([data-darkmode]) + root(false) + </style> diff --git a/src/client/app/desktop/views/components/dialog.vue b/src/client/app/desktop/views/components/dialog.vue index fa17e4a9d2..aff21c1754 100644 --- a/src/client/app/desktop/views/components/dialog.vue +++ b/src/client/app/desktop/views/components/dialog.vue @@ -102,7 +102,7 @@ export default Vue.extend({ left 0 width 100% height 100% - background rgba(0, 0, 0, 0.7) + background rgba(#000, 0.7) opacity 0 pointer-events none diff --git a/src/client/app/desktop/views/components/drive.file.vue b/src/client/app/desktop/views/components/drive.file.vue index d79cb6c09c..39881711fa 100644 --- a/src/client/app/desktop/views/components/drive.file.vue +++ b/src/client/app/desktop/views/components/drive.file.vue @@ -186,7 +186,7 @@ export default Vue.extend({ <style lang="stylus" scoped> @import '~const.styl' -.root.file +root(isDark) padding 8px 0 0 0 height 180px border-radius 4px @@ -195,7 +195,7 @@ export default Vue.extend({ cursor pointer &:hover - background rgba(0, 0, 0, 0.05) + background rgba(#000, 0.05) > .label &:before @@ -203,7 +203,7 @@ export default Vue.extend({ background #0b65a5 &:active - background rgba(0, 0, 0, 0.1) + background rgba(#000, 0.1) > .label &:before @@ -308,10 +308,16 @@ export default Vue.extend({ font-size 0.8em text-align center word-break break-all - color #444 + color isDark ? #fff : #444 overflow hidden > .ext opacity 0.5 +.root.file[data-darkmode] + root(true) + +.root.file:not([data-darkmode]) + root(false) + </style> diff --git a/src/client/app/desktop/views/components/drive.vue b/src/client/app/desktop/views/components/drive.vue index 5e91048d19..973df1014d 100644 --- a/src/client/app/desktop/views/components/drive.vue +++ b/src/client/app/desktop/views/components/drive.vue @@ -577,7 +577,7 @@ export default Vue.extend({ <style lang="stylus" scoped> @import '~const.styl' -.mk-drive +root(isDark) > nav display block @@ -585,10 +585,9 @@ export default Vue.extend({ width 100% overflow auto font-size 0.9em - color #555 - background #fff - //border-bottom 1px solid #dfdfdf - box-shadow 0 1px 0 rgba(0, 0, 0, 0.05) + color isDark ? #d2d9dc : #555 + background isDark ? #282c37 : #fff + box-shadow 0 1px 0 rgba(#000, 0.05) &, * user-select none @@ -665,6 +664,7 @@ export default Vue.extend({ padding 8px height calc(100% - 38px) overflow auto + background isDark ? #191b22 : #fff &, * user-select none @@ -733,7 +733,7 @@ export default Vue.extend({ display inline-block position absolute top 0 - background-color rgba(0, 0, 0, 0.3) + background-color rgba(#000, 0.3) border-radius 100% animation sk-bounce 2.0s infinite ease-in-out @@ -770,4 +770,10 @@ export default Vue.extend({ > input display none +.mk-drive[data-darkmode] + root(true) + +.mk-drive:not([data-darkmode]) + root(false) + </style> diff --git a/src/client/app/desktop/views/components/ellipsis-icon.vue b/src/client/app/desktop/views/components/ellipsis-icon.vue index c54a7db29d..4a5a0f23dc 100644 --- a/src/client/app/desktop/views/components/ellipsis-icon.vue +++ b/src/client/app/desktop/views/components/ellipsis-icon.vue @@ -14,7 +14,7 @@ display inline-block width 18px height 18px - background-color rgba(0, 0, 0, 0.3) + background-color rgba(#000, 0.3) border-radius 100% animation bounce 1.4s infinite ease-in-out both diff --git a/src/client/app/desktop/views/components/follow-button.vue b/src/client/app/desktop/views/components/follow-button.vue index 9eb22b0fb8..60c6129f61 100644 --- a/src/client/app/desktop/views/components/follow-button.vue +++ b/src/client/app/desktop/views/components/follow-button.vue @@ -19,6 +19,7 @@ <script lang="ts"> import Vue from 'vue'; + export default Vue.extend({ props: { user: { @@ -30,6 +31,7 @@ export default Vue.extend({ default: 'compact' } }, + data() { return { wait: false, @@ -37,6 +39,7 @@ export default Vue.extend({ connectionId: null }; }, + mounted() { this.connection = (this as any).os.stream.getConnection(); this.connectionId = (this as any).os.stream.use(); @@ -44,13 +47,14 @@ export default Vue.extend({ this.connection.on('follow', this.onFollow); this.connection.on('unfollow', this.onUnfollow); }, + beforeDestroy() { this.connection.off('follow', this.onFollow); this.connection.off('unfollow', this.onUnfollow); (this as any).os.stream.dispose(this.connectionId); }, - methods: { + methods: { onFollow(user) { if (user.id == this.user.id) { this.user.isFollowing = user.isFollowing; @@ -94,7 +98,7 @@ export default Vue.extend({ <style lang="stylus" scoped> @import '~const.styl' -.mk-follow-button +root(isDark) display block cursor pointer padding 0 @@ -121,17 +125,17 @@ export default Vue.extend({ border-radius 8px &.follow - color #888 - background linear-gradient(to bottom, #ffffff 0%, #f5f5f5 100%) - border solid 1px #e2e2e2 + color isDark ? #fff : #888 + background isDark ? linear-gradient(to bottom, #313543 0%, #282c37 100%) : linear-gradient(to bottom, #ffffff 0%, #f5f5f5 100%) + border solid 1px isDark ? #1c2023 : #e2e2e2 &:hover - background linear-gradient(to bottom, #f9f9f9 0%, #ececec 100%) - border-color #dcdcdc + background isDark ? linear-gradient(to bottom, #2c2f3c 0%, #22262f 100%) : linear-gradient(to bottom, #f9f9f9 0%, #ececec 100%) + border-color isDark ? #151a1d : #dcdcdc &:active - background #ececec - border-color #dcdcdc + background isDark ? #22262f : #ececec + border-color isDark ? #151a1d : #dcdcdc &.unfollow color $theme-color-foreground @@ -161,4 +165,10 @@ export default Vue.extend({ i margin-right 8px +.mk-follow-button[data-darkmode] + root(true) + +.mk-follow-button:not([data-darkmode]) + root(false) + </style> diff --git a/src/client/app/desktop/views/components/friends-maker.vue b/src/client/app/desktop/views/components/friends-maker.vue index af5bde3ad5..3c1f8b8257 100644 --- a/src/client/app/desktop/views/components/friends-maker.vue +++ b/src/client/app/desktop/views/components/friends-maker.vue @@ -3,9 +3,7 @@ <p class="title">気になるユーザーをフォロー:</p> <div class="users" v-if="!fetching && users.length > 0"> <div class="user" v-for="user in users" :key="user.id"> - <router-link class="avatar-anchor" :to="user | userPage"> - <img class="avatar" :src="`${user.avatarUrl}?thumbnail&size=42`" alt="" v-user-preview="user.id"/> - </router-link> + <mk-avatar class="avatar" :user="user" target="_blank"/> <div class="body"> <router-link class="name" :to="user | userPage" v-user-preview="user.id">{{ user | userName }}</router-link> <p class="username">@{{ user | acct }}</p> @@ -86,18 +84,13 @@ export default Vue.extend({ display block clear both - > .avatar-anchor + > .avatar display block float left margin 0 12px 0 0 - - > .avatar - display block - width 42px - height 42px - margin 0 - border-radius 8px - vertical-align bottom + width 42px + height 42px + border-radius 8px > .body float left diff --git a/src/client/app/desktop/views/components/home.vue b/src/client/app/desktop/views/components/home.vue index 90e9d1b785..cae6233cd8 100644 --- a/src/client/app/desktop/views/components/home.vue +++ b/src/client/app/desktop/views/components/home.vue @@ -53,7 +53,7 @@ <div class="main"> <a @click="hint">カスタマイズのヒント</a> <div> - <mk-post-form v-if="os.i.clientSettings.showPostFormOnTopOfTl"/> + <mk-post-form v-if="clientSettings.showPostFormOnTopOfTl"/> <mk-timeline ref="tl" @loaded="onTlLoaded"/> </div> </div> @@ -63,7 +63,7 @@ <component v-for="widget in widgets[place]" :is="`mkw-${widget.name}`" :key="widget.id" :ref="widget.id" :widget="widget" @chosen="warp"/> </div> <div class="main"> - <mk-post-form v-if="os.i.clientSettings.showPostFormOnTopOfTl"/> + <mk-post-form v-if="clientSettings.showPostFormOnTopOfTl"/> <mk-timeline ref="tl" @loaded="onTlLoaded" v-if="mode == 'timeline'"/> <mk-mentions @loaded="onTlLoaded" v-if="mode == 'mentions'"/> </div> @@ -81,6 +81,7 @@ export default Vue.extend({ components: { XDraggable }, + props: { customize: { type: Boolean, @@ -91,61 +92,43 @@ export default Vue.extend({ default: 'timeline' } }, + data() { return { connection: null, connectionId: null, widgetAdderSelected: null, - trash: [], - widgets: { - left: [], - right: [] - } + trash: [] }; }, + computed: { - home: { - get(): any[] { - //#region 互換性のため - (this as any).os.i.clientSettings.home.forEach(w => { - if (w.name == 'rss-reader') w.name = 'rss'; - if (w.name == 'user-recommendation') w.name = 'users'; - if (w.name == 'recommended-polls') w.name = 'polls'; - }); - //#endregion - return (this as any).os.i.clientSettings.home; - }, - set(value) { - (this as any).os.i.clientSettings.home = value; - } + home(): any[] { + return this.$store.state.settings.data.home; }, left(): any[] { return this.home.filter(w => w.place == 'left'); }, right(): any[] { return this.home.filter(w => w.place == 'right'); + }, + widgets(): any { + return { + left: this.left, + right: this.right + }; } }, - created() { - this.widgets.left = this.left; - this.widgets.right = this.right; - this.$watch('os.i.clientSettings', i => { - this.widgets.left = this.left; - this.widgets.right = this.right; - }, { - deep: true - }); - }, + mounted() { this.connection = (this as any).os.stream.getConnection(); this.connectionId = (this as any).os.stream.use(); - - this.connection.on('home_updated', this.onHomeUpdated); }, + beforeDestroy() { - this.connection.off('home_updated', this.onHomeUpdated); (this as any).os.stream.dispose(this.connectionId); }, + methods: { hint() { (this as any).apis.dialog({ @@ -159,56 +142,44 @@ export default Vue.extend({ }] }); }, + onTlLoaded() { this.$emit('loaded'); }, - onHomeUpdated(data) { - if (data.home) { - (this as any).os.i.clientSettings.home = data.home; - this.widgets.left = data.home.filter(w => w.place == 'left'); - this.widgets.right = data.home.filter(w => w.place == 'right'); - } else { - const w = (this as any).os.i.clientSettings.home.find(w => w.id == data.id); - if (w != null) { - w.data = data.data; - this.$refs[w.id][0].preventSave = true; - this.$refs[w.id][0].props = w.data; - this.widgets.left = (this as any).os.i.clientSettings.home.filter(w => w.place == 'left'); - this.widgets.right = (this as any).os.i.clientSettings.home.filter(w => w.place == 'right'); - } - } - }, + onWidgetContextmenu(widgetId) { const w = (this.$refs[widgetId] as any)[0]; if (w.func) w.func(); }, + onWidgetSort() { this.saveHome(); }, + onTrash(evt) { this.saveHome(); }, + addWidget() { - const widget = { + this.$store.dispatch('settings/addHomeWidget', { name: this.widgetAdderSelected, id: uuid(), place: 'left', data: {} - }; - - this.widgets.left.unshift(widget); - this.saveHome(); + }); }, + saveHome() { const left = this.widgets.left; const right = this.widgets.right; - this.home = left.concat(right); + this.$store.commit('settings/setHome', left.concat(right)); left.forEach(w => w.place = 'left'); right.forEach(w => w.place = 'right'); (this as any).api('i/update_home', { home: this.home }); }, + warp(date) { (this.$refs.tl as any).warp(date); } @@ -219,7 +190,7 @@ export default Vue.extend({ <style lang="stylus" scoped> @import '~const.styl' -.mk-home +root(isDark) display block &[data-customize] @@ -249,8 +220,9 @@ export default Vue.extend({ left 0 width 100% height 48px - background #f7f7f7 - box-shadow 0 1px 1px rgba(0, 0, 0, 0.075) + color isDark ? #fff : #000 + background isDark ? #313543 : #f7f7f7 + box-shadow 0 1px 1px rgba(#000, 0.075) > a display block @@ -278,7 +250,7 @@ export default Vue.extend({ > div display flex margin 0 auto - max-width 1200px - 32px + max-width 1220px - 32px > div width 50% @@ -289,7 +261,7 @@ export default Vue.extend({ line-height 48px &.trash - border-left solid 1px #ddd + border-left solid 1px isDark ? #1c2023 : #ddd > div width 100% @@ -309,7 +281,7 @@ export default Vue.extend({ display flex justify-content center margin 0 auto - max-width 1200px + max-width 1220px > * .customize-container @@ -329,7 +301,7 @@ export default Vue.extend({ .mk-post-form margin-bottom 16px - border solid 1px #e5e5e5 + border solid 1px rgba(#000, 0.075) border-radius 4px > *:not(.main) @@ -357,4 +329,10 @@ export default Vue.extend({ max-width 700px margin 0 auto +.mk-home[data-darkmode] + root(true) + +.mk-home:not([data-darkmode]) + root(false) + </style> diff --git a/src/client/app/desktop/views/components/index.ts b/src/client/app/desktop/views/components/index.ts index 4f61f43692..f58d0706df 100644 --- a/src/client/app/desktop/views/components/index.ts +++ b/src/client/app/desktop/views/components/index.ts @@ -28,6 +28,7 @@ import friendsMaker from './friends-maker.vue'; import followers from './followers.vue'; import following from './following.vue'; import usersList from './users-list.vue'; +import userListTimeline from './user-list-timeline.vue'; import widgetContainer from './widget-container.vue'; Vue.component('mk-ui', ui); @@ -58,4 +59,5 @@ Vue.component('mk-friends-maker', friendsMaker); Vue.component('mk-followers', followers); Vue.component('mk-following', following); Vue.component('mk-users-list', usersList); +Vue.component('mk-user-list-timeline', userListTimeline); Vue.component('mk-widget-container', widgetContainer); 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 dec140d1c9..026522d907 100644 --- a/src/client/app/desktop/views/components/media-image-dialog.vue +++ b/src/client/app/desktop/views/components/media-image-dialog.vue @@ -52,7 +52,7 @@ export default Vue.extend({ left 0 width 100% height 100% - background rgba(0, 0, 0, 0.7) + background rgba(#000, 0.7) > img position fixed diff --git a/src/client/app/desktop/views/components/media-image.vue b/src/client/app/desktop/views/components/media-image.vue index 51309a0578..e5803cc36e 100644 --- a/src/client/app/desktop/views/components/media-image.vue +++ b/src/client/app/desktop/views/components/media-image.vue @@ -14,12 +14,20 @@ import Vue from 'vue'; import MkMediaImageDialog from './media-image-dialog.vue'; export default Vue.extend({ - props: ['image'], + props: { + image: { + type: Object, + required: true + }, + raw: { + default: false + } + }, computed: { style(): any { return { 'background-color': this.image.properties.avgColor ? `rgb(${this.image.properties.avgColor.join(',')})` : 'transparent', - 'background-image': `url(${this.image.url}?thumbnail&size=512)` + 'background-image': this.raw ? `url(${this.image.url})` : `url(${this.image.url}?thumbnail&size=512)` }; } }, @@ -31,7 +39,7 @@ export default Vue.extend({ const xp = mouseX / this.$el.offsetWidth * 100; const yp = mouseY / this.$el.offsetHeight * 100; this.$el.style.backgroundPosition = xp + '% ' + yp + '%'; - this.$el.style.backgroundImage = 'url("' + this.image.url + '?thumbnail")'; + this.$el.style.backgroundImage = `url("${this.image.url}")`; }, onMouseleave() { 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 cbf862cd1c..959cefa42c 100644 --- a/src/client/app/desktop/views/components/media-video-dialog.vue +++ b/src/client/app/desktop/views/components/media-video-dialog.vue @@ -54,7 +54,7 @@ export default Vue.extend({ left 0 width 100% height 100% - background rgba(0, 0, 0, 0.7) + background rgba(#000, 0.7) > video position fixed diff --git a/src/client/app/desktop/views/components/media-video.vue b/src/client/app/desktop/views/components/media-video.vue index 4fd955a821..3635941e64 100644 --- a/src/client/app/desktop/views/components/media-video.vue +++ b/src/client/app/desktop/views/components/media-video.vue @@ -52,6 +52,7 @@ export default Vue.extend({ width 100% height 100% border-radius 4px + .mk-media-video-thumbnail display flex justify-content center diff --git a/src/client/app/desktop/views/components/mentions.vue b/src/client/app/desktop/views/components/mentions.vue index fc3a7af75d..66bdab5c08 100644 --- a/src/client/app/desktop/views/components/mentions.vue +++ b/src/client/app/desktop/views/components/mentions.vue @@ -1,8 +1,8 @@ <template> <div class="mk-mentions"> <header> - <span :data-is-active="mode == 'all'" @click="mode = 'all'">すべて</span> - <span :data-is-active="mode == 'following'" @click="mode = 'following'">フォロー中</span> + <span :data-active="mode == 'all'" @click="mode = 'all'">すべて</span> + <span :data-active="mode == 'following'" @click="mode = 'following'">フォロー中</span> </header> <div class="fetching" v-if="fetching"> <mk-ellipsis-icon/> @@ -85,7 +85,7 @@ export default Vue.extend({ .mk-mentions background #fff - border solid 1px rgba(0, 0, 0, 0.075) + border solid 1px rgba(#000, 0.075) border-radius 6px > header @@ -98,7 +98,7 @@ export default Vue.extend({ font-size 18px color #555 - &:not([data-is-active]) + &:not([data-active]) color $theme-color cursor pointer diff --git a/src/client/app/desktop/views/components/note-detail.sub.vue b/src/client/app/desktop/views/components/note-detail.sub.vue index 16bc2a1d98..24550c4e94 100644 --- a/src/client/app/desktop/views/components/note-detail.sub.vue +++ b/src/client/app/desktop/views/components/note-detail.sub.vue @@ -1,8 +1,6 @@ <template> <div class="sub" :title="title"> - <router-link class="avatar-anchor" :to="note.user | userPage"> - <img class="avatar" :src="`${note.user.avatarUrl}?thumbnail&size=64`" alt="avatar" v-user-preview="note.userId"/> - </router-link> + <mk-avatar class="avatar" :user="note.user"/> <div class="main"> <header> <div class="left"> @@ -16,8 +14,11 @@ </div> </header> <div class="body"> - <mk-note-html v-if="note.text" :text="note.text" :i="os.i" :class="$style.text"/> - <div class="media" v-if="note.media > 0"> + <div class="text"> + <span v-if="note.isHidden" style="opacity: 0.5">(この投稿は非公開です)</span> + <mk-note-html v-if="note.text" :text="note.text" :i="os.i"/> + </div> + <div class="media" v-if="note.mediaIds.length > 0"> <mk-media-list :media-list="note.media"/> </div> </div> @@ -40,10 +41,10 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> -.sub +root(isDark) margin 0 padding 20px 32px - background #fdfdfd + background isDark ? #21242d : #fdfdfd &:after content "" @@ -54,18 +55,13 @@ export default Vue.extend({ > .main > footer > button color #888 - > .avatar-anchor + > .avatar display block float left margin 0 16px 0 0 - - > .avatar - display block - width 44px - height 44px - margin 0 - border-radius 4px - vertical-align bottom + width 44px + height 44px + border-radius 4px > .main float left @@ -87,7 +83,7 @@ export default Vue.extend({ display inline margin 0 padding 0 - color #777 + color isDark ? #fff : #777 font-size 1em font-weight 700 text-align left @@ -99,24 +95,29 @@ export default Vue.extend({ > .username text-align left margin 0 0 0 8px - color #ccc + color isDark ? #606984 : #ccc > .right float right > .time font-size 0.9em - color #c0c0c0 + color isDark ? #606984 : #c0c0c0 -</style> + > .body + > .text + cursor default + display block + margin 0 + padding 0 + overflow-wrap break-word + font-size 1em + color isDark ? #959ba7 : #717171 + +.sub[data-darkmode] + root(true) + +.sub:not([data-darkmode]) + root(false) -<style lang="stylus" module> -.text - cursor default - display block - margin 0 - padding 0 - overflow-wrap break-word - font-size 1em - color #717171 </style> diff --git a/src/client/app/desktop/views/components/note-detail.vue b/src/client/app/desktop/views/components/note-detail.vue index b62a7cfd61..a0e3915149 100644 --- a/src/client/app/desktop/views/components/note-detail.vue +++ b/src/client/app/desktop/views/components/note-detail.vue @@ -18,18 +18,14 @@ </div> <div class="renote" v-if="isRenote"> <p> - <router-link class="avatar-anchor" :to="note.user | userPage" v-user-preview="note.userId"> - <img class="avatar" :src="`${note.user.avatarUrl}?thumbnail&size=32`" alt="avatar"/> - </router-link> + <mk-avatar class="avatar" :user="note.user"/> %fa:retweet% <router-link class="name" :href="note.user | userPage">{{ note.user | userName }}</router-link> がRenote </p> </div> <article> - <router-link class="avatar-anchor" :to="p.user | userPage"> - <img class="avatar" :src="`${p.user.avatarUrl}?thumbnail&size=64`" alt="avatar" v-user-preview="p.user.id"/> - </router-link> + <mk-avatar class="avatar" :user="p.user"/> <header> <router-link class="name" :to="p.user | userPage" v-user-preview="p.user.id">{{ p.user | userName }}</router-link> <span class="username">@{{ p.user | acct }}</span> @@ -38,9 +34,12 @@ </router-link> </header> <div class="body"> - <mk-note-html :class="$style.text" v-if="p.text" :text="p.text" :i="os.i"/> + <div class="text"> + <span v-if="p.isHidden" style="opacity: 0.5">(この投稿は非公開です)</span> + <mk-note-html v-if="p.text" :text="p.text" :i="os.i"/> + </div> <div class="media" v-if="p.media.length > 0"> - <mk-media-list :media-list="p.media"/> + <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"/> @@ -56,7 +55,9 @@ <footer> <mk-reactions-viewer :note="p"/> <button @click="reply" title="返信"> - %fa:reply%<p class="count" v-if="p.repliesCount > 0">{{ p.repliesCount }}</p> + <template v-if="p.reply">%fa:reply-all%</template> + <template v-else>%fa:reply%</template> + <p class="count" v-if="p.repliesCount > 0">{{ p.repliesCount }}</p> </button> <button @click="renote" title="Renote"> %fa:retweet%<p class="count" v-if="p.renoteCount > 0">{{ p.renoteCount }}</p> @@ -154,7 +155,7 @@ export default Vue.extend({ // Draw map if (this.p.geo) { - const shouldShowMap = (this as any).os.isSignedIn ? (this as any).os.i.clientSettings.showMaps : true; + const shouldShowMap = (this as any).os.isSignedIn ? (this as any).clientSettings.showMaps : true; if (shouldShowMap) { (this as any).os.getGoogleMaps().then(maps => { const uluru = new maps.LatLng(this.p.geo.coordinates[1], this.p.geo.coordinates[0]); @@ -212,13 +213,13 @@ export default Vue.extend({ <style lang="stylus" scoped> @import '~const.styl' -.mk-note-detail - margin 0 +root(isDark) + margin 0 auto padding 0 overflow hidden text-align left - background #fff - border solid 1px rgba(0, 0, 0, 0.1) + background isDark ? #282C37 : #fff + border solid 1px rgba(#000, 0.1) border-radius 8px > .read-more @@ -230,44 +231,39 @@ export default Vue.extend({ text-align center color #999 cursor pointer - background #fafafa + background isDark ? #21242d : #fafafa outline none border none - border-bottom solid 1px #eef0f2 + border-bottom solid 1px isDark ? #1c2023 : #eef0f2 border-radius 6px 6px 0 0 &:hover - background #f6f6f6 + background isDark ? #2e3440 : #f6f6f6 &:active - background #f0f0f0 + background isDark ? #21242b : #f0f0f0 &:disabled - color #ccc + color isDark ? #21242b : #ccc > .context > * - border-bottom 1px solid #eef0f2 + border-bottom 1px solid isDark ? #1c2023 : #eef0f2 > .renote color #9dbb00 - background linear-gradient(to bottom, #edfde2 0%, #fff 100%) + background isDark ? linear-gradient(to bottom, #314027 0%, #282c37 100%) : linear-gradient(to bottom, #edfde2 0%, #fff 100%) > p margin 0 padding 16px 32px - .avatar-anchor + .avatar display inline-block - - .avatar - vertical-align bottom - min-width 28px - min-height 28px - max-width 28px - max-height 28px - margin 0 8px 0 0 - border-radius 6px + width 28px + height 28px + margin 0 8px 0 0 + border-radius 6px [data-fa] margin-right 4px @@ -279,7 +275,7 @@ export default Vue.extend({ padding-top 8px > .reply-to - border-bottom 1px solid #eef0f2 + border-bottom 1px solid isDark ? #1c2023 : #eef0f2 > article padding 28px 32px 18px 32px @@ -290,21 +286,13 @@ export default Vue.extend({ clear both &:hover - > .main > footer > button - color #888 + > footer > button + color isDark ? #707b97 : #888 - > .avatar-anchor - display block + > .avatar width 60px height 60px - - > .avatar - display block - width 60px - height 60px - margin 0 - border-radius 8px - vertical-align bottom + border-radius 8px > header position absolute @@ -316,7 +304,7 @@ export default Vue.extend({ display inline-block margin 0 line-height 24px - color #777 + color isDark ? #fff : #627079 font-size 18px font-weight 700 text-align left @@ -329,18 +317,27 @@ export default Vue.extend({ display block text-align left margin 0 - color #ccc + color isDark ? #606984 : #ccc > .time position absolute top 0 right 32px font-size 1em - color #c0c0c0 + color isDark ? #606984 : #c0c0c0 > .body padding 8px 0 + > .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 @@ -402,11 +399,11 @@ export default Vue.extend({ background transparent border none font-size 1em - color #ddd + color isDark ? #606984 : #ccc cursor pointer &:hover - color #666 + color isDark ? #9198af : #666 > .count display inline @@ -418,17 +415,12 @@ export default Vue.extend({ > .replies > * - border-top 1px solid #eef0f2 + border-top 1px solid isDark ? #1c2023 : #eef0f2 -</style> +.mk-note-detail[data-darkmode] + root(true) + +.mk-note-detail:not([data-darkmode]) + root(false) -<style lang="stylus" module> -.text - cursor default - display block - margin 0 - padding 0 - overflow-wrap break-word - font-size 1.5em - color #717171 </style> diff --git a/src/client/app/desktop/views/components/note-preview.vue b/src/client/app/desktop/views/components/note-preview.vue index ff3ecadc20..d04abfc5a7 100644 --- a/src/client/app/desktop/views/components/note-preview.vue +++ b/src/client/app/desktop/views/components/note-preview.vue @@ -1,8 +1,6 @@ <template> <div class="mk-note-preview" :title="title"> - <router-link class="avatar-anchor" :to="note.user | userPage"> - <img class="avatar" :src="`${note.user.avatarUrl}?thumbnail&size=64`" alt="avatar" v-user-preview="note.userId"/> - </router-link> + <mk-avatar class="avatar" :user="note.user"/> <div class="main"> <header> <router-link class="name" :to="note.user | userPage" v-user-preview="note.userId">{{ note.user | userName }}</router-link> @@ -33,31 +31,21 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> -.mk-note-preview +root(isDark) font-size 0.9em - background #fff &:after content "" display block clear both - &:hover - > .main > footer > button - color #888 - - > .avatar-anchor + > .avatar display block float left margin 0 16px 0 0 - - > .avatar - display block - width 52px - height 52px - margin 0 - border-radius 8px - vertical-align bottom + width 52px + height 52px + border-radius 8px > .main float left @@ -65,12 +53,13 @@ export default Vue.extend({ > header display flex + align-items baseline white-space nowrap > .name margin 0 .5em 0 0 padding 0 - color #607073 + color isDark ? #fff : #607073 font-size 1em font-weight bold text-decoration none @@ -81,11 +70,11 @@ export default Vue.extend({ > .username margin 0 .5em 0 0 - color #d1d8da + color isDark ? #606984 : #d1d8da > .time margin-left auto - color #b2b8bb + color isDark ? #606984 : #b2b8bb > .body @@ -94,6 +83,12 @@ export default Vue.extend({ margin 0 padding 0 font-size 1.1em - color #717171 + color isDark ? #959ba7 : #717171 + +.mk-note-preview[data-darkmode] + root(true) + +.mk-note-preview: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 e854785783..575d605203 100644 --- a/src/client/app/desktop/views/components/notes.note.sub.vue +++ b/src/client/app/desktop/views/components/notes.note.sub.vue @@ -1,15 +1,22 @@ <template> <div class="sub" :title="title"> - <router-link class="avatar-anchor" :to="note.user | userPage"> - <img class="avatar" :src="`${note.user.avatarUrl}?thumbnail&size=64`" alt="avatar" v-user-preview="note.userId"/> - </router-link> + <mk-avatar class="avatar" :user="note.user"/> <div class="main"> <header> <router-link class="name" :to="note.user | userPage" v-user-preview="note.userId">{{ note.user | userName }}</router-link> <span class="username">@{{ note.user | acct }}</span> - <router-link class="created-at" :to="note | notePage"> - <mk-time :time="note.createdAt"/> - </router-link> + <div class="info"> + <span class="mobile" v-if="note.viaMobile">%fa:mobile-alt%</span> + <router-link class="created-at" :to="note | notePage"> + <mk-time :time="note.createdAt"/> + </router-link> + <span class="visibility" v-if="note.visibility != 'public'"> + <template v-if="note.visibility == 'home'">%fa:home%</template> + <template v-if="note.visibility == 'followers'">%fa:unlock%</template> + <template v-if="note.visibility == 'specified'">%fa:envelope%</template> + <template v-if="note.visibility == 'private'">%fa:lock%</template> + </span> + </div> </header> <div class="body"> <mk-sub-note-content class="text" :note="note"/> @@ -33,32 +40,24 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> -.sub +root(isDark) margin 0 - padding 16px + padding 16px 32px font-size 0.9em + background isDark ? #21242d : #fcfcfc &:after content "" display block clear both - &:hover - > .main > footer > button - color #888 - - > .avatar-anchor + > .avatar display block float left margin 0 14px 0 0 - - > .avatar - display block - width 52px - height 52px - margin 0 - border-radius 8px - vertical-align bottom + width 52px + height 52px + border-radius 8px > .main float left @@ -66,6 +65,7 @@ export default Vue.extend({ > header display flex + align-items baseline margin-bottom 2px white-space nowrap line-height 21px @@ -75,7 +75,7 @@ export default Vue.extend({ margin 0 .5em 0 0 padding 0 overflow hidden - color #607073 + color isDark ? #fff : #607073 font-size 1em font-weight bold text-decoration none @@ -86,23 +86,40 @@ export default Vue.extend({ > .username margin 0 .5em 0 0 - color #d1d8da + color isDark ? #606984 : #d1d8da - > .created-at + > .info margin-left auto - color #b2b8bb + font-size 0.9em + + > * + color isDark ? #606984 : #b2b8bb + + > .mobile + margin-right 6px + + > .visibility + margin-left 6px > .body + max-height 128px + overflow hidden > .text cursor default margin 0 padding 0 font-size 1.1em - color #717171 + color isDark ? #959ba7 : #717171 pre max-height 120px font-size 80% +.sub[data-darkmode] + root(true) + +.sub: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 326ec4dc89..057c3c0956 100644 --- a/src/client/app/desktop/views/components/notes.note.vue +++ b/src/client/app/desktop/views/components/notes.note.vue @@ -1,24 +1,18 @@ <template> <div class="note" tabindex="-1" :title="title" @keydown="onKeydown"> - <div class="reply-to" v-if="p.reply"> + <div class="reply-to" v-if="p.reply && (!os.isSignedIn || clientSettings.showReplyTarget)"> <x-sub :note="p.reply"/> </div> <div class="renote" v-if="isRenote"> - <p> - <router-link class="avatar-anchor" :to="note.user | userPage" v-user-preview="note.userId"> - <img class="avatar" :src="`${note.user.avatarUrl}?thumbnail&size=32`" alt="avatar"/> - </router-link> - %fa:retweet% - <span>{{ '%i18n:!@reposted-by%'.substr(0, '%i18n:!@reposted-by%'.indexOf('{')) }}</span> - <a class="name" :href="note.user | userPage" v-user-preview="note.userId">{{ note.user | userName }}</a> - <span>{{ '%i18n:!@reposted-by%'.substr('%i18n:!@reposted-by%'.indexOf('}') + 1) }}</span> - </p> + <mk-avatar class="avatar" :user="note.user"/> + %fa:retweet% + <span>{{ '%i18n:!@reposted-by%'.substr(0, '%i18n:!@reposted-by%'.indexOf('{')) }}</span> + <a class="name" :href="note.user | userPage" v-user-preview="note.userId">{{ note.user | userName }}</a> + <span>{{ '%i18n:!@reposted-by%'.substr('%i18n:!@reposted-by%'.indexOf('}') + 1) }}</span> <mk-time :time="note.createdAt"/> </div> <article> - <router-link class="avatar-anchor" :to="p.user | userPage"> - <img class="avatar" :src="`${p.user.avatarUrl}?thumbnail&size=64`" alt="avatar" v-user-preview="p.user.id"/> - </router-link> + <mk-avatar class="avatar" :user="p.user"/> <div class="main"> <header> <router-link class="name" :to="p.user | userPage" v-user-preview="p.user.id">{{ p.user | userName }}</router-link> @@ -30,35 +24,50 @@ <router-link class="created-at" :to="p | notePage"> <mk-time :time="p.createdAt"/> </router-link> + <span class="visibility" v-if="p.visibility != 'public'"> + <template v-if="p.visibility == 'home'">%fa:home%</template> + <template v-if="p.visibility == 'followers'">%fa:unlock%</template> + <template v-if="p.visibility == 'specified'">%fa:envelope%</template> + <template v-if="p.visibility == 'private'">%fa:lock%</template> + </span> </div> </header> <div class="body"> <p class="channel" v-if="p.channel"> <a :href="`${_CH_URL_}/${p.channel.id}`" target="_blank">{{ p.channel.title }}</a>: </p> - <div class="text"> - <a class="reply" v-if="p.reply">%fa:reply%</a> - <mk-note-html v-if="p.textHtml" :text="p.text" :i="os.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> - <mk-poll v-if="p.poll" :note="p" ref="pollViewer"/> - <div class="tags" v-if="p.tags && p.tags.length > 0"> - <router-link v-for="tag in p.tags" :key="tag" :to="`/search?q=#${tag}`">{{ tag }}</router-link> - </div> - <a class="location" v-if="p.geo" :href="`http://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"/> + <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 ? '隠す' : 'もっと見る' }}</span> + </p> + <div class="content" v-show="p.cw == null || showContent"> + <div class="text"> + <span v-if="p.isHidden" style="opacity: 0.5">(この投稿は非公開です)</span> + <a class="reply" v-if="p.reply">%fa:reply%</a> + <mk-note-html v-if="p.text" :text="p.text" :i="os.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> + <mk-poll v-if="p.poll" :note="p" ref="pollViewer"/> + <div class="tags" v-if="p.tags && p.tags.length > 0"> + <router-link v-for="tag in p.tags" :key="tag" :to="`/search?q=#${tag}`">{{ tag }}</router-link> + </div> + <a class="location" v-if="p.geo" :href="`http://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> + <mk-url-preview v-for="url in urls" :url="url" :key="url"/> </div> - <mk-url-preview v-for="url in urls" :url="url" :key="url"/> </div> <footer> <mk-reactions-viewer :note="p" ref="reactionsViewer"/> <button @click="reply" title="%i18n:@reply%"> - %fa:reply%<p class="count" v-if="p.repliesCount > 0">{{ p.repliesCount }}</p> + <template v-if="p.reply">%fa:reply-all%</template> + <template v-else>%fa:reply%</template> + <p class="count" v-if="p.repliesCount > 0">{{ p.repliesCount }}</p> </button> <button @click="renote" title="%i18n:@renote%"> %fa:retweet%<p class="count" v-if="p.renoteCount > 0">{{ p.renoteCount }}</p> @@ -113,6 +122,7 @@ export default Vue.extend({ data() { return { + showContent: false, isDetailOpened: false, connection: null, connectionId: null @@ -168,7 +178,7 @@ export default Vue.extend({ // Draw map if (this.p.geo) { - const shouldShowMap = (this as any).os.isSignedIn ? (this as any).os.i.clientSettings.showMaps : true; + const shouldShowMap = (this as any).os.isSignedIn ? (this as any).clientSettings.showMaps : true; if (shouldShowMap) { (this as any).os.getGoogleMaps().then(maps => { const uluru = new maps.LatLng(this.p.geo.coordinates[1], this.p.geo.coordinates[0]); @@ -289,20 +299,21 @@ export default Vue.extend({ <style lang="stylus" scoped> @import '~const.styl' -.note +root(isDark) margin 0 padding 0 - background #fff - border-bottom solid 1px #eaeaea - - &:first-child - border-top-left-radius 6px - border-top-right-radius 6px + background isDark ? #282C37 : #fff + border-bottom solid 1px isDark ? #1c2023 : #eaeaea - > .renote + &[data-round] + &:first-child border-top-left-radius 6px border-top-right-radius 6px + > .renote + border-top-left-radius 6px + border-top-right-radius 6px + &:last-of-type border-bottom none @@ -321,47 +332,45 @@ export default Vue.extend({ border-radius 4px > .renote + display flex + align-items center + padding 16px 32px + line-height 28px color #9dbb00 - background linear-gradient(to bottom, #edfde2 0%, #fff 100%) + background isDark ? linear-gradient(to bottom, #314027 0%, #282c37 100%) : linear-gradient(to bottom, #edfde2 0%, #fff 100%) - > p - margin 0 - padding 16px 32px - line-height 28px + .avatar + display inline-block + width 28px + height 28px + margin 0 8px 0 0 + border-radius 6px - .avatar-anchor - display inline-block + [data-fa] + margin-right 4px - .avatar - vertical-align bottom - width 28px - height 28px - margin 0 8px 0 0 - border-radius 6px + > span + flex-shrink 0 - [data-fa] - margin-right 4px + &:last-of-type + margin-right 8px - .name - font-weight bold + .name + overflow hidden + flex-shrink 1 + text-overflow ellipsis + white-space nowrap + font-weight bold > .mk-time - position absolute - top 16px - right 32px + display block + margin-left auto + flex-shrink 0 font-size 0.9em - line-height 28px & + article padding-top 8px - > .reply-to - padding 0 16px - background rgba(0, 0, 0, 0.0125) - - > .mk-note-preview - background transparent - > article padding 28px 32px 18px 32px @@ -372,31 +381,26 @@ export default Vue.extend({ &:hover > .main > footer > button - color #888 + color isDark ? #707b97 : #888 - > .avatar-anchor + > .avatar display block float left margin 0 16px 10px 0 + width 58px + height 58px + border-radius 8px //position -webkit-sticky //position sticky //top 74px - > .avatar - display block - width 58px - height 58px - margin 0 - border-radius 8px - vertical-align bottom - > .main float left width calc(100% - 74px) > header display flex - align-items center + align-items baseline margin-bottom 4px white-space nowrap @@ -405,7 +409,7 @@ export default Vue.extend({ margin 0 .5em 0 0 padding 0 overflow hidden - color #627079 + color isDark ? #fff : #627079 font-size 1em font-weight bold text-decoration none @@ -418,114 +422,156 @@ export default Vue.extend({ margin 0 .5em 0 0 padding 1px 6px font-size 12px - color #aaa - border solid 1px #ddd + color isDark ? #758188 : #aaa + border solid 1px isDark ? #57616f : #ddd border-radius 3px > .username margin 0 .5em 0 0 - color #ccc + overflow hidden + text-overflow ellipsis + color isDark ? #606984 : #ccc > .info margin-left auto font-size 0.9em + > * + color isDark ? #606984 : #c0c0c0 + > .mobile margin-right 8px - color #ccc > .app margin-right 8px padding-right 8px - color #ccc border-right solid 1px #eaeaea - > .created-at - color #c0c0c0 + > .visibility + margin-left 8px > .body - > .text + > .cw cursor default display block margin 0 padding 0 overflow-wrap break-word font-size 1.1em - color #717171 - - >>> .quote - margin 8px - padding 6px 12px - color #aaa - border-left solid 3px #eee + color isDark ? #fff : #717171 - > .reply + > .text margin-right 8px - color #717171 - > .rp - margin-left 4px - font-style oblique - color #a0bf46 + > .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 - > .location - margin 4px 0 - font-size 12px - color #ccc + &:hover + background isDark ? #707b97 : #bbc4ce - > .map - width 100% - height 300px + > .content - &:empty - display none + > .text + cursor default + display block + margin 0 + padding 0 + overflow-wrap break-word + font-size 1.1em + color isDark ? #fff : #717171 - > .tags - margin 4px 0 0 0 + >>> .title + display block + margin-bottom 4px + padding 4px + font-size 90% + text-align center + background isDark ? #2f3944 : #eef1f3 + border-radius 4px - > * - display inline-block - margin 0 8px 0 0 - padding 2px 8px 2px 16px - font-size 90% - color #8d969e - background #edf0f3 - border-radius 4px + >>> .code + margin 8px 0 - &:before - content "" - display block - position absolute - top 0 - bottom 0 - left 4px - width 8px - height 8px - margin auto 0 - background #fff - border-radius 100% + >>> .quote + margin 8px + padding 6px 12px + color isDark ? #6f808e : #aaa + border-left solid 3px isDark ? #637182 : #eee - &:hover - text-decoration none - background #e2e7ec + > .reply + margin-right 8px + color isDark ? #99abbf : #717171 - .mk-url-preview - margin-top 8px + > .rp + margin-left 4px + font-style oblique + color #a0bf46 - > .channel - margin 0 + > .location + margin 4px 0 + font-size 12px + color #ccc + + > .map + width 100% + height 300px - > .mk-poll - font-size 80% + &:empty + display none - > .renote - margin 8px 0 + > .tags + margin 4px 0 0 0 - > .mk-note-preview - padding 16px - border dashed 1px #c0dac6 - border-radius 8px + > * + display inline-block + margin 0 8px 0 0 + padding 2px 8px 2px 16px + font-size 90% + color #8d969e + background #edf0f3 + border-radius 4px + + &:before + content "" + display block + position absolute + top 0 + bottom 0 + left 4px + width 8px + height 8px + margin auto 0 + background #fff + border-radius 100% + + &:hover + text-decoration none + background #e2e7ec + + .mk-url-preview + margin-top 8px + + > .channel + margin 0 + + > .mk-poll + font-size 80% + + > .renote + margin 8px 0 + + > .mk-note-preview + padding 16px + border dashed 1px isDark ? #4e945e : #c0dac6 + border-radius 8px > footer > button @@ -533,13 +579,13 @@ export default Vue.extend({ padding 0 8px line-height 32px font-size 1em - color #ddd + color isDark ? #606984 : #ddd background transparent border none cursor pointer &:hover - color #666 + color isDark ? #9198af : #666 > .count display inline @@ -556,7 +602,13 @@ export default Vue.extend({ > .detail padding-top 4px - background rgba(0, 0, 0, 0.0125) + background rgba(#000, 0.0125) + +.note[data-darkmode] + root(true) + +.note:not([data-darkmode]) + root(false) </style> diff --git a/src/client/app/desktop/views/components/notes.vue b/src/client/app/desktop/views/components/notes.vue index b5f6957a16..7e80e6f74a 100644 --- a/src/client/app/desktop/views/components/notes.vue +++ b/src/client/app/desktop/views/components/notes.vue @@ -1,32 +1,65 @@ <template> <div class="mk-notes"> - <template v-for="(note, i) in _notes"> - <x-note :note="note" :key="note.id" @update:note="onNoteUpdated(i, $event)"/> - <p class="date" v-if="i != notes.length - 1 && note._date != _notes[i + 1]._date"> - <span>%fa:angle-up%{{ note._datetext }}</span> - <span>%fa:angle-down%{{ _notes[i + 1]._datetext }}</span> - </p> - </template> - <footer> - <slot name="footer"></slot> + <div class="newer-indicator" :style="{ top: $store.state.uiHeaderHeight + 'px' }" v-show="queue.length > 0"></div> + + <slot name="empty" v-if="notes.length == 0 && !fetching && requestInitPromise == null"></slot> + + <div v-if="!fetching && requestInitPromise != null"> + <p>読み込みに失敗しました。</p> + <button @click="resolveInitPromise">リトライ</button> + </div> + + <transition-group name="mk-notes" class="transition"> + <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"> + <span>%fa:angle-up%{{ note._datetext }}</span> + <span>%fa:angle-down%{{ _notes[i + 1]._datetext }}</span> + </p> + </template> + </transition-group> + + <footer v-if="more"> + <button @click="loadMore" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }"> + <template v-if="!moreFetching">%i18n:@load-more%</template> + <template v-if="moreFetching">%fa:spinner .pulse .fw%</template> + </button> </footer> </div> </template> <script lang="ts"> import Vue from 'vue'; +import { url } from '../../../config'; +import getNoteSummary from '../../../../../renderers/get-note-summary'; + import XNote from './notes.note.vue'; +const displayLimit = 30; + export default Vue.extend({ components: { XNote }, + props: { - notes: { - type: Array, - default: () => [] + more: { + type: Function, + required: false } }, + + data() { + return { + requestInitPromise: null as () => Promise<any[]>, + notes: [], + queue: [], + unreadCount: 0, + fetching: true, + moreFetching: false + }; + }, + computed: { _notes(): any[] { return (this.notes as any).map(note => { @@ -38,52 +71,202 @@ export default Vue.extend({ }); } }, + + mounted() { + document.addEventListener('visibilitychange', this.onVisibilitychange, false); + window.addEventListener('scroll', this.onScroll); + }, + + beforeDestroy() { + document.removeEventListener('visibilitychange', this.onVisibilitychange); + window.removeEventListener('scroll', this.onScroll); + }, + methods: { + isScrollTop() { + return window.scrollY <= 8; + }, + focus() { (this.$el as any).children[0].focus(); }, + onNoteUpdated(i, note) { Vue.set((this as any).notes, i, note); + }, + + init(promiseGenerator: () => Promise<any[]>) { + this.requestInitPromise = promiseGenerator; + this.resolveInitPromise(); + }, + + resolveInitPromise() { + this.queue = []; + this.notes = []; + this.fetching = true; + + const promise = this.requestInitPromise(); + + promise.then(notes => { + this.notes = notes; + this.requestInitPromise = null; + this.fetching = false; + }, e => { + this.fetching = false; + }); + }, + + prepend(note, silent = false) { + //#region 弾く + const isMyNote = note.userId == (this as any).os.i.id; + const isPureRenote = note.renoteId != null && note.text == null && note.mediaIds.length == 0 && note.poll == null; + + if ((this as any).clientSettings.showMyRenotes === false) { + if (isMyNote && isPureRenote) { + return; + } + } + + if ((this as any).clientSettings.showRenotedMyNotes === false) { + if (isPureRenote && (note.renote.userId == (this as any).os.i.id)) { + return; + } + } + //#endregion + + // 投稿が自分のものではないかつ、タブが非表示またはスクロール位置が最上部ではないならタイトルで通知 + if ((document.hidden || !this.isScrollTop()) && note.userId !== (this as any).os.i.id) { + this.unreadCount++; + document.title = `(${this.unreadCount}) ${getNoteSummary(note)}`; + } + + if (this.isScrollTop()) { + // Prepend the note + this.notes.unshift(note); + + // サウンドを再生する + if ((this as any).os.isEnableSounds && !silent) { + const sound = new Audio(`${url}/assets/post.mp3`); + sound.volume = localStorage.getItem('soundVolume') ? parseInt(localStorage.getItem('soundVolume'), 10) / 100 : 0.5; + sound.play(); + } + + // オーバーフローしたら古い投稿は捨てる + if (this.notes.length >= displayLimit) { + this.notes = this.notes.slice(0, displayLimit); + } + } else { + this.queue.push(note); + } + }, + + append(note) { + this.notes.push(note); + }, + + tail() { + return this.notes[this.notes.length - 1]; + }, + + releaseQueue() { + this.queue.forEach(n => this.prepend(n, true)); + this.queue = []; + }, + + async loadMore() { + if (this.more == null) return; + if (this.moreFetching) return; + + this.moreFetching = true; + await this.more(); + this.moreFetching = false; + }, + + clearNotification() { + this.unreadCount = 0; + document.title = 'Misskey'; + }, + + onVisibilitychange() { + if (!document.hidden) { + this.clearNotification(); + } + }, + + onScroll() { + if (this.isScrollTop()) { + this.releaseQueue(); + this.clearNotification(); + } + + if ((this as any).clientSettings.fetchOnScroll !== false) { + const current = window.scrollY + window.innerHeight; + if (current > document.body.offsetHeight - 8) this.loadMore(); + } } } }); </script> <style lang="stylus" scoped> -.mk-notes +@import '~const.styl' + +root(isDark) + .transition + .mk-notes-enter + .mk-notes-leave-to + opacity 0 + transform translateY(-30px) + + > * + transition transform .3s ease, opacity .3s ease - > .date - display block - margin 0 - line-height 32px - font-size 14px - text-align center - color #aaa - background #fdfdfd - border-bottom solid 1px #eaeaea + > .date + display block + margin 0 + line-height 32px + font-size 14px + text-align center + color isDark ? #666b79 : #aaa + background isDark ? #242731 : #fdfdfd + border-bottom solid 1px isDark ? #1c2023 : #eaeaea - span - margin 0 16px + span + margin 0 16px - [data-fa] - margin-right 8px + [data-fa] + margin-right 8px + + > .newer-indicator + position -webkit-sticky + position sticky + z-index 100 + height 3px + background $theme-color > footer - > * + > button display block margin 0 padding 16px width 100% text-align center color #ccc - border-top solid 1px #eaeaea - border-bottom-left-radius 4px - border-bottom-right-radius 4px + background isDark ? #282C37 : #fff + border-top solid 1px isDark ? #1c2023 : #eaeaea + border-bottom-left-radius 6px + border-bottom-right-radius 6px - > button &:hover - background #f5f5f5 + background isDark ? #2e3440 : #f5f5f5 &:active - background #eee + background isDark ? #21242b : #eee + +.mk-notes[data-darkmode] + root(true) + +.mk-notes:not([data-darkmode]) + root(false) + </style> diff --git a/src/client/app/desktop/views/components/notifications.vue b/src/client/app/desktop/views/components/notifications.vue index 413a87755a..7923d1a62d 100644 --- a/src/client/app/desktop/views/components/notifications.vue +++ b/src/client/app/desktop/views/components/notifications.vue @@ -1,96 +1,84 @@ <template> <div class="mk-notifications"> <div class="notifications" v-if="notifications.length != 0"> - <template v-for="(notification, i) in _notifications"> - <div class="notification" :class="notification.type" :key="notification.id"> - <mk-time :time="notification.createdAt"/> - <template v-if="notification.type == 'reaction'"> - <router-link class="avatar-anchor" :to="notification.user | userPage" v-user-preview="notification.user.id"> - <img class="avatar" :src="`${notification.user.avatarUrl}?thumbnail&size=48`" alt="avatar"/> - </router-link> - <div class="text"> - <p> - <mk-reaction-icon :reaction="notification.reaction"/> - <router-link :to="notification.user | userPage" v-user-preview="notification.user.id">{{ notification.user | userName }}</router-link> - </p> - <router-link class="note-ref" :to="notification.note | notePage"> - %fa:quote-left%{{ getNoteSummary(notification.note) }}%fa:quote-right% - </router-link> - </div> - </template> - <template v-if="notification.type == 'renote'"> - <router-link class="avatar-anchor" :to="notification.note.user | userPage" v-user-preview="notification.note.userId"> - <img class="avatar" :src="`${notification.note.user.avatarUrl}?thumbnail&size=48`" alt="avatar"/> - </router-link> - <div class="text"> - <p>%fa:retweet% - <router-link :to="notification.note.user | userPage" v-user-preview="notification.note.userId">{{ notification.note.user | userName }}</router-link> - </p> - <router-link class="note-ref" :to="notification.note | notePage"> - %fa:quote-left%{{ getNoteSummary(notification.note.renote) }}%fa:quote-right% - </router-link> - </div> - </template> - <template v-if="notification.type == 'quote'"> - <router-link class="avatar-anchor" :to="notification.note.user | userPage" v-user-preview="notification.note.userId"> - <img class="avatar" :src="`${notification.note.user.avatarUrl}?thumbnail&size=48`" alt="avatar"/> - </router-link> - <div class="text"> - <p>%fa:quote-left% - <router-link :to="notification.note.user | userPage" v-user-preview="notification.note.userId">{{ notification.note.user | userName }}</router-link> - </p> - <router-link class="note-preview" :to="notification.note | notePage">{{ getNoteSummary(notification.note) }}</router-link> - </div> - </template> - <template v-if="notification.type == 'follow'"> - <router-link class="avatar-anchor" :to="notification.user | userPage" v-user-preview="notification.user.id"> - <img class="avatar" :src="`${notification.user.avatarUrl}?thumbnail&size=48`" alt="avatar"/> - </router-link> - <div class="text"> - <p>%fa:user-plus% - <router-link :to="notification.user | userPage" v-user-preview="notification.user.id">{{ notification.user | userName }}</router-link> - </p> - </div> - </template> - <template v-if="notification.type == 'reply'"> - <router-link class="avatar-anchor" :to="notification.note.user | userPage" v-user-preview="notification.note.userId"> - <img class="avatar" :src="`${notification.note.user.avatarUrl}?thumbnail&size=48`" alt="avatar"/> - </router-link> - <div class="text"> - <p>%fa:reply% - <router-link :to="notification.note.user | userPage" v-user-preview="notification.note.userId">{{ notification.note.user | userName }}</router-link> - </p> - <router-link class="note-preview" :to="notification.note | notePage">{{ getNoteSummary(notification.note) }}</router-link> - </div> - </template> - <template v-if="notification.type == 'mention'"> - <router-link class="avatar-anchor" :to="notification.note.user | userPage" v-user-preview="notification.note.userId"> - <img class="avatar" :src="`${notification.note.user.avatarUrl}?thumbnail&size=48`" alt="avatar"/> - </router-link> - <div class="text"> - <p>%fa:at% - <router-link :to="notification.note.user | userPage" v-user-preview="notification.note.userId">{{ notification.note.user | userName }}</router-link> - </p> - <a class="note-preview" :href="notification.note | notePage">{{ getNoteSummary(notification.note) }}</a> - </div> - </template> - <template v-if="notification.type == 'poll_vote'"> - <router-link class="avatar-anchor" :to="notification.user | userPage" v-user-preview="notification.user.id"> - <img class="avatar" :src="`${notification.user.avatarUrl}?thumbnail&size=48`" alt="avatar"/> - </router-link> - <div class="text"> - <p>%fa:chart-pie%<a :href="notification.user | userPage" v-user-preview="notification.user.id">{{ notification.user | userName }}</a></p> - <router-link class="note-ref" :to="notification.note | notePage"> - %fa:quote-left%{{ getNoteSummary(notification.note) }}%fa:quote-right% - </router-link> - </div> - </template> - </div> - <p class="date" v-if="i != notifications.length - 1 && notification._date != _notifications[i + 1]._date" :key="notification.id + '-time'"> - <span>%fa:angle-up%{{ notification._datetext }}</span> - <span>%fa:angle-down%{{ _notifications[i + 1]._datetext }}</span> - </p> - </template> + <transition-group name="mk-notifications" class="transition"> + <template v-for="(notification, i) in _notifications"> + <div class="notification" :class="notification.type" :key="notification.id"> + <mk-time :time="notification.createdAt"/> + <template v-if="notification.type == 'reaction'"> + <mk-avatar class="avatar" :user="notification.user"/> + <div class="text"> + <p> + <mk-reaction-icon :reaction="notification.reaction"/> + <router-link :to="notification.user | userPage" v-user-preview="notification.user.id">{{ notification.user | userName }}</router-link> + </p> + <router-link class="note-ref" :to="notification.note | notePage"> + %fa:quote-left%{{ getNoteSummary(notification.note) }}%fa:quote-right% + </router-link> + </div> + </template> + <template v-if="notification.type == 'renote'"> + <mk-avatar class="avatar" :user="notification.note.user"/> + <div class="text"> + <p>%fa:retweet% + <router-link :to="notification.note.user | userPage" v-user-preview="notification.note.userId">{{ notification.note.user | userName }}</router-link> + </p> + <router-link class="note-ref" :to="notification.note | notePage"> + %fa:quote-left%{{ getNoteSummary(notification.note.renote) }}%fa:quote-right% + </router-link> + </div> + </template> + <template v-if="notification.type == 'quote'"> + <mk-avatar class="avatar" :user="notification.note.user"/> + <div class="text"> + <p>%fa:quote-left% + <router-link :to="notification.note.user | userPage" v-user-preview="notification.note.userId">{{ notification.note.user | userName }}</router-link> + </p> + <router-link class="note-preview" :to="notification.note | notePage">{{ getNoteSummary(notification.note) }}</router-link> + </div> + </template> + <template v-if="notification.type == 'follow'"> + <mk-avatar class="avatar" :user="notification.user"/> + <div class="text"> + <p>%fa:user-plus% + <router-link :to="notification.user | userPage" v-user-preview="notification.user.id">{{ notification.user | userName }}</router-link> + </p> + </div> + </template> + <template v-if="notification.type == 'reply'"> + <mk-avatar class="avatar" :user="notification.note.user"/> + <div class="text"> + <p>%fa:reply% + <router-link :to="notification.note.user | userPage" v-user-preview="notification.note.userId">{{ notification.note.user | userName }}</router-link> + </p> + <router-link class="note-preview" :to="notification.note | notePage">{{ getNoteSummary(notification.note) }}</router-link> + </div> + </template> + <template v-if="notification.type == 'mention'"> + <mk-avatar class="avatar" :user="notification.note.user"/> + <div class="text"> + <p>%fa:at% + <router-link :to="notification.note.user | userPage" v-user-preview="notification.note.userId">{{ notification.note.user | userName }}</router-link> + </p> + <a class="note-preview" :href="notification.note | notePage">{{ getNoteSummary(notification.note) }}</a> + </div> + </template> + <template v-if="notification.type == 'poll_vote'"> + <mk-avatar class="avatar" :user="notification.user"/> + <div class="text"> + <p>%fa:chart-pie%<a :href="notification.user | userPage" v-user-preview="notification.user.id">{{ notification.user | userName }}</a></p> + <router-link class="note-ref" :to="notification.note | notePage"> + %fa:quote-left%{{ getNoteSummary(notification.note) }}%fa:quote-right% + </router-link> + </div> + </template> + </div> + <p class="date" v-if="i != notifications.length - 1 && notification._date != _notifications[i + 1]._date" :key="notification.id + '-time'"> + <span>%fa:angle-up%{{ notification._datetext }}</span> + <span>%fa:angle-down%{{ _notifications[i + 1]._datetext }}</span> + </p> + </template> + </transition-group> </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%' }} @@ -185,111 +173,116 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> -.mk-notifications - > .notifications - > .notification - margin 0 - padding 16px - overflow-wrap break-word - font-size 0.9em - border-bottom solid 1px rgba(0, 0, 0, 0.05) +root(isDark) + .transition + .mk-notifications-enter + .mk-notifications-leave-to + opacity 0 + transform translateY(-30px) - &:last-child - border-bottom none + > * + transition transform .3s ease, opacity .3s ease - > .mk-time - display inline - position absolute - top 16px - right 12px - vertical-align top - color rgba(0, 0, 0, 0.6) - font-size small + > .notifications + > * + > .notification + margin 0 + padding 16px + overflow-wrap break-word + font-size 0.9em + border-bottom solid 1px isDark ? #1c2023 : rgba(#000, 0.05) - &:after - content "" - display block - clear both + &:last-child + border-bottom none - > .avatar-anchor - display block - float left - position -webkit-sticky - position sticky - top 16px + > .mk-time + display inline + position absolute + top 16px + right 12px + vertical-align top + color isDark ? #606984 : rgba(#000, 0.6) + font-size small + + &:after + content "" + display block + clear both - > img + > .avatar display block - min-width 36px - min-height 36px - max-width 36px - max-height 36px + float left + position -webkit-sticky + position sticky + top 16px + width 36px + height 36px border-radius 6px - > .text - float right - width calc(100% - 36px) - padding-left 8px + > .text + float right + width calc(100% - 36px) + padding-left 8px - p - margin 0 + p + margin 0 - i, .mk-reaction-icon - margin-right 4px + i, .mk-reaction-icon + margin-right 4px - .note-preview - color rgba(0, 0, 0, 0.7) + .note-preview + color isDark ? #c2cad4 : rgba(#000, 0.7) - .note-ref - color rgba(0, 0, 0, 0.7) + .note-ref + color isDark ? #c2cad4 : rgba(#000, 0.7) - [data-fa] - font-size 1em - font-weight normal - font-style normal - display inline-block - margin-right 3px + [data-fa] + font-size 1em + font-weight normal + font-style normal + display inline-block + margin-right 3px - &.renote, &.quote - .text p i - color #77B255 + &.renote, &.quote + .text p i + color #77B255 - &.follow - .text p i - color #53c7ce + &.follow + .text p i + color #53c7ce - &.reply, &.mention - .text p i - color #555 + &.reply, &.mention + .text p i + color #555 - > .date - display block - margin 0 - line-height 32px - text-align center - font-size 0.8em - color #aaa - background #fdfdfd - border-bottom solid 1px rgba(0, 0, 0, 0.05) + > .date + display block + margin 0 + line-height 32px + text-align center + font-size 0.8em + color isDark ? #666b79 : #aaa + background isDark ? #242731 : #fdfdfd + border-bottom solid 1px isDark ? #1c2023 : rgba(#000, 0.05) - span - margin 0 16px + span + margin 0 16px - [data-fa] - margin-right 8px + [data-fa] + margin-right 8px > .more display block width 100% padding 16px color #555 - border-top solid 1px rgba(0, 0, 0, 0.05) + border-top solid 1px rgba(#000, 0.05) &:hover - background rgba(0, 0, 0, 0.025) + background rgba(#000, 0.025) &:active - background rgba(0, 0, 0, 0.05) + background rgba(#000, 0.05) &.fetching cursor wait @@ -312,4 +305,10 @@ export default Vue.extend({ > [data-fa] margin-right 4px +.mk-notifications[data-darkmode] + root(true) + +.mk-notifications:not([data-darkmode]) + root(false) + </style> diff --git a/src/client/app/desktop/views/components/post-form.vue b/src/client/app/desktop/views/components/post-form.vue index ebb0193088..984fc9866c 100644 --- a/src/client/app/desktop/views/components/post-form.vue +++ b/src/client/app/desktop/views/components/post-form.vue @@ -6,6 +6,11 @@ @drop.stop="onDrop" > <div class="content"> + <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">+ユーザーを追加</a> + </div> + <input v-show="useCw" v-model="cw" placeholder="内容への注釈 (オプション)"> <textarea :class="{ with: (files.length != 0 || poll) }" ref="text" v-model="text" :disabled="posting" @keydown="onKeydown" @paste="onPaste" :placeholder="placeholder" @@ -27,8 +32,10 @@ <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="内容を隠す" @click="useCw = !useCw">%fa:eye-slash%</button> <button class="geo" title="位置情報を添付する" @click="geo ? removeGeo() : setGeo()">%fa:map-marker-alt%</button> - <p class="text-count" :class="{ over: text.length > 1000 }">{{ '%i18n:!@text-remain%'.replace('{}', 1000 - text.length) }}</p> + <button class="visibility" title="公開範囲" @click="setVisibility" ref="visibilityButton">%fa:lock%</button> + <p class="text-count" :class="{ over: text.length > 1000 }">{{ 1000 - text.length }}</p> <button :class="{ posting }" class="submit" :disabled="!canPost" @click="post"> {{ posting ? '%i18n:!@posting%' : submitText }}<mk-ellipsis v-if="posting"/> </button> @@ -41,12 +48,16 @@ import Vue from 'vue'; import * as XDraggable from 'vuedraggable'; import getKao from '../../../common/scripts/get-kao'; +import MkVisibilityChooser from '../../../common/views/components/visibility-chooser.vue'; export default Vue.extend({ components: { - XDraggable + XDraggable, + MkVisibilityChooser }, + props: ['reply', 'renote'], + data() { return { posting: false, @@ -54,11 +65,16 @@ export default Vue.extend({ files: [], uploadings: [], poll: false, + useCw: false, + cw: null, geo: null, + visibility: 'public', + visibleUsers: [], autocomplete: null, draghover: false }; }, + computed: { draftId(): string { return this.renote @@ -67,6 +83,7 @@ export default Vue.extend({ ? 'reply:' + this.reply.id : 'note'; }, + placeholder(): string { return this.renote ? '%i18n:!@quote-placeholder%' @@ -74,6 +91,7 @@ export default Vue.extend({ ? '%i18n:!@reply-placeholder%' : '%i18n:!@note-placeholder%'; }, + submitText(): string { return this.renote ? '%i18n:!@renote%' @@ -81,22 +99,17 @@ export default Vue.extend({ ? '%i18n:!@reply%' : '%i18n:!@note%'; }, + canPost(): boolean { return !this.posting && (this.text.length != 0 || this.files.length != 0 || this.poll || this.renote); } }, - watch: { - text() { - this.saveDraft(); - }, - poll() { - this.saveDraft(); - }, - files() { - this.saveDraft(); - } - }, + mounted() { + if (this.reply && this.reply.user.host != null) { + this.text = `@${this.reply.user.username}@${this.reply.user.host} `; + } + this.$nextTick(() => { // 書きかけの投稿を復元 const draft = JSON.parse(localStorage.getItem('drafts') || '{}')[this.draftId]; @@ -111,15 +124,26 @@ export default Vue.extend({ } this.$emit('change-attached-media', this.files); } + + this.$nextTick(() => this.watch()); }); }, + methods: { + watch() { + this.$watch('text', () => this.saveDraft()); + this.$watch('poll', () => this.saveDraft()); + this.$watch('files', () => this.saveDraft()); + }, + focus() { (this.$refs.text as any).focus(); }, + chooseFile() { (this.$refs.file as any).click(); }, + chooseFileFromDrive() { (this as any).apis.chooseDriveFile({ multiple: true @@ -127,32 +151,40 @@ export default Vue.extend({ files.forEach(this.attachMedia); }); }, + attachMedia(driveFile) { this.files.push(driveFile); this.$emit('change-attached-media', this.files); }, + detachMedia(id) { this.files = this.files.filter(x => x.id != id); this.$emit('change-attached-media', this.files); }, + onChangeFile() { Array.from((this.$refs.file as any).files).forEach(this.upload); }, + upload(file) { (this.$refs.uploader as any).upload(file); }, + onChangeUploadings(uploads) { this.$emit('change-uploadings', uploads); }, + clear() { this.text = ''; this.files = []; this.poll = false; this.$emit('change-attached-media', this.files); }, + onKeydown(e) { if ((e.which == 10 || e.which == 13) && (e.ctrlKey || e.metaKey)) this.post(); }, + onPaste(e) { Array.from(e.clipboardData.items).forEach((item: any) => { if (item.kind == 'file') { @@ -160,6 +192,7 @@ export default Vue.extend({ } }); }, + onDragover(e) { const isFile = e.dataTransfer.items[0].kind == 'file'; const isDriveFile = e.dataTransfer.types[0] == 'mk_drive_file'; @@ -169,12 +202,15 @@ export default Vue.extend({ e.dataTransfer.dropEffect = e.dataTransfer.effectAllowed == 'all' ? 'copy' : 'move'; } }, + onDragenter(e) { this.draghover = true; }, + onDragleave(e) { this.draghover = false; }, + onDrop(e): void { this.draghover = false; @@ -195,6 +231,7 @@ export default Vue.extend({ } //#endregion }, + setGeo() { if (navigator.geolocation == null) { alert('お使いの端末は位置情報に対応していません'); @@ -210,10 +247,38 @@ export default Vue.extend({ enableHighAccuracy: true }); }, + removeGeo() { this.geo = null; this.$emit('geo-dettached'); }, + + setVisibility() { + const w = (this as any).os.new(MkVisibilityChooser, { + source: this.$refs.visibilityButton, + v: this.visibility + }); + w.$once('chosen', v => { + this.visibility = v; + }); + }, + + addVisibleUser() { + (this as any).apis.input({ + title: 'ユーザー名を入力してください' + }).then(username => { + (this as any).api('users/show', { + username + }).then(user => { + this.visibleUsers.push(user); + }); + }); + }, + + removeVisibleUser(user) { + this.visibleUsers = this.visibleUsers.filter(u => u != user); + }, + post() { this.posting = true; @@ -223,6 +288,9 @@ export default Vue.extend({ replyId: this.reply ? this.reply.id : undefined, renoteId: this.renote ? this.renote.id : undefined, poll: this.poll ? (this.$refs.poll as any).get() : undefined, + cw: this.useCw ? this.cw || '' : undefined, + visibility: this.visibility, + visibleUserIds: this.visibility == 'specified' ? this.visibleUsers.map(u => u.id) : undefined, geo: this.geo ? { coordinates: [this.geo.longitude, this.geo.latitude], altitude: this.geo.altitude, @@ -250,6 +318,7 @@ export default Vue.extend({ this.posting = false; }); }, + saveDraft() { const data = JSON.parse(localStorage.getItem('drafts') || '{}'); @@ -264,6 +333,7 @@ export default Vue.extend({ localStorage.setItem('drafts', JSON.stringify(data)); }, + deleteDraft() { const data = JSON.parse(localStorage.getItem('drafts') || '{}'); @@ -271,6 +341,7 @@ export default Vue.extend({ localStorage.setItem('drafts', JSON.stringify(data)); }, + kao() { this.text += getKao(); } @@ -281,10 +352,10 @@ export default Vue.extend({ <style lang="stylus" scoped> @import '~const.styl' -.mk-post-form +root(isDark) display block padding 16px - background lighten($theme-color, 95%) + background isDark ? #282C37 : lighten($theme-color, 95%) &:after content "" @@ -292,56 +363,70 @@ export default Vue.extend({ clear both > .content - - textarea + > input + > textarea display block - padding 12px - margin 0 width 100% - max-width 100% - min-width 100% - min-height calc(16px + 12px + 12px) + padding 12px font-size 16px - color #333 - background #fff + color isDark ? #fff : #333 + background isDark ? #191d23 : #fff outline none border solid 1px rgba($theme-color, 0.1) border-radius 4px - transition border-color .3s ease + transition border-color .2s ease &:hover border-color rgba($theme-color, 0.2) transition border-color .1s ease + &:focus + border-color rgba($theme-color, 0.5) + transition border-color 0s ease + + &:disabled + opacity 0.5 + + &::-webkit-input-placeholder + color rgba($theme-color, 0.3) + + > input + margin-bottom 8px + + > textarea + margin 0 + max-width 100% + min-width 100% + min-height 64px + + &:hover & + * & + * + * border-color rgba($theme-color, 0.2) transition border-color .1s ease &:focus - color $theme-color - border-color rgba($theme-color, 0.5) - transition border-color 0s ease - & + * & + * + * border-color rgba($theme-color, 0.5) transition border-color 0s ease - &:disabled - opacity 0.5 - - &::-webkit-input-placeholder - color rgba($theme-color, 0.3) - &.with border-bottom solid 1px rgba($theme-color, 0.1) !important border-radius 4px 4px 0 0 + > .visibleUsers + margin-bottom 8px + font-size 14px + + > span + margin-right 16px + color isDark ? #fff : #666 + > .medias margin 0 padding 0 - background lighten($theme-color, 98%) + background isDark ? #181b23 : lighten($theme-color, 98%) border solid 1px rgba($theme-color, 0.1) border-top none border-radius 0 0 4px 4px @@ -392,7 +477,7 @@ export default Vue.extend({ cursor pointer > .mk-poll-editor - background lighten($theme-color, 98%) + background isDark ? #181b23 : lighten($theme-color, 98%) border solid 1px rgba($theme-color, 0.1) border-top none border-radius 0 0 4px 4px @@ -407,19 +492,6 @@ export default Vue.extend({ input[type='file'] display none - .text-count - pointer-events none - display block - position absolute - bottom 16px - right 138px - margin 0 - line-height 40px - color rgba($theme-color, 0.5) - - &.over - color #ec3828 - .submit display block position absolute @@ -484,11 +556,25 @@ export default Vue.extend({ from {background-position: 0 0;} to {background-position: -64px 32px;} + > .text-count + pointer-events none + display block + position absolute + bottom 16px + right 138px + margin 0 + line-height 40px + color rgba($theme-color, 0.5) + + &.over + color #ec3828 + > .upload > .drive > .kao > .poll > .geo + > .visibility display inline-block cursor pointer padding 0 @@ -496,7 +582,7 @@ export default Vue.extend({ width 40px height 40px font-size 1em - color rgba($theme-color, 0.5) + color isDark ? $theme-color : rgba($theme-color, 0.5) background transparent outline none border solid 1px transparent @@ -504,13 +590,13 @@ export default Vue.extend({ &:hover background transparent - border-color rgba($theme-color, 0.3) + border-color isDark ? rgba($theme-color, 0.5) : rgba($theme-color, 0.3) &:active color rgba($theme-color, 0.6) - background linear-gradient(to bottom, lighten($theme-color, 80%) 0%, lighten($theme-color, 90%) 100%) + background isDark ? transparent : linear-gradient(to bottom, lighten($theme-color, 80%) 0%, lighten($theme-color, 90%) 100%) border-color rgba($theme-color, 0.5) - box-shadow 0 2px 4px rgba(0, 0, 0, 0.15) inset + box-shadow 0 2px 4px rgba(#000, 0.15) inset &:focus &:after @@ -533,4 +619,10 @@ export default Vue.extend({ border dashed 2px rgba($theme-color, 0.5) pointer-events none +.mk-post-form[data-darkmode] + root(true) + +.mk-post-form:not([data-darkmode]) + root(false) + </style> diff --git a/src/client/app/desktop/views/components/renote-form.vue b/src/client/app/desktop/views/components/renote-form.vue index daae5df5e9..9c0154211b 100644 --- a/src/client/app/desktop/views/components/renote-form.vue +++ b/src/client/app/desktop/views/components/renote-form.vue @@ -4,8 +4,8 @@ <template v-if="!quote"> <footer> <a class="quote" v-if="!quote" @click="onQuote">%i18n:@quote%</a> - <button class="cancel" @click="cancel">%i18n:@cancel%</button> - <button class="ok" @click="ok" :disabled="wait">{{ wait ? '%i18n:!@reposting%' : '%i18n:!@renote%' }}</button> + <button class="ui cancel" @click="cancel">%i18n:@cancel%</button> + <button class="ui primary ok" @click="ok" :disabled="wait">{{ wait ? '%i18n:!@reposting%' : '%i18n:!@renote%' }}</button> </footer> </template> <template v-if="quote"> @@ -59,14 +59,14 @@ export default Vue.extend({ <style lang="stylus" scoped> @import '~const.styl' -.mk-renote-form +root(isDark) > .mk-note-preview margin 16px 22px > footer height 72px - background lighten($theme-color, 95%) + background isDark ? #313543 : lighten($theme-color, 95%) > .quote position absolute @@ -78,54 +78,19 @@ export default Vue.extend({ display block position absolute bottom 16px - cursor pointer - padding 0 - margin 0 width 120px height 40px - font-size 1em - outline none - border-radius 4px - &:focus - &:after - content "" - pointer-events none - position absolute - top -5px - right -5px - bottom -5px - left -5px - border 2px solid rgba($theme-color, 0.3) - border-radius 8px + &.cancel + right 148px - > .cancel - right 148px - color #888 - background linear-gradient(to bottom, #ffffff 0%, #f5f5f5 100%) - border solid 1px #e2e2e2 + &.ok + right 16px - &:hover - background linear-gradient(to bottom, #f9f9f9 0%, #ececec 100%) - border-color #dcdcdc +.mk-renote-form[data-darkmode] + root(true) - &:active - background #ececec - border-color #dcdcdc - - > .ok - right 16px - font-weight bold - color $theme-color-foreground - background linear-gradient(to bottom, lighten($theme-color, 25%) 0%, lighten($theme-color, 10%) 100%) - border solid 1px lighten($theme-color, 15%) - - &:hover - background linear-gradient(to bottom, lighten($theme-color, 8%) 0%, darken($theme-color, 8%) 100%) - border-color $theme-color - - &:active - background $theme-color - border-color $theme-color +.mk-renote-form:not([data-darkmode]) + root(false) </style> diff --git a/src/client/app/desktop/views/components/repost-form.vue b/src/client/app/desktop/views/components/repost-form.vue deleted file mode 100644 index d5b1696757..0000000000 --- a/src/client/app/desktop/views/components/repost-form.vue +++ /dev/null @@ -1,131 +0,0 @@ -<template> -<div class="mk-renote-form"> - <mk-note-preview :note="note"/> - <template v-if="!quote"> - <footer> - <a class="quote" v-if="!quote" @click="onQuote">%i18n:desktop.tags.mk-renote-form.quote%</a> - <button class="cancel" @click="cancel">%i18n:desktop.tags.mk-renote-form.cancel%</button> - <button class="ok" @click="ok" :disabled="wait">{{ wait ? '%i18n:!desktop.tags.mk-renote-form.reposting%' : '%i18n:!desktop.tags.mk-renote-form.renote%' }}</button> - </footer> - </template> - <template v-if="quote"> - <mk-post-form ref="form" :renote="note" @posted="onChildFormPosted"/> - </template> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; - -export default Vue.extend({ - props: ['note'], - data() { - return { - wait: false, - quote: false - }; - }, - methods: { - ok() { - this.wait = true; - (this as any).api('notes/create', { - renoteId: this.note.id - }).then(data => { - this.$emit('posted'); - (this as any).apis.notify('%i18n:!desktop.tags.mk-renote-form.success%'); - }).catch(err => { - (this as any).apis.notify('%i18n:!desktop.tags.mk-renote-form.failure%'); - }).then(() => { - this.wait = false; - }); - }, - cancel() { - this.$emit('canceled'); - }, - onQuote() { - this.quote = true; - - this.$nextTick(() => { - (this.$refs.form as any).focus(); - }); - }, - onChildFormPosted() { - this.$emit('posted'); - } - } -}); -</script> - -<style lang="stylus" scoped> -@import '~const.styl' - -.mk-renote-form - - > .mk-note-preview - margin 16px 22px - - > footer - height 72px - background lighten($theme-color, 95%) - - > .quote - position absolute - bottom 16px - left 28px - line-height 40px - - button - display block - position absolute - bottom 16px - cursor pointer - padding 0 - margin 0 - width 120px - height 40px - font-size 1em - outline none - border-radius 4px - - &:focus - &:after - content "" - pointer-events none - position absolute - top -5px - right -5px - bottom -5px - left -5px - border 2px solid rgba($theme-color, 0.3) - border-radius 8px - - > .cancel - right 148px - color #888 - background linear-gradient(to bottom, #ffffff 0%, #f5f5f5 100%) - border solid 1px #e2e2e2 - - &:hover - background linear-gradient(to bottom, #f9f9f9 0%, #ececec 100%) - border-color #dcdcdc - - &:active - background #ececec - border-color #dcdcdc - - > .ok - right 16px - font-weight bold - color $theme-color-foreground - background linear-gradient(to bottom, lighten($theme-color, 25%) 0%, lighten($theme-color, 10%) 100%) - border solid 1px lighten($theme-color, 15%) - - &:hover - background linear-gradient(to bottom, lighten($theme-color, 8%) 0%, darken($theme-color, 8%) 100%) - border-color $theme-color - - &:active - background $theme-color - border-color $theme-color - -</style> diff --git a/src/client/app/desktop/views/components/settings.api.vue b/src/client/app/desktop/views/components/settings.api.vue index a43c6e8ea6..377f2e689b 100644 --- a/src/client/app/desktop/views/components/settings.api.vue +++ b/src/client/app/desktop/views/components/settings.api.vue @@ -29,8 +29,6 @@ export default Vue.extend({ <style lang="stylus" scoped> .root.api - color #4a535a - code display inline-block padding 4px 6px diff --git a/src/client/app/desktop/views/components/settings.vue b/src/client/app/desktop/views/components/settings.vue index 3d88ccb6c2..9439ded2fc 100644 --- a/src/client/app/desktop/views/components/settings.vue +++ b/src/client/app/desktop/views/components/settings.vue @@ -20,7 +20,7 @@ <section class="web" v-show="page == 'web'"> <h1>動作</h1> - <mk-switch v-model="os.i.clientSettings.fetchOnScroll" @change="onChangeFetchOnScroll" text="スクロールで自動読み込み"> + <mk-switch v-model="clientSettings.fetchOnScroll" @change="onChangeFetchOnScroll" text="スクロールで自動読み込み"> <span>ページを下までスクロールしたときに自動で追加のコンテンツを読み込みます。</span> </mk-switch> <mk-switch v-model="autoPopout" text="ウィンドウの自動ポップアウト"> @@ -37,13 +37,20 @@ <section class="web" v-show="page == 'web'"> <h1>デザインと表示</h1> <div class="div"> - <button class="ui button" @click="customizeHome">ホームをカスタマイズ</button> + <button class="ui button" @click="customizeHome" style="margin-bottom: 16px">ホームをカスタマイズ</button> </div> - <mk-switch v-model="os.i.clientSettings.showPostFormOnTopOfTl" @change="onChangeShowPostFormOnTopOfTl" text="タイムライン上部に投稿フォームを表示する"/> - <mk-switch v-model="os.i.clientSettings.showMaps" @change="onChangeShowMaps" text="マップの自動展開"> + <div class="div"> + <mk-switch v-model="darkmode" text="ダークモード"/> + <mk-switch v-model="clientSettings.circleIcons" @change="onChangeCircleIcons" text="円形のアイコンを使用"/> + <mk-switch v-model="clientSettings.gradientWindowHeader" @change="onChangeGradientWindowHeader" text="ウィンドウのタイトルバーにグラデーションを使用"/> + </div> + <mk-switch v-model="clientSettings.showPostFormOnTopOfTl" @change="onChangeShowPostFormOnTopOfTl" text="タイムライン上部に投稿フォームを表示する"/> + <mk-switch v-model="clientSettings.showReplyTarget" @change="onChangeShowReplyTarget" text="リプライ先を表示する"/> + <mk-switch v-model="clientSettings.showMyRenotes" @change="onChangeShowMyRenotes" text="自分の行ったRenoteをタイムラインに表示する"/> + <mk-switch v-model="clientSettings.showRenotedMyNotes" @change="onChangeShowRenotedMyNotes" text="Renoteされた自分の投稿をタイムラインに表示する"/> + <mk-switch v-model="clientSettings.showMaps" @change="onChangeShowMaps" text="マップの自動展開"> <span>位置情報が添付された投稿のマップを自動的に展開します。</span> </mk-switch> - <mk-switch v-model="os.i.clientSettings.gradientWindowHeader" @change="onChangeGradientWindowHeader" text="ウィンドウのタイトルバーにグラデーションを使用"/> </section> <section class="web" v-show="page == 'web'"> @@ -63,7 +70,7 @@ <section class="web" v-show="page == 'web'"> <h1>モバイル</h1> - <mk-switch v-model="os.i.clientSettings.disableViaMobile" @change="onChangeDisableViaMobile" text="「モバイルからの投稿」フラグを付けない"/> + <mk-switch v-model="clientSettings.disableViaMobile" @change="onChangeDisableViaMobile" text="「モバイルからの投稿」フラグを付けない"/> </section> <section class="web" v-show="page == 'web'"> @@ -76,6 +83,7 @@ <el-option label="ja-JP" value="ja"/> <el-option label="en-US" value="en"/> <el-option label="fr" value="fr"/> + <el-option label="pl" value="pl"/> </el-option-group> </el-select> <div class="none ui info"> @@ -228,6 +236,7 @@ export default Vue.extend({ version, latestVersion: undefined, checkingForUpdate: false, + darkmode: localStorage.getItem('darkmode') == 'true', enableSounds: localStorage.getItem('enableSounds') == 'true', autoPopout: localStorage.getItem('autoPopout') == 'true', apiViaStream: localStorage.getItem('apiViaStream') ? localStorage.getItem('apiViaStream') == 'true' : true, @@ -251,6 +260,9 @@ export default Vue.extend({ apiViaStream() { localStorage.setItem('apiViaStream', this.apiViaStream ? 'true' : 'false'); }, + darkmode() { + (this as any)._updateDarkmode_(this.darkmode); + }, enableSounds() { localStorage.setItem('enableSounds', this.enableSounds ? 'true' : 'false'); }, @@ -287,8 +299,8 @@ export default Vue.extend({ this.$emit('done'); }, onChangeFetchOnScroll(v) { - (this as any).api('i/update_client_setting', { - name: 'fetchOnScroll', + this.$store.dispatch('settings/set', { + key: 'fetchOnScroll', value: v }); }, @@ -297,27 +309,57 @@ export default Vue.extend({ autoWatch: v }); }, + onChangeDark(v) { + this.$store.dispatch('settings/set', { + key: 'dark', + value: v + }); + }, onChangeShowPostFormOnTopOfTl(v) { - (this as any).api('i/update_client_setting', { - name: 'showPostFormOnTopOfTl', + this.$store.dispatch('settings/set', { + key: 'showPostFormOnTopOfTl', + 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 }); }, onChangeShowMaps(v) { - (this as any).api('i/update_client_setting', { - name: 'showMaps', + this.$store.dispatch('settings/set', { + key: 'showMaps', + value: v + }); + }, + onChangeCircleIcons(v) { + this.$store.dispatch('settings/set', { + key: 'circleIcons', value: v }); }, onChangeGradientWindowHeader(v) { - (this as any).api('i/update_client_setting', { - name: 'gradientWindowHeader', + this.$store.dispatch('settings/set', { + key: 'gradientWindowHeader', value: v }); }, onChangeDisableViaMobile(v) { - (this as any).api('i/update_client_setting', { - name: 'disableViaMobile', + this.$store.dispatch('settings/set', { + key: 'disableViaMobile', value: v }); }, @@ -358,7 +400,7 @@ export default Vue.extend({ <style lang="stylus" scoped> @import '~const.styl' -.mk-settings +root(isDark) display flex width 100% height 100% @@ -369,13 +411,13 @@ export default Vue.extend({ height 100% padding 16px 0 0 0 overflow auto - border-right solid 1px #ddd + border-right solid 1px isDark ? #1c2023 : #ddd > p display block padding 10px 16px margin 0 - color #666 + color isDark ? #9aa2a7 : #666 cursor pointer user-select none transition margin-left 0.2s ease @@ -384,7 +426,7 @@ export default Vue.extend({ margin-right 4px &:hover - color #555 + color isDark ? #fff : #555 &.active margin-left 8px @@ -398,14 +440,14 @@ export default Vue.extend({ > section margin 32px - color #4a535a + color isDark ? #c4ccd2 : #4a535a > h1 margin 0 0 1em 0 padding 0 0 8px 0 font-size 1em - color #555 - border-bottom solid 1px #eee + color isDark ? #e3e7ea : #555 + border-bottom solid 1px isDark ? #1c2023 : #eee &, >>> * .ui.button.block @@ -418,13 +460,18 @@ export default Vue.extend({ margin 0 0 1em 0 padding 0 0 8px 0 font-size 1em - color #555 - border-bottom solid 1px #eee + color isDark ? #e3e7ea : #555 + border-bottom solid 1px isDark ? #1c2023 : #eee > .web > .div - border-bottom solid 1px #eee - padding 0 0 16px 0 - margin 0 0 16px 0 + border-bottom solid 1px isDark ? #1c2023 : #eee + margin 16px 0 + +.mk-settings[data-darkmode] + root(true) + +.mk-settings:not([data-darkmode]) + root(false) </style> 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 51ee93cba6..dd4012039b 100644 --- a/src/client/app/desktop/views/components/sub-note-content.vue +++ b/src/client/app/desktop/views/components/sub-note-content.vue @@ -1,6 +1,7 @@ <template> <div class="mk-sub-note-content"> <div class="body"> + <span v-if="note.isHidden" style="opacity: 0.5">(この投稿は非公開です)</span> <a class="reply" v-if="note.replyId">%fa:reply%</a> <mk-note-html :text="note.text" :i="os.i"/> <a class="rp" v-if="note.renoteId" :href="`/note:${note.renoteId}`">RP: ...</a> diff --git a/src/client/app/desktop/views/components/timeline.core.vue b/src/client/app/desktop/views/components/timeline.core.vue index 1e98f087e1..254a5b9d63 100644 --- a/src/client/app/desktop/views/components/timeline.core.vue +++ b/src/client/app/desktop/views/components/timeline.core.vue @@ -1,24 +1,23 @@ <template> -<div class="mk-home-timeline"> +<div class="mk-timeline-core"> <mk-friends-maker v-if="src == 'home' && alone"/> <div class="fetching" v-if="fetching"> <mk-ellipsis-icon/> </div> - <p class="empty" v-if="notes.length == 0 && !fetching"> - %fa:R comments%%i18n:@empty% - </p> - <mk-notes :notes="notes" ref="timeline"> - <button slot="footer" @click="more" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }"> - <template v-if="!moreFetching">%i18n:@load-more%</template> - <template v-if="moreFetching">%fa:spinner .pulse .fw%</template> - </button> + + <mk-notes ref="timeline" :more="canFetchMore ? more : null"> + <p :class="$style.empty" slot="empty"> + %fa:R comments%%i18n:@empty% + </p> </mk-notes> </div> </template> <script lang="ts"> import Vue from 'vue'; -import { url } from '../../../config'; +import getNoteSummary from '../../../../../renderers/get-note-summary'; + +const fetchLimit = 10; export default Vue.extend({ props: { @@ -33,9 +32,9 @@ export default Vue.extend({ fetching: true, moreFetching: false, existMore: false, - notes: [], connection: null, connectionId: null, + unreadCount: 0, date: null }; }, @@ -59,6 +58,10 @@ export default Vue.extend({ : this.src == 'local' ? 'notes/local-timeline' : 'notes/global-timeline'; + }, + + canFetchMore(): boolean { + return !this.moreFetching && !this.fetching && this.existMore; } }, @@ -72,6 +75,9 @@ export default Vue.extend({ this.connection.on('unfollow', this.onChangeFollowing); } + document.addEventListener('keydown', this.onKeydown); + document.addEventListener('visibilitychange', this.onVisibilitychange, false); + this.fetch(); }, @@ -82,56 +88,62 @@ export default Vue.extend({ this.connection.off('unfollow', this.onChangeFollowing); } this.stream.dispose(this.connectionId); + + document.removeEventListener('keydown', this.onKeydown); + document.removeEventListener('visibilitychange', this.onVisibilitychange); }, methods: { - fetch(cb?) { + fetch() { this.fetching = true; - (this as any).api(this.endpoint, { - limit: 11, - untilDate: this.date ? this.date.getTime() : undefined - }).then(notes => { - if (notes.length == 11) { - notes.pop(); - this.existMore = true; - } - this.notes = notes; - this.fetching = false; - this.$emit('loaded'); - if (cb) cb(); - }); + (this.$refs.timeline as any).init(() => new Promise((res, rej) => { + (this as any).api(this.endpoint, { + limit: fetchLimit + 1, + untilDate: this.date ? this.date.getTime() : undefined, + includeMyRenotes: (this as any).clientSettings.showMyRenotes, + includeRenotedMyNotes: (this as any).clientSettings.showRenotedMyNotes + }).then(notes => { + if (notes.length == fetchLimit + 1) { + notes.pop(); + this.existMore = true; + } + res(notes); + this.fetching = false; + this.$emit('loaded'); + }, rej); + })); }, more() { - if (this.moreFetching || this.fetching || this.notes.length == 0 || !this.existMore) return; + if (!this.canFetchMore) return; + this.moreFetching = true; + (this as any).api(this.endpoint, { - limit: 11, - untilId: this.notes[this.notes.length - 1].id + limit: fetchLimit + 1, + untilId: (this.$refs.timeline as any).tail().id, + includeMyRenotes: (this as any).clientSettings.showMyRenotes, + includeRenotedMyNotes: (this as any).clientSettings.showRenotedMyNotes }).then(notes => { - if (notes.length == 11) { + if (notes.length == fetchLimit + 1) { notes.pop(); } else { this.existMore = false; } - this.notes = this.notes.concat(notes); + notes.forEach(n => (this.$refs.timeline as any).append(n)); this.moreFetching = false; }); }, onNote(note) { - // サウンドを再生する - if ((this as any).os.isEnableSounds) { - const sound = new Audio(`${url}/assets/post.mp3`); - sound.volume = localStorage.getItem('soundVolume') ? parseInt(localStorage.getItem('soundVolume'), 10) / 100 : 0.5; - sound.play(); + if (document.hidden && note.userId !== (this as any).os.i.id) { + this.unreadCount++; + document.title = `(${this.unreadCount}) ${getNoteSummary(note)}`; } - this.notes.unshift(note); - - const isTop = window.scrollY > 8; - if (isTop) this.notes.pop(); + // Prepend a note + (this.$refs.timeline as any).prepend(note); }, onChangeFollowing() { @@ -145,31 +157,51 @@ export default Vue.extend({ warp(date) { this.date = date; this.fetch(); + }, + + onVisibilitychange() { + if (!document.hidden) { + this.unreadCount = 0; + document.title = 'Misskey'; + } + }, + + onKeydown(e) { + if (e.target.tagName != 'INPUT' && e.target.tagName != 'TEXTAREA') { + if (e.which == 84) { // t + this.focus(); + } + } } } }); </script> <style lang="stylus" scoped> -.mk-home-timeline +@import '~const.styl' + +.mk-timeline-core > .mk-friends-maker border-bottom solid 1px #eee > .fetching padding 64px 0 - > .empty - display block - margin 0 auto - padding 32px - max-width 400px - text-align center - color #999 +</style> + +<style lang="stylus" module> +.empty + display block + margin 0 auto + padding 32px + max-width 400px + text-align center + color #999 - > [data-fa] - display block - margin-bottom 16px - font-size 3em - color #ccc + > [data-fa] + display block + margin-bottom 16px + font-size 3em + color #ccc </style> diff --git a/src/client/app/desktop/views/components/timeline.vue b/src/client/app/desktop/views/components/timeline.vue index e0215ad1a2..a776e40a24 100644 --- a/src/client/app/desktop/views/components/timeline.vue +++ b/src/client/app/desktop/views/components/timeline.vue @@ -1,19 +1,23 @@ <template> <div class="mk-timeline"> <header> - <span :data-is-active="src == 'home'" @click="src = 'home'">%fa:home% ホーム</span> - <span :data-is-active="src == 'local'" @click="src = 'local'">%fa:R comments% ローカル</span> - <span :data-is-active="src == 'global'" @click="src = 'global'">%fa:globe% グローバル</span> + <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 == 'global'" @click="src = 'global'">%fa:globe% %i18n:@global%</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> </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 == 'global'" ref="tl" key="global" src="global"/> + <mk-user-list-timeline v-if="src == 'list'" ref="tl" :key="list.id" :list="list"/> </div> </template> <script lang="ts"> import Vue from 'vue'; import XCore from './timeline.core.vue'; +import MkUserListsWindow from './user-lists-window.vue'; export default Vue.extend({ components: { @@ -22,44 +26,35 @@ export default Vue.extend({ data() { return { - src: 'home' + src: 'home', + list: null }; }, - mounted() { - document.addEventListener('keydown', this.onKeydown); - window.addEventListener('scroll', this.onScroll); - - console.log(this.$refs.tl); + created() { + if ((this as any).os.i.followingCount == 0) { + this.src = 'local'; + } + }, + mounted() { (this.$refs.tl as any).$once('loaded', () => { this.$emit('loaded'); }); }, - beforeDestroy() { - document.removeEventListener('keydown', this.onKeydown); - window.removeEventListener('scroll', this.onScroll); - }, - methods: { - onScroll() { - if ((this as any).os.i.clientSettings.fetchOnScroll !== false) { - const current = window.scrollY + window.innerHeight; - if (current > document.body.offsetHeight - 8) (this.$refs.tl as any).more(); - } - }, - - onKeydown(e) { - if (e.target.tagName != 'INPUT' && e.target.tagName != 'TEXTAREA') { - if (e.which == 84) { // t - (this.$refs.tl as any).focus(); - } - } - }, - warp(date) { (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(); + }); } } }); @@ -68,26 +63,68 @@ export default Vue.extend({ <style lang="stylus" scoped> @import '~const.styl' -.mk-timeline - background #fff - border solid 1px rgba(0, 0, 0, 0.075) +root(isDark) + background isDark ? #282C37 : #fff + border solid 1px rgba(#000, 0.075) border-radius 6px > header - padding 8px 16px - border-bottom solid 1px #eee + padding 0 8px + z-index 10 + background isDark ? #313543 : #fff + border-radius 6px 6px 0 0 + box-shadow 0 1px isDark ? rgba(#000, 0.15) : rgba(#000, 0.08) + + > button + position absolute + z-index 2 + top 0 + right 0 + padding 0 + width 42px + font-size 0.9em + line-height 42px + color isDark ? #9baec8 : #ccc + + &:hover + color isDark ? #b2c1d5 : #aaa + + &:active + color isDark ? #b2c1d5 : #999 > span - margin-right 16px - line-height 27px - font-size 14px - color #555 + display inline-block + padding 0 10px + line-height 42px + font-size 12px + user-select none - &:not([data-is-active]) + &[data-active] color $theme-color + cursor default + font-weight bold + + &:before + content "" + display block + position absolute + bottom 0 + left -8px + width calc(100% + 16px) + height 2px + background $theme-color + + &:not([data-active]) + color isDark ? #9aa2a7 : #6f7477 cursor pointer &:hover - text-decoration underline + color isDark ? #d9dcde : #525a5f + +.mk-timeline[data-darkmode] + root(true) + +.mk-timeline:not([data-darkmode]) + root(false) </style> diff --git a/src/client/app/desktop/views/components/ui.header.account.vue b/src/client/app/desktop/views/components/ui.header.account.vue index 558aaa6dc8..fd15ea6006 100644 --- a/src/client/app/desktop/views/components/ui.header.account.vue +++ b/src/client/app/desktop/views/components/ui.header.account.vue @@ -2,32 +2,40 @@ <div class="account"> <button class="header" :data-active="isOpen" @click="toggle"> <span class="username">{{ os.i.username }}<template v-if="!isOpen">%fa:angle-down%</template><template v-if="isOpen">%fa:angle-up%</template></span> - <img class="avatar" :src="`${ os.i.avatarUrl }?thumbnail&size=64`" alt="avatar"/> + <mk-avatar class="avatar" :user="os.i"/> </button> <transition name="zoom-in-top"> <div class="menu" v-if="isOpen"> <ul> <li> - <router-link :to="`/@${ os.i.username }`">%fa:user%%i18n:@profile%%fa:angle-right%</router-link> + <router-link :to="`/@${ os.i.username }`">%fa:user%<span>%i18n:@profile%</span>%fa:angle-right%</router-link> </li> <li @click="drive"> - <p>%fa:cloud%%i18n:@drive%%fa:angle-right%</p> + <p>%fa:cloud%<span>%i18n:@drive%</span>%fa:angle-right%</p> </li> <li> - <a href="/i/mentions">%fa:at%%i18n:@mentions%%fa:angle-right%</a> + <router-link to="/i/favorites">%fa:star%<span>%i18n:@favorites%</span>%fa:angle-right%</router-link> + </li> + <li @click="list"> + <p>%fa:list%<span>%i18n:@lists%</span>%fa:angle-right%</p> </li> </ul> <ul> <li> - <a href="/i/customize-home">%fa:wrench%%i18n:@customize%%fa:angle-right%</a> + <router-link to="/i/customize-home">%fa:wrench%<span>%i18n:@customize%</span>%fa:angle-right%</router-link> </li> <li @click="settings"> - <p>%fa:cog%%i18n:@settings%%fa:angle-right%</p> + <p>%fa:cog%<span>%i18n:@settings%</span>%fa:angle-right%</p> </li> </ul> <ul> <li @click="signout"> - <p>%fa:power-off%%i18n:@signout%%fa:angle-right%</p> + <p class="signout">%fa:power-off%<span>%i18n:@signout%</span></p> + </li> + </ul> + <ul> + <li @click="dark"> + <p><span>%i18n:@dark%</span><template v-if="_darkmode_">%fa:moon%</template><template v-else>%fa:R moon%</template></p> </li> </ul> </div> @@ -37,6 +45,7 @@ <script lang="ts"> import Vue from 'vue'; +import MkUserListsWindow from './user-lists-window.vue'; import MkSettingsWindow from './settings-window.vue'; import MkDriveWindow from './drive-window.vue'; import contains from '../../../common/scripts/contains'; @@ -75,12 +84,22 @@ export default Vue.extend({ this.close(); (this as any).os.new(MkDriveWindow); }, + list() { + this.close(); + const w = (this as any).os.new(MkUserListsWindow); + w.$once('choosen', list => { + this.$router.push(`i/lists/${ list.id }`); + }); + }, settings() { this.close(); (this as any).os.new(MkSettingsWindow); }, signout() { (this as any).os.signout(); + }, + dark() { + (this as any)._updateDarkmode_(!(this as any)._darkmode_); } } }); @@ -89,7 +108,7 @@ export default Vue.extend({ <style lang="stylus" scoped> @import '~const.styl' -.account +root(isDark) > .header display block margin 0 @@ -104,13 +123,13 @@ export default Vue.extend({ &:hover &[data-active='true'] - color darken(#9eaba8, 20%) + color isDark ? #fff : darken(#9eaba8, 20%) > .avatar filter saturate(150%) &:active - color darken(#9eaba8, 30%) + color isDark ? #fff : darken(#9eaba8, 30%) > .username display block @@ -137,15 +156,16 @@ export default Vue.extend({ transition filter 100ms ease > .menu + $bgcolor = isDark ? #282c37 : #fff display block position absolute top 56px right -2px width 230px font-size 0.8em - background #fff + background $bgcolor border-radius 4px - box-shadow 0 1px 4px rgba(0, 0, 0, 0.25) + box-shadow 0 1px 4px rgba(#000, 0.25) &:before content "" @@ -156,7 +176,7 @@ export default Vue.extend({ right 12px border-top solid 14px transparent border-right solid 14px transparent - border-bottom solid 14px rgba(0, 0, 0, 0.1) + border-bottom solid 14px rgba(#000, 0.1) border-left solid 14px transparent &:after @@ -168,7 +188,7 @@ export default Vue.extend({ right 12px border-top solid 14px transparent border-right solid 14px transparent - border-bottom solid 14px #fff + border-bottom solid 14px $bgcolor border-left solid 14px transparent ul @@ -179,7 +199,7 @@ export default Vue.extend({ & + ul padding-top 10px - border-top solid 1px #eee + border-top solid 1px isDark ? #1c2023 : #eee > li display block @@ -193,16 +213,20 @@ export default Vue.extend({ padding 0 28px margin 0 line-height 40px - color #868C8C + color isDark ? #c8cece : #868C8C cursor pointer * pointer-events none - > [data-fa]:first-of-type + > span:first-child + padding-left 22px + + > [data-fa]:first-child margin-right 6px + width 16px - > [data-fa]:last-of-type + > [data-fa]:last-child display block position absolute top 0 @@ -220,9 +244,25 @@ export default Vue.extend({ &:active background darken($theme-color, 10%) + &.signout + $color = #e64137 + + &:hover, &:active + background $color + color #fff + + &:active + background darken($color, 10%) + .zoom-in-top-enter-active, .zoom-in-top-leave-active { transform-origin: center -16px; } +.account[data-darkmode] + root(true) + +.account:not([data-darkmode]) + root(false) + </style> diff --git a/src/client/app/desktop/views/components/ui.header.nav.vue b/src/client/app/desktop/views/components/ui.header.nav.vue index 19f72a86d7..0800d96eb6 100644 --- a/src/client/app/desktop/views/components/ui.header.nav.vue +++ b/src/client/app/desktop/views/components/ui.header.nav.vue @@ -99,7 +99,7 @@ export default Vue.extend({ <style lang="stylus" scoped> @import '~const.styl' -.nav +root(isDark) display inline-block margin 0 padding 0 @@ -131,7 +131,7 @@ export default Vue.extend({ padding 0 24px font-size 13px font-variant small-caps - color #9eaba8 + color isDark ? #b8c5ca : #9eaba8 text-decoration none transition none cursor pointer @@ -140,7 +140,7 @@ export default Vue.extend({ pointer-events none &:hover - color darken(#9eaba8, 20%) + color isDark ? #fff : darken(#9eaba8, 20%) text-decoration none > [data-fa]:first-child @@ -164,4 +164,10 @@ export default Vue.extend({ @media (max-width 700px) padding 0 12px +.nav[data-darkmode] + root(true) + +.nav:not([data-darkmode]) + root(false) + </style> diff --git a/src/client/app/desktop/views/components/ui.header.notifications.vue b/src/client/app/desktop/views/components/ui.header.notifications.vue index e9a6b9b04f..ea814dd7a3 100644 --- a/src/client/app/desktop/views/components/ui.header.notifications.vue +++ b/src/client/app/desktop/views/components/ui.header.notifications.vue @@ -84,7 +84,7 @@ export default Vue.extend({ <style lang="stylus" scoped> @import '~const.styl' -.notifications +root(isDark) > button display block @@ -101,10 +101,10 @@ export default Vue.extend({ &:hover &[data-active='true'] - color darken(#9eaba8, 20%) + color isDark ? #fff : darken(#9eaba8, 20%) &:active - color darken(#9eaba8, 30%) + color isDark ? #fff : darken(#9eaba8, 30%) > [data-fa].bell font-size 1.2em @@ -117,14 +117,15 @@ export default Vue.extend({ color $theme-color > .pop + $bgcolor = isDark ? #282c37 : #fff display block position absolute top 56px right -72px width 300px - background #fff + background $bgcolor border-radius 4px - box-shadow 0 1px 4px rgba(0, 0, 0, 0.25) + box-shadow 0 1px 4px rgba(#000, 0.25) &:before content "" @@ -135,7 +136,7 @@ export default Vue.extend({ right 74px border-top solid 14px transparent border-right solid 14px transparent - border-bottom solid 14px rgba(0, 0, 0, 0.1) + border-bottom solid 14px rgba(#000, 0.1) border-left solid 14px transparent &:after @@ -147,7 +148,7 @@ export default Vue.extend({ right 74px border-top solid 14px transparent border-right solid 14px transparent - border-bottom solid 14px #fff + border-bottom solid 14px $bgcolor border-left solid 14px transparent > .mk-notifications @@ -155,4 +156,10 @@ export default Vue.extend({ font-size 1rem overflow auto +.notifications[data-darkmode] + root(true) + +.notifications:not([data-darkmode]) + root(false) + </style> diff --git a/src/client/app/desktop/views/components/ui.header.search.vue b/src/client/app/desktop/views/components/ui.header.search.vue index 3167aab8ab..1ed28ba3a8 100644 --- a/src/client/app/desktop/views/components/ui.header.search.vue +++ b/src/client/app/desktop/views/components/ui.header.search.vue @@ -50,7 +50,7 @@ export default Vue.extend({ width 14em height 32px font-size 1em - background rgba(0, 0, 0, 0.05) + background rgba(#000, 0.05) outline none //border solid 1px #ddd border none @@ -62,7 +62,7 @@ export default Vue.extend({ color #9eaba8 &:hover - background rgba(0, 0, 0, 0.08) + background rgba(#000, 0.08) &:focus box-shadow 0 0 0 2px rgba($theme-color, 0.5) !important diff --git a/src/client/app/desktop/views/components/ui.header.vue b/src/client/app/desktop/views/components/ui.header.vue index 2b63030cd2..7729575b56 100644 --- a/src/client/app/desktop/views/components/ui.header.vue +++ b/src/client/app/desktop/views/components/ui.header.vue @@ -43,10 +43,13 @@ export default Vue.extend({ XClock, }, mounted() { + this.$store.commit('setUiHeaderHeight', 48); + if ((this as any).os.isSignedIn) { - const ago = (new Date().getTime() - new Date((this as any).os.i.lastUsedAt).getTime()) / 1000 + const ago = (new Date().getTime() - new Date((this as any).os.i.lastUsedAt).getTime()) / 1000; const isHisasiburi = ago >= 3600; (this as any).os.i.lastUsedAt = new Date(); + (this as any).os.bakeMe(); if (isHisasiburi) { (this.$refs.welcomeback as any).style.display = 'block'; (this.$refs.main as any).style.overflow = 'hidden'; @@ -101,7 +104,7 @@ root(isDark) top 0 z-index 1000 width 100% - box-shadow 0 1px 1px rgba(0, 0, 0, 0.075) + box-shadow 0 1px 1px rgba(#000, 0.075) > .main height 48px @@ -130,7 +133,7 @@ root(isDark) line-height 48px margin 0 text-align center - color #888 + color isDark ? #fff : #888 opacity 0 > .container @@ -169,10 +172,10 @@ root(isDark) > .mk-ui-header-search display none -.header[data-is-darkmode] +.header[data-darkmode] root(true) -.header +.header:not([data-darkmode]) root(false) </style> diff --git a/src/client/app/desktop/views/components/user-list-timeline.vue b/src/client/app/desktop/views/components/user-list-timeline.vue new file mode 100644 index 0000000000..59d6abbbc1 --- /dev/null +++ b/src/client/app/desktop/views/components/user-list-timeline.vue @@ -0,0 +1,93 @@ +<template> +<div> + <mk-notes ref="timeline" :more="existMore ? more : null"/> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { UserListStream } from '../../../common/scripts/streaming/user-list'; + +const fetchLimit = 10; + +export default Vue.extend({ + props: ['list'], + data() { + return { + fetching: true, + moreFetching: false, + existMore: false, + connection: null + }; + }, + watch: { + $route: 'init' + }, + mounted() { + this.init(); + }, + beforeDestroy() { + this.connection.close(); + }, + methods: { + init() { + if (this.connection) this.connection.close(); + this.connection = new UserListStream((this as any).os, (this as any).os.i, this.list.id); + this.connection.on('note', this.onNote); + this.connection.on('userAdded', this.onUserAdded); + this.connection.on('userRemoved', this.onUserRemoved); + + this.fetch(); + }, + fetch() { + this.fetching = true; + + (this.$refs.timeline as any).init(() => new Promise((res, rej) => { + (this as any).api('notes/user-list-timeline', { + listId: this.list.id, + limit: fetchLimit + 1, + includeMyRenotes: (this as any).clientSettings.showMyRenotes, + includeRenotedMyNotes: (this as any).clientSettings.showRenotedMyNotes + }).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; + + (this as any).api('notes/user-list-timeline', { + listId: this.list.id, + limit: fetchLimit + 1, + untilId: (this.$refs.timeline as any).tail().id, + includeMyRenotes: (this as any).clientSettings.showMyRenotes, + includeRenotedMyNotes: (this as any).clientSettings.showRenotedMyNotes + }).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; + }); + }, + onNote(note) { + // Prepend a note + (this.$refs.timeline as any).prepend(note); + }, + onUserAdded() { + this.fetch(); + }, + onUserRemoved() { + this.fetch(); + } + } +}); +</script> diff --git a/src/client/app/desktop/views/components/user-lists-window.vue b/src/client/app/desktop/views/components/user-lists-window.vue new file mode 100644 index 0000000000..d082610132 --- /dev/null +++ b/src/client/app/desktop/views/components/user-lists-window.vue @@ -0,0 +1,69 @@ +<template> +<mk-window ref="window" is-modal width="450px" height="500px" @closed="$destroy"> + <span slot="header">%fa:list% リスト</span> + + <div data-id="6e4caea3-d8f9-4ab7-96de-ab67fe8d5c82" :data-darkmode="_darkmode_"> + <button class="ui" @click="add">リストを作成</button> + <a v-for="list in lists" :key="list.id" @click="choice(list)">{{ list.title }}</a> + </div> +</mk-window> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + data() { + return { + fetching: true, + lists: [] + }; + }, + mounted() { + (this as any).api('users/lists/list').then(lists => { + this.fetching = false; + this.lists = lists; + }); + }, + methods: { + add() { + (this as any).apis.input({ + title: 'リスト名', + }).then(async title => { + const list = await (this as any).api('users/lists/create', { + title + }); + + this.$emit('choosen', list); + }); + }, + choice(list) { + this.$emit('choosen', list); + }, + close() { + (this as any).$refs.window.close(); + } + } +}); +</script> + +<style lang="stylus" scoped> + +root(isDark) + padding 16px + + > button + margin-bottom 16px + + > a + display block + padding 16px + border solid 1px isDark ? #1c2023 : #eee + border-radius 4px + +[data-id="6e4caea3-d8f9-4ab7-96de-ab67fe8d5c82"][data-darkmode] + root(true) + +[data-id="6e4caea3-d8f9-4ab7-96de-ab67fe8d5c82"]:not([data-darkmode]) + root(false) + +</style> diff --git a/src/client/app/desktop/views/components/user-preview.vue b/src/client/app/desktop/views/components/user-preview.vue index bcd79dc2af..cc5e021390 100644 --- a/src/client/app/desktop/views/components/user-preview.vue +++ b/src/client/app/desktop/views/components/user-preview.vue @@ -2,11 +2,9 @@ <div class="mk-user-preview"> <template v-if="u != null"> <div class="banner" :style="u.bannerUrl ? `background-image: url(${u.bannerUrl}?thumbnail&size=512)` : ''"></div> - <router-link class="avatar" :to="u | userPage"> - <img :src="`${u.avatarUrl}?thumbnail&size=64`" alt="avatar"/> - </router-link> + <mk-avatar class="avatar" :user="u" :disable-preview="true"/> <div class="title"> - <router-link class="name" :to="u | userPage">{{ u.name }}</router-link> + <router-link class="name" :to="u | userPage">{{ u | userName }}</router-link> <p class="username">@{{ u | acct }}</p> </div> <div class="description">{{ u.description }}</div> @@ -87,21 +85,21 @@ export default Vue.extend({ <style lang="stylus" scoped> @import '~const.styl' -.mk-user-preview +root(isDark) position absolute z-index 2048 margin-top -8px width 250px - background #fff + background isDark ? #282c37 : #fff background-clip content-box - border solid 1px rgba(0, 0, 0, 0.1) + border solid 1px rgba(#000, 0.1) border-radius 4px overflow hidden opacity 0 > .banner height 84px - background-color #f5f5f5 + background-color isDark ? #1c1e26 : #f5f5f5 background-size cover background-position center @@ -111,14 +109,10 @@ export default Vue.extend({ top 62px left 13px z-index 2 - - > img - display block - width 58px - height 58px - margin 0 - border solid 3px #fff - border-radius 8px + width 58px + height 58px + border solid 3px isDark ? #282c37 : #fff + border-radius 8px > .title display block @@ -129,19 +123,19 @@ export default Vue.extend({ margin 0 font-weight bold line-height 16px - color #656565 + color isDark ? #fff : #656565 > .username display block margin 0 line-height 16px font-size 0.8em - color #999 + color isDark ? #606984 : #999 > .description padding 0 16px font-size 0.7em - color #555 + color isDark ? #9ea4ad : #555 > .status padding 8px 16px @@ -164,4 +158,10 @@ export default Vue.extend({ top 92px right 8px +.mk-user-preview[data-darkmode] + root(true) + +.mk-user-preview:not([data-darkmode]) + root(false) + </style> 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 005c9cd6d3..dbad295178 100644 --- a/src/client/app/desktop/views/components/users-list.item.vue +++ b/src/client/app/desktop/views/components/users-list.item.vue @@ -1,8 +1,6 @@ <template> <div class="root item"> - <router-link class="avatar-anchor" :to="user | userPage" v-user-preview="user.id"> - <img class="avatar" :src="`${user.avatarUrl}?thumbnail&size=64`" alt="avatar"/> - </router-link> + <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> @@ -35,18 +33,13 @@ export default Vue.extend({ display block clear both - > .avatar-anchor + > .avatar display block float left margin 0 16px 0 0 - - > .avatar - display block - width 58px - height 58px - margin 0 - border-radius 8px - vertical-align bottom + width 58px + height 58px + border-radius 8px > .main float left diff --git a/src/client/app/desktop/views/components/users-list.vue b/src/client/app/desktop/views/components/users-list.vue index a08e76f573..13d0d07bbc 100644 --- a/src/client/app/desktop/views/components/users-list.vue +++ b/src/client/app/desktop/views/components/users-list.vue @@ -2,8 +2,8 @@ <div class="mk-users-list"> <nav> <div> - <span :data-is-active="mode == 'all'" @click="mode = 'all'">すべて<span>{{ count }}</span></span> - <span v-if="os.isSignedIn && youKnowCount" :data-is-active="mode == 'iknow'" @click="mode = 'iknow'">知り合い<span>{{ youKnowCount }}</span></span> + <span :data-active="mode == 'all'" @click="mode = 'all'">すべて<span>{{ count }}</span></span> + <span v-if="os.isSignedIn && youKnowCount" :data-active="mode == 'iknow'" @click="mode = 'iknow'">知り合い<span>{{ youKnowCount }}</span></span> </div> </nav> <div class="users" v-if="!fetching && users.length != 0"> @@ -98,7 +98,7 @@ export default Vue.extend({ * pointer-events none - &[data-is-active] + &[data-active] font-weight bold color $theme-color border-color $theme-color @@ -119,7 +119,7 @@ export default Vue.extend({ overflow auto > * - border-bottom solid 1px rgba(0, 0, 0, 0.05) + border-bottom solid 1px rgba(#000, 0.05) > * max-width 600px diff --git a/src/client/app/desktop/views/components/widget-container.vue b/src/client/app/desktop/views/components/widget-container.vue index 188a67313e..ab8327d39e 100644 --- a/src/client/app/desktop/views/components/widget-container.vue +++ b/src/client/app/desktop/views/components/widget-container.vue @@ -24,8 +24,8 @@ export default Vue.extend({ computed: { withGradient(): boolean { return (this as any).os.isSignedIn - ? (this as any).os.i.clientSettings.gradientWindowHeader != null - ? (this as any).os.i.clientSettings.gradientWindowHeader + ? (this as any).clientSettings.gradientWindowHeader != null + ? (this as any).clientSettings.gradientWindowHeader : false : false; } @@ -34,9 +34,9 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> -.mk-widget-container - background #fff - border solid 1px rgba(0, 0, 0, 0.075) +root(isDark) + background isDark ? #282C37 : #fff + border solid 1px rgba(#000, 0.075) border-radius 6px overflow hidden @@ -45,6 +45,8 @@ export default Vue.extend({ border none !important > header + background isDark ? #313543 : #fff + > .title z-index 1 margin 0 @@ -52,11 +54,11 @@ export default Vue.extend({ line-height 42px font-size 0.9em font-weight bold - color #888 - box-shadow 0 1px rgba(0, 0, 0, 0.07) + color isDark ? #e3e5e8 : #888 + box-shadow 0 1px rgba(#000, 0.07) > [data-fa] - margin-right 4px + margin-right 6px &:empty display none @@ -70,16 +72,23 @@ export default Vue.extend({ width 42px font-size 0.9em line-height 42px - color #ccc + color isDark ? #9baec8 : #ccc &:hover - color #aaa + color isDark ? #b2c1d5 : #aaa &:active - color #999 + color isDark ? #b2c1d5 : #999 &.withGradient > .title - background linear-gradient(to bottom, #fff, #ececec) + background isDark ? linear-gradient(to bottom, #313543, #1d2027) : linear-gradient(to bottom, #fff, #ececec) box-shadow 0 1px rgba(#000, 0.11) + +.mk-widget-container[data-darkmode] + root(true) + +.mk-widget-container:not([data-darkmode]) + root(false) + </style> diff --git a/src/client/app/desktop/views/components/window.vue b/src/client/app/desktop/views/components/window.vue index e2cab21799..2e7eb557b4 100644 --- a/src/client/app/desktop/views/components/window.vue +++ b/src/client/app/desktop/views/components/window.vue @@ -4,7 +4,7 @@ <div class="main" ref="main" tabindex="-1" :data-is-modal="isModal" @mousedown="onBodyMousedown" @keydown="onKeydown" :style="{ width, height }"> <div class="body"> <header ref="header" - :class="{ withGradient }" + :class="{ withGradient: clientSettings.gradientWindowHeader }" @contextmenu.prevent="() => {}" @mousedown.prevent="onHeaderMousedown" > <h1><slot name="header"></slot></h1> @@ -17,14 +17,16 @@ <slot></slot> </div> </div> - <div class="handle top" v-if="canResize" @mousedown.prevent="onTopHandleMousedown"></div> - <div class="handle right" v-if="canResize" @mousedown.prevent="onRightHandleMousedown"></div> - <div class="handle bottom" v-if="canResize" @mousedown.prevent="onBottomHandleMousedown"></div> - <div class="handle left" v-if="canResize" @mousedown.prevent="onLeftHandleMousedown"></div> - <div class="handle top-left" v-if="canResize" @mousedown.prevent="onTopLeftHandleMousedown"></div> - <div class="handle top-right" v-if="canResize" @mousedown.prevent="onTopRightHandleMousedown"></div> - <div class="handle bottom-right" v-if="canResize" @mousedown.prevent="onBottomRightHandleMousedown"></div> - <div class="handle bottom-left" v-if="canResize" @mousedown.prevent="onBottomLeftHandleMousedown"></div> + <template v-if="canResize"> + <div class="handle top" @mousedown.prevent="onTopHandleMousedown"></div> + <div class="handle right" @mousedown.prevent="onRightHandleMousedown"></div> + <div class="handle bottom" @mousedown.prevent="onBottomHandleMousedown"></div> + <div class="handle left" @mousedown.prevent="onLeftHandleMousedown"></div> + <div class="handle top-left" @mousedown.prevent="onTopLeftHandleMousedown"></div> + <div class="handle top-right" @mousedown.prevent="onTopRightHandleMousedown"></div> + <div class="handle bottom-right" @mousedown.prevent="onBottomRightHandleMousedown"></div> + <div class="handle bottom-left" @mousedown.prevent="onBottomLeftHandleMousedown"></div> + </template> </div> </div> </template> @@ -85,17 +87,10 @@ export default Vue.extend({ computed: { isFlexible(): boolean { - return this.height == null; + return this.height == 'auto'; }, canResize(): boolean { return !this.isFlexible; - }, - withGradient(): boolean { - return (this as any).os.isSignedIn - ? (this as any).os.i.clientSettings.gradientWindowHeader != null - ? (this as any).os.i.clientSettings.gradientWindowHeader - : false - : false; } }, @@ -465,7 +460,7 @@ export default Vue.extend({ <style lang="stylus" scoped> @import '~const.styl' -.mk-window +root(isDark) display block > .bg @@ -476,7 +471,7 @@ export default Vue.extend({ left 0 width 100% height 100% - background rgba(0, 0, 0, 0.7) + background rgba(#000, 0.7) opacity 0 pointer-events none @@ -493,7 +488,7 @@ export default Vue.extend({ &:focus &:not([data-is-modal]) > .body - box-shadow 0 0 0px 1px rgba($theme-color, 0.5), 0 2px 6px 0 rgba(0, 0, 0, 0.2) + box-shadow 0 0 0px 1px rgba($theme-color, 0.5), 0 2px 6px 0 rgba(#000, 0.2) > .handle $size = 8px @@ -559,9 +554,9 @@ export default Vue.extend({ > .body height 100% overflow hidden - background #fff + background isDark ? #282C37 : #fff border-radius 6px - box-shadow 0 2px 6px 0 rgba(0, 0, 0, 0.2) + box-shadow 0 2px 6px 0 rgba(#000, 0.2) > header $header-height = 40px @@ -571,12 +566,12 @@ export default Vue.extend({ overflow hidden white-space nowrap cursor move - background #fff + background isDark ? #313543 : #fff border-radius 6px 6px 0 0 box-shadow 0 1px 0 rgba(#000, 0.1) &.withGradient - background linear-gradient(to bottom, #fff, #ececec) + background isDark ? linear-gradient(to bottom, #313543, #1d2027) : linear-gradient(to bottom, #fff, #ececec) box-shadow 0 1px 0 rgba(#000, 0.15) &, * @@ -593,7 +588,7 @@ export default Vue.extend({ font-size 1em line-height $header-height font-weight normal - color #666 + color isDark ? #e3e5e8 : #666 > div:last-child position absolute @@ -608,16 +603,16 @@ export default Vue.extend({ padding 0 cursor pointer font-size 1em - color rgba(#000, 0.4) + color isDark ? #9baec8 : rgba(#000, 0.4) border none outline none background transparent &:hover - color rgba(#000, 0.6) + color isDark ? #b2c1d5 : rgba(#000, 0.6) &:active - color darken(#000, 30%) + color isDark ? #b2c1d5 : darken(#000, 30%) > [data-fa] padding 0 @@ -632,4 +627,10 @@ export default Vue.extend({ > .main > .body > .content height calc(100% - 40px) +.mk-window[data-darkmode] + root(true) + +.mk-window:not([data-darkmode]) + root(false) + </style> diff --git a/src/client/app/desktop/views/pages/favorites.vue b/src/client/app/desktop/views/pages/favorites.vue new file mode 100644 index 0000000000..d908c08f7c --- /dev/null +++ b/src/client/app/desktop/views/pages/favorites.vue @@ -0,0 +1,73 @@ +<template> +<mk-ui> + <main v-if="!fetching"> + <template v-for="favorite in favorites"> + <mk-note-detail :note="favorite.note" :key="favorite.note.id"/> + </template> + <a v-if="existMore" @click="more">さらに読み込む</a> + </main> +</mk-ui> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import Progress from '../../../common/scripts/loading'; + +export default Vue.extend({ + data() { + return { + fetching: true, + favorites: [], + existMore: false, + moreFetching: false + }; + }, + created() { + this.fetch(); + }, + methods: { + fetch() { + Progress.start(); + this.fetching = true; + + (this as any).api('i/favorites', { + limit: 11 + }).then(favorites => { + if (favorites.length == 11) { + this.existMore = true; + favorites.pop(); + } + + this.favorites = favorites; + this.fetching = false; + + Progress.done(); + }); + }, + more() { + this.moreFetching = true; + (this as any).api('i/favorites', { + limit: 11, + maxId: this.favorites[this.favorites.length - 1].id + }).then(favorites => { + if (favorites.length == 11) { + this.existMore = true; + favorites.pop(); + } else { + this.existMore = false; + } + + this.favorites = this.favorites.concat(favorites); + this.moreFetching = false; + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +main + margin 0 auto + padding 16px + max-width 700px +</style> diff --git a/src/client/app/desktop/views/pages/note.vue b/src/client/app/desktop/views/pages/note.vue index e92b0ff105..8502dd3d58 100644 --- a/src/client/app/desktop/views/pages/note.vue +++ b/src/client/app/desktop/views/pages/note.vue @@ -1,9 +1,11 @@ <template> <mk-ui> <main v-if="!fetching"> - <a v-if="note.next" :href="note.next">%fa:angle-up%%i18n:@next%</a> <mk-note-detail :note="note"/> - <a v-if="note.prev" :href="note.prev">%fa:angle-down%%i18n:@prev%</a> + <footer> + <router-link v-if="note.next" :to="note.next">%fa:angle-left% %i18n:@next%</router-link> + <router-link v-if="note.prev" :to="note.prev">%i18n:@prev% %fa:angle-right%</router-link> + </footer> </main> </mk-ui> </template> @@ -48,17 +50,12 @@ main padding 16px text-align center - > a - display inline-block + > footer + margin-top 16px - &:first-child - margin-bottom 4px - - &:last-child - margin-top 4px - - > [data-fa] - margin-right 4px + > a + display inline-block + margin 0 16px > .mk-note-detail margin 0 auto diff --git a/src/client/app/desktop/views/pages/search.vue b/src/client/app/desktop/views/pages/search.vue index 698154e667..67e1e3bfe0 100644 --- a/src/client/app/desktop/views/pages/search.vue +++ b/src/client/app/desktop/views/pages/search.vue @@ -114,7 +114,7 @@ export default Vue.extend({ .notes max-width 600px margin 0 auto - border solid 1px rgba(0, 0, 0, 0.075) + border solid 1px rgba(#000, 0.075) border-radius 6px overflow hidden diff --git a/src/client/app/desktop/views/pages/user-list.users.vue b/src/client/app/desktop/views/pages/user-list.users.vue new file mode 100644 index 0000000000..4236cdbb14 --- /dev/null +++ b/src/client/app/desktop/views/pages/user-list.users.vue @@ -0,0 +1,124 @@ +<template> +<div> + <mk-widget-container> + <template slot="header">%fa:users% ユーザー</template> + <button slot="func" title="ユーザーを追加" @click="add">%fa:plus%</button> + + <div data-id="d0b63759-a822-4556-a5ce-373ab966e08a"> + <p class="fetching" v-if="fetching">%fa:spinner .pulse .fw% %i18n:common.loading%<mk-ellipsis/></p> + <template v-else-if="users.length != 0"> + <div class="user" v-for="_user in users"> + <mk-avatar class="avatar" :user="_user"/> + <div class="body"> + <router-link class="name" :to="_user | userPage" v-user-preview="_user.id">{{ _user | userName }}</router-link> + <p class="username">@{{ _user | acct }}</p> + </div> + </div> + </template> + <p class="empty" v-else>%i18n:@no-one%</p> + </div> + </mk-widget-container> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +export default Vue.extend({ + props: { + list: { + type: Object, + required: true + } + }, + data() { + return { + fetching: true, + users: [] + }; + }, + mounted() { + (this as any).api('users/show', { + userIds: this.list.userIds + }).then(users => { + this.users = users; + this.fetching = false; + }); + }, + methods: { + add() { + (this as any).apis.input({ + title: 'ユーザー名', + }).then(async username => { + const user = await (this as any).api('users/show', { + username + }); + + (this as any).api('users/lists/push', { + listId: this.list.id, + userId: user.id + }); + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +root(isDark) + > .user + padding 16px + border-bottom solid 1px isDark ? #1c2023 : #eee + + &:last-child + border-bottom none + + &:after + content "" + display block + clear both + + > .avatar + display block + float left + margin 0 12px 0 0 + width 42px + height 42px + border-radius 8px + + > .body + float left + width calc(100% - 54px) + + > .name + margin 0 + font-size 16px + line-height 24px + color isDark ? #fff : #555 + + > .username + display block + margin 0 + font-size 15px + line-height 16px + color isDark ? #606984 : #ccc + + > .empty + margin 0 + padding 16px + text-align center + color #aaa + + > .fetching + margin 0 + padding 16px + text-align center + color #aaa + +[data-id="d0b63759-a822-4556-a5ce-373ab966e08a"][data-darkmode] + root(true) + +[data-id="d0b63759-a822-4556-a5ce-373ab966e08a"]:not([data-darkmode]) + root(false) + +</style> diff --git a/src/client/app/desktop/views/pages/user-list.vue b/src/client/app/desktop/views/pages/user-list.vue new file mode 100644 index 0000000000..2241b84e5e --- /dev/null +++ b/src/client/app/desktop/views/pages/user-list.vue @@ -0,0 +1,71 @@ +<template> +<mk-ui> + <div v-if="!fetching" data-id="02010e15-cc48-4245-8636-16078a9b623c"> + <div> + <div><h1>{{ list.title }}</h1></div> + <x-users :list="list"/> + </div> + <main> + <mk-user-list-timeline :list="list"/> + </main> + </div> +</mk-ui> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import XUsers from './user-list.users.vue'; + +export default Vue.extend({ + components: { + XUsers + }, + data() { + return { + fetching: true, + list: null + }; + }, + watch: { + $route: 'fetch' + }, + mounted() { + this.fetch(); + }, + methods: { + fetch() { + this.fetching = true; + + (this as any).api('users/lists/show', { + listId: this.$route.params.list + }).then(list => { + this.list = list; + this.fetching = false; + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +[data-id="02010e15-cc48-4245-8636-16078a9b623c"] + display flex + justify-content center + margin 0 auto + max-width 1200px + + > main + > div > div + > *:not(:last-child) + margin-bottom 16px + + > main + padding 16px + width calc(100% - 275px * 2) + + > div + width 275px + margin 0 + padding 16px 0 16px 16px + +</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 9ccbc7a310..4c1b91e7a6 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 @@ -38,7 +38,7 @@ export default Vue.extend({ <style lang="stylus" scoped> .followers-you-know background #fff - border solid 1px rgba(0, 0, 0, 0.075) + border solid 1px rgba(#000, 0.075) border-radius 6px > .title @@ -49,7 +49,7 @@ export default Vue.extend({ font-size 0.9em font-weight bold color #888 - box-shadow 0 1px rgba(0, 0, 0, 0.07) + box-shadow 0 1px rgba(#000, 0.07) > i margin-right 4px 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 203f936478..4af0f0bca6 100644 --- a/src/client/app/desktop/views/pages/user/user.friends.vue +++ b/src/client/app/desktop/views/pages/user/user.friends.vue @@ -4,9 +4,7 @@ <p class="initializing" v-if="fetching">%fa:spinner .pulse .fw%%i18n:@loading%<mk-ellipsis/></p> <template v-if="!fetching && users.length != 0"> <div class="user" v-for="friend in users"> - <router-link class="avatar-anchor" :to="friend | userPage"> - <img class="avatar" :src="`${friend.avatarUrl}?thumbnail&size=42`" alt="" v-user-preview="friend.id"/> - </router-link> + <mk-avatar class="avatar" :user="friend"/> <div class="body"> <router-link class="name" :to="friend | userPage" v-user-preview="friend.id">{{ friend.name }}</router-link> <p class="username">@{{ friend | acct }}</p> @@ -44,7 +42,7 @@ export default Vue.extend({ <style lang="stylus" scoped> .friends background #fff - border solid 1px rgba(0, 0, 0, 0.075) + border solid 1px rgba(#000, 0.075) border-radius 6px > .title @@ -55,7 +53,7 @@ export default Vue.extend({ font-size 0.9em font-weight bold color #888 - box-shadow 0 1px rgba(0, 0, 0, 0.07) + box-shadow 0 1px rgba(#000, 0.07) > i margin-right 4px @@ -82,18 +80,13 @@ export default Vue.extend({ display block clear both - > .avatar-anchor + > .avatar display block float left margin 0 12px 0 0 - - > .avatar - display block - width 42px - height 42px - margin 0 - border-radius 8px - vertical-align bottom + width 42px + height 42px + border-radius 8px > .body float left 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 7a0672d3d7..60dc15b15d 100644 --- a/src/client/app/desktop/views/pages/user/user.header.vue +++ b/src/client/app/desktop/views/pages/user/user.header.vue @@ -1,12 +1,13 @@ <template> <div class="header" :data-is-dark-background="user.bannerUrl != null"> - <div class="is-remote" v-if="user.host != null"><p>%fa:exclamation-triangle% %i18n:@is-remote% <a :href="user.url || user.uri" target="_blank">%i18n:@view-remote%</a></p></div> - <div class="banner-container" :style="user.bannerUrl ? `background-image: url(${user.bannerUrl}?thumbnail&size=2048)` : ''"> - <div class="banner" ref="banner" :style="user.bannerUrl ? `background-image: url(${user.bannerUrl}?thumbnail&size=2048)` : ''" @click="onBannerClick"></div> + <div class="is-suspended" v-if="user.isSuspended"><p>%fa:exclamation-triangle% %i18n:@is-suspended%</p></div> + <div class="is-remote" v-if="user.host != null"><p>%fa:exclamation-triangle% %i18n:@is-remote%<a :href="user.url || user.uri" target="_blank">%i18n:@view-remote%</a></p></div> + <div class="banner-container" :style="style"> + <div class="banner" ref="banner" :style="style" @click="onBannerClick"></div> + <div class="fade"></div> </div> - <div class="fade"></div> <div class="container"> - <img class="avatar" :src="`${user.avatarUrl}?thumbnail&size=150`" alt="avatar"/> + <mk-avatar class="avatar" :user="user" :disable-preview="true"/> <div class="title"> <p class="name">{{ user | userName }}</p> <p class="username">@{{ user | acct }}</p> @@ -24,6 +25,15 @@ import Vue from 'vue'; export default Vue.extend({ props: ['user'], + computed: { + style(): any { + if (this.user.bannerUrl == null) return {}; + return { + backgroundColor: this.user.bannerColor ? `rgb(${ this.user.bannerColor.join(',') })` : null, + backgroundImage: `url(${ this.user.bannerUrl })` + }; + } + }, mounted() { if (this.user.bannerUrl) { window.addEventListener('load', this.onScroll); @@ -67,21 +77,27 @@ export default Vue.extend({ @import '~const.styl' .header - $banner-height = 320px $footer-height = 58px overflow hidden background #f7f7f7 - box-shadow 0 1px 1px rgba(0, 0, 0, 0.075) + box-shadow 0 1px 1px rgba(#000, 0.075) + > .is-suspended > .is-remote - padding 16px - color #573c08 - background #fff0db + &.is-suspended + color #570808 + background #ffdbdb + + &.is-remote + color #573c08 + background #fff0db > p margin 0 auto - max-width 1024px + padding 14px 16px + max-width 1200px + font-size 14px > a font-weight bold @@ -91,8 +107,8 @@ export default Vue.extend({ > .banner background-color #383838 - > .fade - background linear-gradient(transparent, rgba(0, 0, 0, 0.7)) + > .fade + background linear-gradient(transparent, rgba(#000, 0.7)) > .container > .title @@ -102,7 +118,7 @@ export default Vue.extend({ text-shadow 0 0 8px #000 > .banner-container - height $banner-height + height 320px overflow hidden background-size cover background-position center @@ -113,14 +129,12 @@ export default Vue.extend({ background-size cover background-position center - > .fade - $fade-hight = 78px - - position absolute - top ($banner-height - $fade-hight) - left 0 - width 100% - height $fade-hight + > .fade + position absolute + bottom 0 + left 0 + width 100% + height 78px > .container max-width 1200px @@ -134,10 +148,9 @@ export default Vue.extend({ z-index 2 width 160px height 160px - margin 0 border solid 3px #fff border-radius 8px - box-shadow 1px 1px 3px rgba(0, 0, 0, 0.2) + box-shadow 1px 1px 3px rgba(#000, 0.2) > .title position absolute diff --git a/src/client/app/desktop/views/pages/user/user.home.vue b/src/client/app/desktop/views/pages/user/user.home.vue index 7ca520ea7f..6b242a6129 100644 --- a/src/client/app/desktop/views/pages/user/user.home.vue +++ b/src/client/app/desktop/views/pages/user/user.home.vue @@ -65,7 +65,7 @@ export default Vue.extend({ width calc(100% - 275px * 2) > .timeline - border solid 1px rgba(0, 0, 0, 0.075) + border solid 1px rgba(#000, 0.075) border-radius 6px > div @@ -91,7 +91,7 @@ export default Vue.extend({ font-size 12px color #aaa background #fff - border solid 1px rgba(0, 0, 0, 0.075) + border solid 1px rgba(#000, 0.075) border-radius 6px a 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 9f749d5cc9..01c4c7b31e 100644 --- a/src/client/app/desktop/views/pages/user/user.photos.vue +++ b/src/client/app/desktop/views/pages/user/user.photos.vue @@ -41,7 +41,7 @@ export default Vue.extend({ <style lang="stylus" scoped> .photos background #fff - border solid 1px rgba(0, 0, 0, 0.075) + border solid 1px rgba(#000, 0.075) border-radius 6px > .title @@ -52,7 +52,7 @@ export default Vue.extend({ font-size 0.9em font-weight bold color #888 - box-shadow 0 1px rgba(0, 0, 0, 0.07) + box-shadow 0 1px rgba(#000, 0.07) > i margin-right 4px diff --git a/src/client/app/desktop/views/pages/user/user.profile.vue b/src/client/app/desktop/views/pages/user/user.profile.vue index 72750e1b3d..29e49f36a6 100644 --- a/src/client/app/desktop/views/pages/user/user.profile.vue +++ b/src/client/app/desktop/views/pages/user/user.profile.vue @@ -3,8 +3,17 @@ <div class="friend-form" v-if="os.isSignedIn && os.i.id != user.id"> <mk-follow-button :user="user" size="big"/> <p class="followed" v-if="user.isFollowed">%i18n:@follows-you%</p> - <p v-if="user.isMuted">%i18n:@muted% <a @click="unmute">%i18n:@unmute%</a></p> - <p v-if="!user.isMuted"><a @click="mute">%i18n:@mute%</a></p> + <p class="stalk" v-if="user.isFollowing"> + <span v-if="user.isStalking">%i18n:@stalking% <a @click="unstalk">%fa:meh% %i18n:@unstalk%</a></span> + <span v-if="!user.isStalking"><a @click="stalk">%fa:user-secret% %i18n:@stalk%</a></span> + </p> + </div> + <div class="action-form"> + <button class="mute ui" @click="user.isMuted ? unmute() : mute()"> + <span v-if="user.isMuted">%fa:eye% %i18n:@unmute%</span> + <span v-if="!user.isMuted">%fa:eye-slash% %i18n:@mute%</span> + </button> + <button class="mute ui" @click="list">%fa:list% リストに追加</button> </div> <div class="description" v-if="user.description">{{ user.description }}</div> <div class="birthday" v-if="user.host === null && user.profile.birthday"> @@ -26,6 +35,7 @@ import Vue from 'vue'; import * as age from 's-age'; import MkFollowingWindow from '../../components/following-window.vue'; import MkFollowersWindow from '../../components/followers-window.vue'; +import MkUserListsWindow from '../../components/user-lists-window.vue'; export default Vue.extend({ props: ['user'], @@ -47,6 +57,26 @@ export default Vue.extend({ }); }, + stalk() { + (this as any).api('following/stalk', { + userId: this.user.id + }).then(() => { + this.user.isStalking = true; + }, () => { + alert('error'); + }); + }, + + unstalk() { + (this as any).api('following/unstalk', { + userId: this.user.id + }).then(() => { + this.user.isStalking = false; + }, () => { + alert('error'); + }); + }, + mute() { (this as any).api('mute/create', { userId: this.user.id @@ -65,6 +95,21 @@ export default Vue.extend({ }, () => { alert('error'); }); + }, + + list() { + const w = (this as any).os.new(MkUserListsWindow); + w.$once('choosen', async list => { + w.close(); + await (this as any).api('users/lists/push', { + listId: list.id, + userId: this.user.id + }); + (this as any).apis.dialog({ + title: 'Done!', + text: `${this.user.name}を${list.title}に追加しました。` + }); + }); } } }); @@ -73,7 +118,7 @@ export default Vue.extend({ <style lang="stylus" scoped> .profile background #fff - border solid 1px rgba(0, 0, 0, 0.075) + border solid 1px rgba(#000, 0.075) border-radius 6px > *:first-child @@ -81,11 +126,9 @@ export default Vue.extend({ > .friend-form padding 16px + text-align center border-top solid 1px #eee - > .mk-big-follow-button - width 100% - > .followed margin 12px 0 0 0 padding 0 @@ -96,6 +139,20 @@ export default Vue.extend({ background #eefaff border-radius 4px + > .stalk + margin 12px 0 0 0 + + > .action-form + padding 16px + text-align center + border-top solid 1px #eee + + > * + width 100% + + &:not(:last-child) + margin-bottom 12px + > .description padding 16px color #555 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 55d6072a9d..9c9840c190 100644 --- a/src/client/app/desktop/views/pages/user/user.timeline.vue +++ b/src/client/app/desktop/views/pages/user/user.timeline.vue @@ -1,42 +1,36 @@ <template> <div class="timeline"> <header> - <span :data-is-active="mode == 'default'" @click="mode = 'default'">投稿</span> - <span :data-is-active="mode == 'with-replies'" @click="mode = 'with-replies'">投稿と返信</span> - <span :data-is-active="mode == 'with-media'" @click="mode = 'with-media'">メディア</span> + <span :data-active="mode == 'default'" @click="mode = 'default'">投稿</span> + <span :data-active="mode == 'with-replies'" @click="mode = 'with-replies'">投稿と返信</span> + <span :data-active="mode == 'with-media'" @click="mode = 'with-media'">メディア</span> </header> <div class="loading" v-if="fetching"> <mk-ellipsis-icon/> </div> - <p class="empty" v-if="empty">%fa:R comments%このユーザーはまだ何も投稿していないようです。</p> - <mk-notes ref="timeline" :notes="notes"> - <div slot="footer"> - <template v-if="!moreFetching">%fa:moon%</template> - <template v-if="moreFetching">%fa:spinner .pulse .fw%</template> - </div> + <mk-notes ref="timeline" :more="existMore ? more : null"> + <p class="empty" slot="empty">%fa:R comments%このユーザーはまだ何も投稿していないようです。</p> </mk-notes> </div> </template> <script lang="ts"> import Vue from 'vue'; + +const fetchLimit = 10; + export default Vue.extend({ props: ['user'], data() { return { fetching: true, moreFetching: false, + existMore: false, mode: 'default', unreadCount: 0, - notes: [], date: null }; }, - computed: { - empty(): boolean { - return this.notes.length == 0; - } - }, watch: { mode() { this.fetch(); @@ -44,13 +38,11 @@ export default Vue.extend({ }, mounted() { document.addEventListener('keydown', this.onDocumentKeydown); - window.addEventListener('scroll', this.onScroll); this.fetch(() => this.$emit('loaded')); }, beforeDestroy() { document.removeEventListener('keydown', this.onDocumentKeydown); - window.removeEventListener('scroll', this.onScroll); }, methods: { onDocumentKeydown(e) { @@ -61,36 +53,43 @@ export default Vue.extend({ } }, fetch(cb?) { - (this as any).api('users/notes', { - userId: this.user.id, - untilDate: this.date ? this.date.getTime() : undefined, - includeReplies: this.mode == 'with-replies', - withMedia: this.mode == 'with-media' - }).then(notes => { - this.notes = notes; - this.fetching = false; - if (cb) cb(); - }); + this.fetching = true; + (this.$refs.timeline as any).init(() => new Promise((res, rej) => { + (this as any).api('users/notes', { + userId: this.user.id, + limit: fetchLimit + 1, + untilDate: this.date ? this.date.getTime() : undefined, + includeReplies: this.mode == 'with-replies', + withMedia: this.mode == 'with-media' + }).then(notes => { + if (notes.length == fetchLimit + 1) { + notes.pop(); + this.existMore = true; + } + res(notes); + this.fetching = false; + if (cb) cb(); + }, rej); + })); }, more() { - if (this.moreFetching || this.fetching || this.notes.length == 0) return; this.moreFetching = true; (this as any).api('users/notes', { userId: this.user.id, + limit: fetchLimit + 1, includeReplies: this.mode == 'with-replies', withMedia: this.mode == 'with-media', - untilId: this.notes[this.notes.length - 1].id + untilId: (this.$refs.timeline as any).tail().id }).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; - this.notes = this.notes.concat(notes); }); }, - onScroll() { - const current = window.scrollY + window.innerHeight; - if (current > document.body.offsetHeight - 16/*遊び*/) { - this.more(); - } - }, warp(date) { this.date = date; this.fetch(); @@ -115,7 +114,7 @@ export default Vue.extend({ font-size 18px color #555 - &:not([data-is-active]) + &:not([data-active]) color $theme-color cursor pointer diff --git a/src/client/app/desktop/views/pages/welcome.vue b/src/client/app/desktop/views/pages/welcome.vue index 93d17b58fe..898b6b2179 100644 --- a/src/client/app/desktop/views/pages/welcome.vue +++ b/src/client/app/desktop/views/pages/welcome.vue @@ -8,9 +8,7 @@ <p>ようこそ! <b>Misskey</b>はTwitter風ミニブログSNSです。思ったことや皆と共有したいことを投稿しましょう。タイムラインを見れば、皆の関心事をすぐにチェックすることもできます。<a :href="aboutUrl">詳しく...</a></p> <p><button class="signup" @click="signup">はじめる</button><button class="signin" @click="signin">ログイン</button></p> <div class="users"> - <router-link v-for="user in users" :key="user.id" class="avatar-anchor" :to="user | userPage" v-user-preview="user.id"> - <img class="avatar" :src="`${user.avatarUrl}?thumbnail&size=64`" alt="avatar"/> - </router-link> + <mk-avatar class="avatar" v-for="user in users" :key="user.id" :user="user"/> </div> </div> <div> @@ -125,7 +123,8 @@ export default Vue.extend({ flex 1 $width = 1000px - background-image url('/assets/welcome-bg.svg') + background linear-gradient(to bottom, #1e1d65, #bd6659) + //background-image url('/assets/welcome-bg.svg') background-size cover background-position top center @@ -216,13 +215,9 @@ export default Vue.extend({ > * display inline-block margin 4px - - > * - display inline-block - width 38px - height 38px - vertical-align top - border-radius 6px + width 38px + height 38px + border-radius 6px > div:last-child @@ -230,14 +225,14 @@ export default Vue.extend({ width 410px background #fff border-radius 8px - box-shadow 0 0 0 12px rgba(0, 0, 0, 0.1) + box-shadow 0 0 0 12px rgba(#000, 0.1) overflow hidden > header z-index 1 padding 12px 16px color #888d94 - box-shadow 0 1px 0px rgba(0, 0, 0, 0.1) + box-shadow 0 1px 0px rgba(#000, 0.1) > div position absolute @@ -309,9 +304,3 @@ export default Vue.extend({ a color #666 </style> - -<style lang="stylus"> -html -body - background linear-gradient(to bottom, #1e1d65, #bd6659) -</style> diff --git a/src/client/app/desktop/views/widgets/activity.vue b/src/client/app/desktop/views/widgets/activity.vue index 0bdf4622af..1be87f590c 100644 --- a/src/client/app/desktop/views/widgets/activity.vue +++ b/src/client/app/desktop/views/widgets/activity.vue @@ -22,9 +22,11 @@ export default define({ } else { this.props.design++; } + this.save(); }, viewChanged(view) { this.props.view = view; + this.save(); } } }); diff --git a/src/client/app/desktop/views/widgets/channel.vue b/src/client/app/desktop/views/widgets/channel.vue index 7e96f8ee3d..d21aed40fd 100644 --- a/src/client/app/desktop/views/widgets/channel.vue +++ b/src/client/app/desktop/views/widgets/channel.vue @@ -37,6 +37,7 @@ export default define({ methods: { func() { this.props.compact = !this.props.compact; + this.save(); }, settings() { const id = window.prompt('チャンネルID'); @@ -61,7 +62,7 @@ export default define({ <style lang="stylus" scoped> .mkw-channel background #fff - border solid 1px rgba(0, 0, 0, 0.075) + border solid 1px rgba(#000, 0.075) border-radius 6px overflow hidden @@ -73,7 +74,7 @@ export default define({ font-size 0.9em font-weight bold color #888 - box-shadow 0 1px rgba(0, 0, 0, 0.07) + box-shadow 0 1px rgba(#000, 0.07) > [data-fa] margin-right 4px diff --git a/src/client/app/desktop/views/widgets/messaging.vue b/src/client/app/desktop/views/widgets/messaging.vue index 0f197fb2d7..791d2ff1bb 100644 --- a/src/client/app/desktop/views/widgets/messaging.vue +++ b/src/client/app/desktop/views/widgets/messaging.vue @@ -1,13 +1,18 @@ <template> <div class="mkw-messaging"> - <p class="title" v-if="props.design == 0">%fa:comments%%i18n:@title%</p> - <mk-messaging ref="index" compact @navigate="navigate"/> + <mk-widget-container :show-header="props.design == 0"> + <template slot="header">%fa:comments%%i18n:@title%</template> + <button slot="func" @click="add">%fa:plus%</button> + + <mk-messaging ref="index" compact @navigate="navigate"/> + </mk-widget-container> </div> </template> <script lang="ts"> import define from '../../../common/define-widget'; import MkMessagingRoomWindow from '../components/messaging-room-window.vue'; +import MkMessagingWindow from '../components/messaging-window.vue'; export default define({ name: 'messaging', @@ -21,12 +26,16 @@ export default define({ user: user }); }, + add() { + (this as any).os.new(MkMessagingWindow); + }, func() { if (this.props.design == 1) { this.props.design = 0; } else { this.props.design++; } + this.save(); } } }); @@ -34,25 +43,7 @@ export default define({ <style lang="stylus" scoped> .mkw-messaging - overflow hidden - background #fff - border solid 1px rgba(0, 0, 0, 0.075) - border-radius 6px - - > .title - z-index 2 - margin 0 - padding 0 16px - line-height 42px - font-size 0.9em - font-weight bold - color #888 - box-shadow 0 1px rgba(0, 0, 0, 0.07) - - > [data-fa] - margin-right 4px - - > .mk-messaging + .mk-messaging max-height 250px overflow auto diff --git a/src/client/app/desktop/views/widgets/notifications.vue b/src/client/app/desktop/views/widgets/notifications.vue index 0c2fa0434d..f75a091480 100644 --- a/src/client/app/desktop/views/widgets/notifications.vue +++ b/src/client/app/desktop/views/widgets/notifications.vue @@ -1,10 +1,11 @@ <template> <div class="mkw-notifications"> - <template v-if="!props.compact"> - <p class="title">%fa:R bell%%i18n:@title%</p> - <button @click="settings" title="%i18n:@settings%">%fa:cog%</button> - </template> - <mk-notifications/> + <mk-widget-container :show-header="!props.compact"> + <template slot="header">%fa:R bell%%i18n:@title%</template> + <button slot="func" title="%i18n:@settings%" @click="settings">%fa:cog%</button> + + <mk-notifications :class="$style.notifications"/> + </mk-widget-container> </div> </template> @@ -22,49 +23,15 @@ export default define({ }, func() { this.props.compact = !this.props.compact; + this.save(); } } }); </script> -<style lang="stylus" scoped> -.mkw-notifications - background #fff - border solid 1px rgba(0, 0, 0, 0.075) - border-radius 6px - - > .title - z-index 1 - margin 0 - padding 0 16px - line-height 42px - font-size 0.9em - font-weight bold - color #888 - box-shadow 0 1px rgba(0, 0, 0, 0.07) - - > [data-fa] - margin-right 4px - - > button - position absolute - z-index 2 - top 0 - right 0 - padding 0 - width 42px - font-size 0.9em - line-height 42px - color #ccc - - &:hover - color #aaa - - &:active - color #999 - - > .mk-notifications - max-height 300px - overflow auto +<style lang="stylus" module> +.notifications + max-height 300px + overflow auto </style> diff --git a/src/client/app/desktop/views/widgets/polls.vue b/src/client/app/desktop/views/widgets/polls.vue index 6cb1192c24..36fcc20636 100644 --- a/src/client/app/desktop/views/widgets/polls.vue +++ b/src/client/app/desktop/views/widgets/polls.vue @@ -1,16 +1,19 @@ <template> <div class="mkw-polls"> - <template v-if="!props.compact"> - <p class="title">%fa:chart-pie%%i18n:@title%</p> - <button @click="fetch" title="%i18n:@refresh%">%fa:sync%</button> - </template> - <div class="poll" v-if="!fetching && poll != null"> - <p v-if="poll.text"><router-link to="poll | notePage">{{ poll.text }}</router-link></p> - <p v-if="!poll.text"><router-link to="poll | notePage">%fa:link%</router-link></p> - <mk-poll :note="poll"/> - </div> - <p class="empty" v-if="!fetching && poll == null">%i18n:@nothing%</p> - <p class="fetching" v-if="fetching">%fa:spinner .pulse .fw%%i18n:common.loading%<mk-ellipsis/></p> + <mk-widget-container :show-header="!props.compact"> + <template slot="header">%fa:chart-pie%%i18n:@title%</template> + <button slot="func" title="%i18n:@refresh%" @click="fetch">%fa:sync%</button> + + <div class="mkw-polls--body" :data-darkmode="_darkmode_"> + <div class="poll" v-if="!fetching && poll != null"> + <p v-if="poll.text"><router-link to="poll | notePage">{{ poll.text }}</router-link></p> + <p v-if="!poll.text"><router-link to="poll | notePage">%fa:link%</router-link></p> + <mk-poll :note="poll"/> + </div> + <p class="empty" v-if="!fetching && poll == null">%i18n:@nothing%</p> + <p class="fetching" v-if="fetching">%fa:spinner .pulse .fw%%i18n:common.loading%<mk-ellipsis/></p> + </div> + </mk-widget-container> </div> </template> @@ -36,6 +39,7 @@ export default define({ methods: { func() { this.props.compact = !this.props.compact; + this.save(); }, fetch() { this.fetching = true; @@ -60,44 +64,11 @@ export default define({ </script> <style lang="stylus" scoped> -.mkw-polls - background #fff - border solid 1px rgba(0, 0, 0, 0.075) - border-radius 6px - - > .title - margin 0 - padding 0 16px - line-height 42px - font-size 0.9em - font-weight bold - color #888 - border-bottom solid 1px #eee - - > [data-fa] - margin-right 4px - - > button - position absolute - z-index 2 - top 0 - right 0 - padding 0 - width 42px - font-size 0.9em - line-height 42px - color #ccc - - &:hover - color #aaa - - &:active - color #999 - +root(isDark) > .poll padding 16px font-size 12px - color #555 + color isDark ? #9ea4ad : #555 > p margin 0 0 8px 0 @@ -120,4 +91,10 @@ export default define({ > [data-fa] margin-right 4px +.mkw-polls--body[data-darkmode] + root(true) + +.mkw-polls--body:not([data-darkmode]) + root(false) + </style> diff --git a/src/client/app/desktop/views/widgets/post-form.vue b/src/client/app/desktop/views/widgets/post-form.vue index 627943588f..69b21ad37a 100644 --- a/src/client/app/desktop/views/widgets/post-form.vue +++ b/src/client/app/desktop/views/widgets/post-form.vue @@ -29,6 +29,7 @@ export default define({ } else { this.props.design++; } + this.save(); }, onKeydown(e) { if ((e.which == 10 || e.which == 13) && (e.ctrlKey || e.metaKey)) this.post(); @@ -59,7 +60,7 @@ export default define({ .mkw-post-form background #fff overflow hidden - border solid 1px rgba(0, 0, 0, 0.075) + border solid 1px rgba(#000, 0.075) border-radius 6px > .title @@ -70,7 +71,7 @@ export default define({ font-size 0.9em font-weight bold color #888 - box-shadow 0 1px rgba(0, 0, 0, 0.07) + box-shadow 0 1px rgba(#000, 0.07) > [data-fa] margin-right 4px diff --git a/src/client/app/desktop/views/widgets/profile.vue b/src/client/app/desktop/views/widgets/profile.vue index 1b4b11de3c..3b01ed034d 100644 --- a/src/client/app/desktop/views/widgets/profile.vue +++ b/src/client/app/desktop/views/widgets/profile.vue @@ -8,12 +8,9 @@ title="クリックでバナー編集" @click="os.apis.updateBanner" ></div> - <img class="avatar" - :src="`${os.i.avatarUrl}?thumbnail&size=96`" + <mk-avatar class="avatar" :user="os.i" @click="os.apis.updateAvatar" - alt="avatar" title="クリックでアバター編集" - v-user-preview="os.i.id" /> <router-link class="name" :to="os.i | userPage">{{ os.i | userName }}</router-link> <p class="username">@{{ os.i | acct }}</p> @@ -36,16 +33,17 @@ export default define({ } else { this.props.design++; } + this.save(); } } }); </script> <style lang="stylus" scoped> -.mkw-profile +root(isDark) overflow hidden - background #fff - border solid 1px rgba(0, 0, 0, 0.075) + background isDark ? #282c37 : #fff + border solid 1px rgba(#000, 0.075) border-radius 6px &[data-compact] @@ -54,14 +52,14 @@ export default define({ display block width 100% height 100% - background rgba(0, 0, 0, 0.5) + background rgba(#000, 0.5) > .avatar top ((100px - 58px) / 2) left ((100px - 58px) / 2) border none border-radius 100% - box-shadow 0 0 16px rgba(0, 0, 0, 0.5) + box-shadow 0 0 16px rgba(#000, 0.5) > .name position absolute @@ -70,7 +68,7 @@ export default define({ margin 0 line-height 100px color #fff - text-shadow 0 0 8px rgba(0, 0, 0, 0.5) + text-shadow 0 0 8px rgba(#000, 0.5) > .username display none @@ -91,7 +89,7 @@ export default define({ > .banner height 100px - background-color #f5f5f5 + background-color isDark ? #303e4a : #f5f5f5 background-size cover background-position center cursor pointer @@ -103,10 +101,8 @@ export default define({ left 16px width 58px height 58px - margin 0 - border solid 3px #fff + border solid 3px isDark ? #282c37 : #fff border-radius 8px - vertical-align bottom cursor pointer > .name @@ -114,13 +110,19 @@ export default define({ margin 10px 0 0 84px line-height 16px font-weight bold - color #555 + color isDark ? #fff : #555 > .username display block margin 4px 0 8px 84px line-height 16px font-size 0.9em - color #999 + color isDark ? #606984 : #999 + +.mkw-profile[data-darkmode] + root(true) + +.mkw-profile:not([data-darkmode]) + root(false) </style> diff --git a/src/client/app/desktop/views/widgets/timemachine.vue b/src/client/app/desktop/views/widgets/timemachine.vue index 6db3b14c62..22a4120403 100644 --- a/src/client/app/desktop/views/widgets/timemachine.vue +++ b/src/client/app/desktop/views/widgets/timemachine.vue @@ -22,6 +22,7 @@ export default define({ } else { this.props.design++; } + this.save(); } } }); diff --git a/src/client/app/desktop/views/widgets/trends.vue b/src/client/app/desktop/views/widgets/trends.vue index fccda3f9d0..c33bf2f2f2 100644 --- a/src/client/app/desktop/views/widgets/trends.vue +++ b/src/client/app/desktop/views/widgets/trends.vue @@ -1,15 +1,18 @@ <template> <div class="mkw-trends"> - <template v-if="!props.compact"> - <p class="title">%fa:fire%%i18n:@title%</p> - <button @click="fetch" title="%i18n:@refresh%">%fa:sync%</button> - </template> - <p class="fetching" v-if="fetching">%fa:spinner .pulse .fw%%i18n:common.loading%<mk-ellipsis/></p> - <div class="note" v-else-if="note != null"> - <p class="text"><router-link :to="note | notePage">{{ note.text }}</router-link></p> - <p class="author">―<router-link :to="note.user | userPage">@{{ note.user | acct }}</router-link></p> - </div> - <p class="empty" v-else>%i18n:@nothing%</p> + <mk-widget-container :show-header="!props.compact"> + <template slot="header">%fa:fire%%i18n:@title%</template> + <button slot="func" title="%i18n:@refresh%" @click="fetch">%fa:sync%</button> + + <div class="mkw-trends--body"> + <p class="fetching" v-if="fetching">%fa:spinner .pulse .fw%%i18n:common.loading%<mk-ellipsis/></p> + <div class="note" v-else-if="note != null"> + <p class="text"><router-link :to="note | notePage">{{ note.text }}</router-link></p> + <p class="author">―<router-link :to="note.user | userPage">@{{ note.user | acct }}</router-link></p> + </div> + <p class="empty" v-else>%i18n:@nothing%</p> + </div> + </mk-widget-container> </div> </template> @@ -35,6 +38,7 @@ export default define({ methods: { func() { this.props.compact = !this.props.compact; + this.save(); }, fetch() { this.fetching = true; @@ -63,67 +67,41 @@ export default define({ </script> <style lang="stylus" scoped> -.mkw-trends - background #fff - border solid 1px rgba(0, 0, 0, 0.075) - border-radius 6px - - > .title - margin 0 - padding 0 16px - line-height 42px - font-size 0.9em - font-weight bold - color #888 - border-bottom solid 1px #eee +root(isDark) + .mkw-trends--body + > .note + padding 16px + font-size 12px + font-style oblique + color #555 - > [data-fa] - margin-right 4px + > p + margin 0 - > button - position absolute - z-index 2 - top 0 - right 0 - padding 0 - width 42px - font-size 0.9em - line-height 42px - color #ccc + > .text, + > .author + > a + color inherit - &:hover + > .empty + margin 0 + padding 16px + text-align center color #aaa - &:active - color #999 - - > .note - padding 16px - font-size 12px - font-style oblique - color #555 - - > p + > .fetching margin 0 + padding 16px + text-align center + color #aaa - > .text, - > .author - > a - color inherit - - > .empty - margin 0 - padding 16px - text-align center - color #aaa + > [data-fa] + margin-right 4px - > .fetching - margin 0 - padding 16px - text-align center - color #aaa +.mkw-trends[data-darkmode] + root(true) - > [data-fa] - margin-right 4px +.mkw-trends:not([data-darkmode]) + root(false) </style> diff --git a/src/client/app/desktop/views/widgets/users.vue b/src/client/app/desktop/views/widgets/users.vue index 0955ebbd71..328fa56697 100644 --- a/src/client/app/desktop/views/widgets/users.vue +++ b/src/client/app/desktop/views/widgets/users.vue @@ -1,23 +1,24 @@ <template> <div class="mkw-users"> - <template v-if="!props.compact"> - <p class="title">%fa:users%%i18n:@title%</p> - <button @click="refresh" title="%i18n:@refresh%">%fa:sync%</button> - </template> - <p class="fetching" v-if="fetching">%fa:spinner .pulse .fw%%i18n:common.loading%<mk-ellipsis/></p> - <template v-else-if="users.length != 0"> - <div class="user" v-for="_user in users"> - <router-link class="avatar-anchor" :to="_user | userPage"> - <img class="avatar" :src="`${_user.avatarUrl}?thumbnail&size=42`" alt="" v-user-preview="_user.id"/> - </router-link> - <div class="body"> - <router-link class="name" :to="_user | userPage" v-user-preview="_user.id">{{ _user | userName }}</router-link> - <p class="username">@{{ _user | acct }}</p> - </div> - <mk-follow-button :user="_user"/> + <mk-widget-container :show-header="!props.compact"> + <template slot="header">%fa:users%%i18n:@title%</template> + <button slot="func" title="%i18n:@refresh%" @click="refresh">%fa:sync%</button> + + <div class="mkw-users--body"> + <p class="fetching" v-if="fetching">%fa:spinner .pulse .fw%%i18n:common.loading%<mk-ellipsis/></p> + <template v-else-if="users.length != 0"> + <div class="user" v-for="_user in users"> + <mk-avatar class="avatar" :user="_user"/> + <div class="body"> + <router-link class="name" :to="_user | userPage" v-user-preview="_user.id">{{ _user | userName }}</router-link> + <p class="username">@{{ _user | acct }}</p> + </div> + <mk-follow-button :user="_user"/> + </div> + </template> + <p class="empty" v-else>%i18n:@no-one%</p> </div> - </template> - <p class="empty" v-else>%i18n:@no-one%</p> + </mk-widget-container> </div> </template> @@ -45,6 +46,7 @@ export default define({ methods: { func() { this.props.compact = !this.props.compact; + this.save(); }, fetch() { this.fetching = true; @@ -71,100 +73,69 @@ export default define({ </script> <style lang="stylus" scoped> -.mkw-users - background #fff - border solid 1px rgba(0, 0, 0, 0.075) - border-radius 6px - - > .title - margin 0 - padding 0 16px - line-height 42px - font-size 0.9em - font-weight bold - color #888 - border-bottom solid 1px #eee - - > [data-fa] - margin-right 4px - - > button - position absolute - z-index 2 - top 0 - right 0 - padding 0 - width 42px - font-size 0.9em - line-height 42px - color #ccc - - &:hover - color #aaa - - &:active - color #999 - - > .user - padding 16px - border-bottom solid 1px #eee +root(isDark) + .mkw-users--body + > .user + padding 16px + border-bottom solid 1px isDark ? #1c2023 : #eee - &:last-child - border-bottom none + &:last-child + border-bottom none - &:after - content "" - display block - clear both - - > .avatar-anchor - display block - float left - margin 0 12px 0 0 + &:after + content "" + display block + clear both > .avatar display block + float left + margin 0 12px 0 0 width 42px height 42px - margin 0 border-radius 8px - vertical-align bottom - > .body - float left - width calc(100% - 54px) + > .body + float left + width calc(100% - 54px) - > .name - margin 0 - font-size 16px - line-height 24px - color #555 + > .name + margin 0 + font-size 16px + line-height 24px + color isDark ? #fff : #555 - > .username - display block - margin 0 - font-size 15px - line-height 16px - color #ccc + > .username + display block + margin 0 + font-size 15px + line-height 16px + color isDark ? #606984 : #ccc + + > .mk-follow-button + position absolute + top 16px + right 16px - > .mk-follow-button - position absolute - top 16px - right 16px + > .empty + margin 0 + padding 16px + text-align center + color #aaa + + > .fetching + margin 0 + padding 16px + text-align center + color #aaa - > .empty - margin 0 - padding 16px - text-align center - color #aaa + > [data-fa] + margin-right 4px - > .fetching - margin 0 - padding 16px - text-align center - color #aaa +.mkw-users[data-darkmode] + root(true) - > [data-fa] - margin-right 4px +.mkw-users:not([data-darkmode]) + root(false) </style> diff --git a/src/client/app/init.css b/src/client/app/init.css index 2587f63943..fa59195f71 100644 --- a/src/client/app/init.css +++ b/src/client/app/init.css @@ -56,6 +56,13 @@ body > noscript { animation-delay: 0.32s; } +html[data-darkmode] #ini { + background: #191b22; +} + html[data-darkmode] #ini > p { + color: #fff; + } + @keyframes ini { 0%, 80%, 100% { opacity: 1; diff --git a/src/client/app/init.ts b/src/client/app/init.ts index 990933ec0e..4908b73b23 100644 --- a/src/client/app/init.ts +++ b/src/client/app/init.ts @@ -3,6 +3,7 @@ */ 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'; @@ -13,7 +14,7 @@ import ElementLocaleJa from 'element-ui/lib/locale/lang/ja'; import App from './app.vue'; import checkForUpdate from './common/scripts/check-for-update'; -import MiOS, { API } from './common/mios'; +import MiOS, { API } from './mios'; import { version, codename, lang } from './config'; let elementLocale; @@ -23,6 +24,7 @@ switch (lang) { default: elementLocale = ElementLocaleEn; break; } +Vue.use(Vuex); Vue.use(VueRouter); Vue.use(VModal); Vue.use(TreeView); @@ -47,6 +49,48 @@ Vue.mixin({ } }); +// Dark/Light +const bus = new Vue(); +Vue.mixin({ + data() { + return { + _darkmode_: localStorage.getItem('darkmode') == 'true' + }; + }, + beforeCreate() { + // なぜか警告が出るので + this._darkmode_ = localStorage.getItem('darkmode') == 'true'; + }, + beforeDestroy() { + bus.$off('updated', this._onDarkmodeUpdated_); + }, + mounted() { + this._onDarkmodeUpdated_(this._darkmode_); + bus.$on('updated', this._onDarkmodeUpdated_); + }, + methods: { + _updateDarkmode_(v) { + localStorage.setItem('darkmode', v.toString()); + if (v) { + document.documentElement.setAttribute('data-darkmode', 'true'); + } else { + document.documentElement.removeAttribute('data-darkmode'); + } + bus.$emit('updated', v); + }, + _onDarkmodeUpdated_(v) { + if (!this.$el || !this.$el.setAttribute) return; + if (v) { + this.$el.setAttribute('data-darkmode', 'true'); + } else { + this.$el.removeAttribute('data-darkmode'); + } + this._darkmode_ = v; + this.$forceUpdate(); + } + } +}); + /** * APP ENTRY POINT! */ @@ -102,21 +146,15 @@ export default (callback: (launch: (router: VueRouter, api?: (os: MiOS) => API) return { os, api: os.api, - apis: os.apis + apis: os.apis, + clientSettings: os.store.state.settings.data }; } }); const app = new Vue({ + store: os.store, router, - created() { - this.$watch('os.i', i => { - // キャッシュ更新 - localStorage.setItem('me', JSON.stringify(i)); - }, { - deep: true - }); - }, render: createEl => createEl(App) }); diff --git a/src/client/app/common/mios.ts b/src/client/app/mios.ts index 6d6d6b3e68..2373b0d8d2 100644 --- a/src/client/app/common/mios.ts +++ b/src/client/app/mios.ts @@ -3,18 +3,19 @@ import { EventEmitter } from 'eventemitter3'; import * as merge from 'object-assign-deep'; import * as uuid from 'uuid'; -import { hostname, apiUrl, swPublickey, version, lang, googleMapsApiKey } from '../config'; -import Progress from './scripts/loading'; -import Connection from './scripts/streaming/stream'; -import { HomeStreamManager } from './scripts/streaming/home'; -import { DriveStreamManager } from './scripts/streaming/drive'; -import { ServerStreamManager } from './scripts/streaming/server'; -import { MessagingIndexStreamManager } from './scripts/streaming/messaging-index'; -import { OthelloStreamManager } from './scripts/streaming/othello'; +import initStore from './store'; +import { hostname, apiUrl, swPublickey, version, lang, googleMapsApiKey } from './config'; +import Progress from './common/scripts/loading'; +import Connection from './common/scripts/streaming/stream'; +import { HomeStreamManager } from './common/scripts/streaming/home'; +import { DriveStreamManager } from './common/scripts/streaming/drive'; +import { ServerStreamManager } from './common/scripts/streaming/server'; +import { MessagingIndexStreamManager } from './common/scripts/streaming/messaging-index'; +import { OthelloStreamManager } from './common/scripts/streaming/othello'; -import Err from '../common/views/components/connect-failed.vue'; -import { LocalTimelineStreamManager } from './scripts/streaming/local-timeline'; -import { GlobalTimelineStreamManager } from './scripts/streaming/global-timeline'; +import Err from './common/views/components/connect-failed.vue'; +import { LocalTimelineStreamManager } from './common/scripts/streaming/local-timeline'; +import { GlobalTimelineStreamManager } from './common/scripts/streaming/global-timeline'; //#region api requests let spinner = null; @@ -78,6 +79,7 @@ export default class MiOS extends EventEmitter { propsData: props }).$mount(); document.body.appendChild(w.$el); + return w; } /** @@ -106,6 +108,8 @@ export default class MiOS extends EventEmitter { return localStorage.getItem('enableSounds') == 'true'; } + public store: ReturnType<typeof initStore>; + public apis: API; /** @@ -221,8 +225,14 @@ export default class MiOS extends EventEmitter { console.error.apply(null, args); } + public bakeMe() { + // ローカルストレージにキャッシュ + localStorage.setItem('me', JSON.stringify(this.i)); + } + public signout() { localStorage.removeItem('me'); + localStorage.removeItem('settings'); document.cookie = `i=; domain=${hostname}; expires=Thu, 01 Jan 1970 00:00:01 GMT;`; location.href = '/'; } @@ -232,6 +242,8 @@ export default class MiOS extends EventEmitter { * @param callback A function that call when initialized */ public async init(callback) { + this.store = initStore(this); + //#region Init stream managers this.streams.serverStream = new ServerStreamManager(this); @@ -296,21 +308,11 @@ export default class MiOS extends EventEmitter { // フェッチが完了したとき const fetched = me => { - if (me) { - // デフォルトの設定をマージ - me.clientSettings = Object.assign({ - fetchOnScroll: true, - showMaps: true, - showPostFormOnTopOfTl: false, - gradientWindowHeader: false - }, me.clientSettings); - - // ローカルストレージにキャッシュ - localStorage.setItem('me', JSON.stringify(me)); - } - this.i = me; + // ローカルストレージにキャッシュ + this.bakeMe(); + this.emit('signedin'); // Finish init @@ -327,6 +329,14 @@ export default class MiOS extends EventEmitter { // Get cached account data const cachedMe = JSON.parse(localStorage.getItem('me')); + //#region キャッシュされた設定を復元 + const cachedSettings = JSON.parse(localStorage.getItem('settings')); + + if (cachedSettings) { + this.store.dispatch('settings/merge', cachedSettings); + } + //#endregion + // キャッシュがあったとき if (cachedMe) { if (cachedMe.token == null) { @@ -340,12 +350,23 @@ export default class MiOS extends EventEmitter { // 後から新鮮なデータをフェッチ fetchme(cachedMe.token, freshData => { merge(cachedMe, freshData); + + this.store.dispatch('settings/merge', freshData.clientSettings); }); } else { // Get token from cookie const i = (document.cookie.match(/i=(!\w+)/) || [null, null])[1]; - fetchme(i, fetched); + fetchme(i, me => { + if (me) { + this.store.dispatch('settings/merge', me.clientSettings); + + fetched(me); + } else { + // Finish init + callback(); + } + }); } } @@ -450,7 +471,7 @@ export default class MiOS extends EventEmitter { }; const promise = new Promise((resolve, reject) => { - const viaStream = this.stream.hasConnection && + const viaStream = this.stream && this.stream.hasConnection && (localStorage.getItem('apiViaStream') ? localStorage.getItem('apiViaStream') == 'true' : true); if (viaStream) { diff --git a/src/client/app/mobile/script.ts b/src/client/app/mobile/script.ts index 1de4891973..2e9805e0d0 100644 --- a/src/client/app/mobile/script.ts +++ b/src/client/app/mobile/script.ts @@ -55,15 +55,15 @@ init((launch) => { { path: '/signup', name: 'signup', component: MkSignup }, { path: '/i/settings', component: MkSettings }, { path: '/i/settings/profile', component: MkProfileSetting }, - { path: '/i/notifications', component: MkNotifications }, - { path: '/i/messaging', component: MkMessaging }, + { path: '/i/notifications', name: 'notifications', component: MkNotifications }, + { path: '/i/messaging', name: 'messaging', component: MkMessaging }, { path: '/i/messaging/:user', component: MkMessagingRoom }, - { path: '/i/drive', component: MkDrive }, + { path: '/i/drive', name: 'drive', component: MkDrive }, { path: '/i/drive/folder/:folder', component: MkDrive }, { path: '/i/drive/file/:file', component: MkDrive }, { path: '/selectdrive', component: MkSelectDrive }, { path: '/search', component: MkSearch }, - { path: '/othello', component: MkOthello }, + { path: '/othello', name: 'othello', component: MkOthello }, { path: '/othello/:game', component: MkOthello }, { path: '/@:user', component: MkUser }, { path: '/@:user/followers', component: MkFollowers }, diff --git a/src/client/app/mobile/style.styl b/src/client/app/mobile/style.styl index 81912a2483..847ae8eec5 100644 --- a/src/client/app/mobile/style.styl +++ b/src/client/app/mobile/style.styl @@ -8,6 +8,10 @@ html height 100% + background #ececed + + &[data-darkmode] + background #191B22 body display flex 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 41536afbd4..d95d5fa223 100644 --- a/src/client/app/mobile/views/components/drive-file-chooser.vue +++ b/src/client/app/mobile/views/components/drive-file-chooser.vue @@ -54,7 +54,7 @@ export default Vue.extend({ width 100% height 100% padding 8px - background rgba(0, 0, 0, 0.2) + background rgba(#000, 0.2) > .body width 100% 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 bfd8fbda6f..7934fb7816 100644 --- a/src/client/app/mobile/views/components/drive-folder-chooser.vue +++ b/src/client/app/mobile/views/components/drive-folder-chooser.vue @@ -38,7 +38,7 @@ export default Vue.extend({ width 100% height 100% padding 8px - background rgba(0, 0, 0, 0.2) + background rgba(#000, 0.2) > .body width 100% 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 c7be7d1879..764822e98c 100644 --- a/src/client/app/mobile/views/components/drive.file-detail.vue +++ b/src/client/app/mobile/views/components/drive.file-detail.vue @@ -139,7 +139,7 @@ export default Vue.extend({ max-width 100% max-height 300px margin 0 auto - box-shadow 1px 1px 4px rgba(0, 0, 0, 0.2) + box-shadow 1px 1px 4px rgba(#000, 0.2) > footer padding 8px 8px 0 8px @@ -226,7 +226,7 @@ export default Vue.extend({ background-color #767676 background-image none border-color #444 - box-shadow 0 1px 3px rgba(0, 0, 0, 0.075), inset 0 0 5px rgba(0, 0, 0, 0.2) + box-shadow 0 1px 3px rgba(#000, 0.075), inset 0 0 5px rgba(#000, 0.2) > [data-fa] margin-right 4px diff --git a/src/client/app/mobile/views/components/drive.vue b/src/client/app/mobile/views/components/drive.vue index 7aa666e1bb..ef3432a3ec 100644 --- a/src/client/app/mobile/views/components/drive.vue +++ b/src/client/app/mobile/views/components/drive.vue @@ -474,11 +474,11 @@ export default Vue.extend({ overflow auto white-space nowrap font-size 0.9em - color rgba(0, 0, 0, 0.67) + color rgba(#000, 0.67) -webkit-backdrop-filter blur(12px) backdrop-filter blur(12px) background-color rgba(#fff, 0.75) - border-bottom solid 1px rgba(0, 0, 0, 0.13) + border-bottom solid 1px rgba(#000, 0.13) > p > a @@ -555,7 +555,7 @@ export default Vue.extend({ display inline-block position absolute top 0 - background rgba(0, 0, 0, 0.2) + background rgba(#000, 0.2) border-radius 100% animation sk-bounce 2.0s infinite ease-in-out diff --git a/src/client/app/mobile/views/components/friends-maker.vue b/src/client/app/mobile/views/components/friends-maker.vue index 961a5f568a..ba4abe341f 100644 --- a/src/client/app/mobile/views/components/friends-maker.vue +++ b/src/client/app/mobile/views/components/friends-maker.vue @@ -57,7 +57,7 @@ export default Vue.extend({ .mk-friends-maker background #fff border-radius 8px - box-shadow 0 0 0 1px rgba(0, 0, 0, 0.2) + box-shadow 0 0 0 1px rgba(#000, 0.2) > .title margin 0 diff --git a/src/client/app/mobile/views/components/index.ts b/src/client/app/mobile/views/components/index.ts index 9346700304..5ed8427b05 100644 --- a/src/client/app/mobile/views/components/index.ts +++ b/src/client/app/mobile/views/components/index.ts @@ -1,7 +1,6 @@ import Vue from 'vue'; import ui from './ui.vue'; -import timeline from './timeline.vue'; import note from './note.vue'; import notes from './notes.vue'; import mediaImage from './media-image.vue'; @@ -20,11 +19,11 @@ import notificationPreview from './notification-preview.vue'; import usersList from './users-list.vue'; import userPreview from './user-preview.vue'; import userTimeline from './user-timeline.vue'; +import userListTimeline from './user-list-timeline.vue'; import activity from './activity.vue'; import widgetContainer from './widget-container.vue'; Vue.component('mk-ui', ui); -Vue.component('mk-timeline', timeline); Vue.component('mk-note', note); Vue.component('mk-notes', notes); Vue.component('mk-media-image', mediaImage); @@ -43,5 +42,6 @@ Vue.component('mk-notification-preview', notificationPreview); Vue.component('mk-users-list', usersList); Vue.component('mk-user-preview', userPreview); Vue.component('mk-user-timeline', userTimeline); +Vue.component('mk-user-list-timeline', userListTimeline); Vue.component('mk-activity', activity); Vue.component('mk-widget-container', widgetContainer); diff --git a/src/client/app/mobile/views/components/media-image.vue b/src/client/app/mobile/views/components/media-image.vue index cfc2134988..92d1cdc6f5 100644 --- a/src/client/app/mobile/views/components/media-image.vue +++ b/src/client/app/mobile/views/components/media-image.vue @@ -6,12 +6,20 @@ import Vue from 'vue'; export default Vue.extend({ - props: ['image'], + props: { + image: { + type: Object, + required: true + }, + raw: { + default: false + } + }, computed: { style(): any { return { 'background-color': this.image.properties.avgColor ? `rgb(${this.image.properties.avgColor.join(',')})` : 'transparent', - 'background-image': `url(${this.image.url}?thumbnail&size=512)` + 'background-image': this.raw ? `url(${this.image.url})` : `url(${this.image.url}?thumbnail&size=512)` }; } } diff --git a/src/client/app/mobile/views/components/note-card.vue b/src/client/app/mobile/views/components/note-card.vue index 393fa9b831..89700b5e82 100644 --- a/src/client/app/mobile/views/components/note-card.vue +++ b/src/client/app/mobile/views/components/note-card.vue @@ -27,17 +27,17 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> -.mk-note-card +root(isDark) display inline-block width 150px //height 120px font-size 12px - background #fff + background isDark ? #282c37 : #fff border-radius 4px > a display block - color #2c3940 + color isDark ? #fff : #2c3940 &:hover text-decoration none @@ -75,11 +75,17 @@ export default Vue.extend({ left 0 width 100% height 20px - background linear-gradient(to bottom, rgba(255, 255, 255, 0) 0%, #fff 100%) + background isDark ? linear-gradient(to bottom, rgba(#282c37, 0) 0%, #282c37 100%) : linear-gradient(to bottom, rgba(#fff, 0) 0%, #fff 100%) > .mk-time display inline-block padding 8px color #aaa +.mk-note-card[data-darkmode] + root(true) + +.mk-note-card:not([data-darkmode]) + root(false) + </style> diff --git a/src/client/app/mobile/views/components/note-detail.sub.vue b/src/client/app/mobile/views/components/note-detail.sub.vue index 06f442d308..e515fda8a6 100644 --- a/src/client/app/mobile/views/components/note-detail.sub.vue +++ b/src/client/app/mobile/views/components/note-detail.sub.vue @@ -1,8 +1,6 @@ <template> <div class="root sub"> - <router-link class="avatar-anchor" :to="note.user | userPage"> - <img class="avatar" :src="`${note.user.avatarUrl}?thumbnail&size=64`" alt="avatar"/> - </router-link> + <mk-avatar class="avatar" :user="note.user"/> <div class="main"> <header> <router-link class="name" :to="note.user | userPage">{{ note.user | userName }}</router-link> @@ -27,35 +25,29 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> -.root.sub +root(isDark) padding 8px font-size 0.9em - background #fdfdfd + background isDark ? #21242d : #fdfdfd @media (min-width 500px) padding 12px + @media (min-width 600px) + padding 24px 32px + &:after content "" display block clear both - &:hover - > .main > footer > button - color #888 - - > .avatar-anchor + > .avatar display block float left margin 0 12px 0 0 - - > .avatar - display block - width 48px - height 48px - margin 0 - border-radius 8px - vertical-align bottom + width 48px + height 48px + border-radius 8px > .main float left @@ -63,6 +55,7 @@ export default Vue.extend({ > header display flex + align-items baseline margin-bottom 4px white-space nowrap @@ -71,7 +64,7 @@ export default Vue.extend({ margin 0 .5em 0 0 padding 0 overflow hidden - color #607073 + color isDark ? #fff : #607073 font-size 1em font-weight 700 text-align left @@ -84,11 +77,11 @@ export default Vue.extend({ > .username text-align left margin 0 .5em 0 0 - color #d1d8da + color isDark ? #606984 : #d1d8da > .time margin-left auto - color #b2b8bb + color isDark ? #606984 : #b2b8bb > .body @@ -97,7 +90,12 @@ export default Vue.extend({ margin 0 padding 0 font-size 1.1em - color #717171 + color isDark ? #959ba7 : #717171 -</style> +.root.sub[data-darkmode] + root(true) + +.root.sub:not([data-darkmode]) + root(false) +</style> diff --git a/src/client/app/mobile/views/components/note-detail.vue b/src/client/app/mobile/views/components/note-detail.vue index 7d2747751e..5a7226faac 100644 --- a/src/client/app/mobile/views/components/note-detail.vue +++ b/src/client/app/mobile/views/components/note-detail.vue @@ -17,29 +17,27 @@ </div> <div class="renote" v-if="isRenote"> <p> - <router-link class="avatar-anchor" :to="note.user | userPage"> - <img class="avatar" :src="`${note.user.avatarUrl}?thumbnail&size=32`" alt="avatar"/> - </router-link> - %fa:retweet%<router-link class="name" :to="note.user | userPage">{{ note.user | userName }}</router-link>がRenote + <mk-avatar class="avatar" :user="note.user"/>%fa:retweet%<router-link class="name" :to="note.user | userPage">{{ note.user | userName }}</router-link>がRenote </p> </div> <article> <header> - <router-link class="avatar-anchor" :to="p.user | userPage"> - <img class="avatar" :src="`${p.user.avatarUrl}?thumbnail&size=64`" alt="avatar"/> - </router-link> + <mk-avatar class="avatar" :user="p.user"/> <div> <router-link class="name" :to="p.user | userPage">{{ p.user | userName }}</router-link> <span class="username">@{{ p.user | acct }}</span> </div> </header> <div class="body"> - <mk-note-html v-if="p.text" :text="p.text" :i="os.i" :class="$style.text"/> + <div class="text"> + <span v-if="p.isHidden" style="opacity: 0.5">(この投稿は非公開です)</span> + <mk-note-html v-if="p.text" :text="p.text" :i="os.i"/> + </div> <div class="tags" v-if="p.tags && p.tags.length > 0"> <router-link v-for="tag in p.tags" :key="tag" :to="`/search?q=#${tag}`">{{ tag }}</router-link> </div> <div class="media" v-if="p.media.length > 0"> - <mk-media-list :media-list="p.media"/> + <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"/> @@ -55,7 +53,9 @@ <footer> <mk-reactions-viewer :note="p"/> <button @click="reply" title="%i18n:@reply%"> - %fa:reply%<p class="count" v-if="p.repliesCount > 0">{{ p.repliesCount }}</p> + <template v-if="p.reply">%fa:reply-all%</template> + <template v-else>%fa:reply%</template> + <p class="count" v-if="p.repliesCount > 0">{{ p.repliesCount }}</p> </button> <button @click="renote" title="Renote"> %fa:retweet%<p class="count" v-if="p.renoteCount > 0">{{ p.renoteCount }}</p> @@ -147,7 +147,7 @@ export default Vue.extend({ // Draw map if (this.p.geo) { - const shouldShowMap = (this as any).os.isSignedIn ? (this as any).os.i.clientSettings.showMaps : true; + const shouldShowMap = (this as any).os.isSignedIn ? (this as any).clientSettings.showMaps : true; if (shouldShowMap) { (this as any).os.getGoogleMaps().then(maps => { const uluru = new maps.LatLng(this.p.geo.coordinates[1], this.p.geo.coordinates[0]); @@ -207,15 +207,18 @@ export default Vue.extend({ <style lang="stylus" scoped> @import '~const.styl' -.mk-note-detail +root(isDark) overflow hidden margin 0 auto padding 0 width 100% text-align left - background #fff + background isDark ? #282C37 : #fff border-radius 8px - box-shadow 0 0 0 1px rgba(0, 0, 0, 0.2) + box-shadow 0 0 2px rgba(#000, 0.1) + + @media (min-width 500px) + box-shadow 0 8px 32px rgba(#000, 0.1) > .fetching padding 64px 0 @@ -229,45 +232,37 @@ export default Vue.extend({ text-align center color #999 cursor pointer - background #fafafa + background isDark ? #21242d : #fafafa outline none border none - border-bottom solid 1px #eef0f2 + border-bottom solid 1px isDark ? #1c2023 : #eef0f2 border-radius 6px 6px 0 0 box-shadow none &:hover - background #f6f6f6 - - &:active - background #f0f0f0 + background isDark ? #16181d : #f6f6f6 &:disabled color #ccc > .context > * - border-bottom 1px solid #eef0f2 + border-bottom 1px solid isDark ? #1c2023 : #eef0f2 > .renote color #9dbb00 - background linear-gradient(to bottom, #edfde2 0%, #fff 100%) + background isDark ? linear-gradient(to bottom, #314027 0%, #282c37 100%) : linear-gradient(to bottom, #edfde2 0%, #fff 100%) > p margin 0 padding 16px 32px - .avatar-anchor + .avatar display inline-block - - .avatar - vertical-align bottom - min-width 28px - min-height 28px - max-width 28px - max-height 28px - margin 0 8px 0 0 - border-radius 6px + width 28px + height 28px + margin 0 8px 0 0 + border-radius 6px [data-fa] margin-right 4px @@ -279,7 +274,7 @@ export default Vue.extend({ padding-top 8px > .reply-to - border-bottom 1px solid #eef0f2 + border-bottom 1px solid isDark ? #1c2023 : #eef0f2 > article padding 14px 16px 9px 16px @@ -292,36 +287,27 @@ export default Vue.extend({ display block clear both - &:hover - > .main > footer > button - color #888 - > header display flex - line-height 1.1 + line-height 1.1em - > .avatar-anchor + > .avatar display block - padding 0 .5em 0 0 - - > .avatar - display block - width 54px - height 54px - margin 0 - border-radius 8px - vertical-align bottom + margin 0 12px 0 0 + width 54px + height 54px + border-radius 8px - @media (min-width 500px) - width 60px - height 60px + @media (min-width 500px) + width 60px + height 60px > div > .name display inline-block margin .4em 0 - color #777 + color isDark ? #fff : #627079 font-size 16px font-weight bold text-align left @@ -334,11 +320,22 @@ export default Vue.extend({ display block text-align left margin 0 - color #ccc + color isDark ? #606984 : #ccc > .body padding 8px 0 + > .text + display block + margin 0 + padding 0 + overflow-wrap break-word + font-size 16px + color isDark ? #fff : #717171 + + @media (min-width 500px) + font-size 24px + > .renote margin 8px 0 @@ -394,7 +391,7 @@ export default Vue.extend({ > .time font-size 16px - color #c0c0c0 + color isDark ? #606984 : #c0c0c0 > footer font-size 1.2em @@ -406,14 +403,14 @@ export default Vue.extend({ border none box-shadow none font-size 1em - color #ddd + color isDark ? #606984 : #ddd cursor pointer &:not(:last-child) margin-right 28px &:hover - color #666 + color isDark ? #9198af : #666 > .count display inline @@ -425,20 +422,12 @@ export default Vue.extend({ > .replies > * - border-top 1px solid #eef0f2 + border-top 1px solid isDark ? #1c2023 : #eef0f2 -</style> +.mk-note-detail[data-darkmode] + root(true) -<style lang="stylus" module> -.text - display block - margin 0 - padding 0 - overflow-wrap break-word - font-size 16px - color #717171 - - @media (min-width 500px) - font-size 24px +.mk-note-detail:not([data-darkmode]) + root(false) </style> diff --git a/src/client/app/mobile/views/components/note-preview.vue b/src/client/app/mobile/views/components/note-preview.vue index b9a6db315d..ec11f23315 100644 --- a/src/client/app/mobile/views/components/note-preview.vue +++ b/src/client/app/mobile/views/components/note-preview.vue @@ -1,8 +1,6 @@ <template> <div class="mk-note-preview"> - <router-link class="avatar-anchor" :to="note.user | userPage"> - <img class="avatar" :src="`${note.user.avatarUrl}?thumbnail&size=64`" alt="avatar"/> - </router-link> + <mk-avatar class="avatar" :user="note.user"/> <div class="main"> <header> <router-link class="name" :to="note.user | userPage">{{ note.user | userName }}</router-link> @@ -27,33 +25,23 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> -.mk-note-preview +root(isDark) margin 0 padding 0 font-size 0.9em - background #fff &:after content "" display block clear both - &:hover - > .main > footer > button - color #888 - - > .avatar-anchor + > .avatar display block float left margin 0 12px 0 0 - - > .avatar - display block - width 48px - height 48px - margin 0 - border-radius 8px - vertical-align bottom + width 48px + height 48px + border-radius 8px > .main float left @@ -61,6 +49,7 @@ export default Vue.extend({ > header display flex + align-items baseline margin-bottom 4px white-space nowrap @@ -69,7 +58,7 @@ export default Vue.extend({ margin 0 .5em 0 0 padding 0 overflow hidden - color #607073 + color isDark ? #fff : #607073 font-size 1em font-weight 700 text-align left @@ -82,11 +71,11 @@ export default Vue.extend({ > .username text-align left margin 0 .5em 0 0 - color #d1d8da + color isDark ? #606984 : #d1d8da > .time margin-left auto - color #b2b8bb + color isDark ? #606984 : #b2b8bb > .body @@ -95,6 +84,12 @@ export default Vue.extend({ margin 0 padding 0 font-size 1.1em - color #717171 + color isDark ? #959ba7 : #717171 + +.mk-note-preview[data-darkmode] + root(true) + +.mk-note-preview: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 d489f3a053..82025291da 100644 --- a/src/client/app/mobile/views/components/note.sub.vue +++ b/src/client/app/mobile/views/components/note.sub.vue @@ -1,15 +1,22 @@ <template> <div class="sub"> - <router-link class="avatar-anchor" :to="note.user | userPage"> - <img class="avatar" :src="`${note.user.avatarUrl}?thumbnail&size=96`" alt="avatar"/> - </router-link> + <mk-avatar class="avatar" :user="note.user"/> <div class="main"> <header> <router-link class="name" :to="note.user | userPage">{{ note.user | userName }}</router-link> <span class="username">@{{ note.user | acct }}</span> - <router-link class="created-at" :to="note | notePage"> - <mk-time :time="note.createdAt"/> - </router-link> + <div class="info"> + <span class="mobile" v-if="note.viaMobile">%fa:mobile-alt%</span> + <router-link class="created-at" :to="note | notePage"> + <mk-time :time="note.createdAt"/> + </router-link> + <span class="visibility" v-if="note.visibility != 'public'"> + <template v-if="note.visibility == 'home'">%fa:home%</template> + <template v-if="note.visibility == 'followers'">%fa:unlock%</template> + <template v-if="note.visibility == 'specified'">%fa:envelope%</template> + <template v-if="note.visibility == 'private'">%fa:lock%</template> + </span> + </div> </header> <div class="body"> <mk-sub-note-content class="text" :note="note"/> @@ -27,34 +34,31 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> -.sub - font-size 0.9em +root(isDark) padding 16px + font-size 0.9em + background isDark ? #21242d : #fcfcfc + + @media (min-width 600px) + padding 24px 32px &:after content "" display block clear both - > .avatar-anchor + > .avatar display block float left margin 0 10px 0 0 + width 44px + height 44px + border-radius 8px @media (min-width 500px) margin-right 16px - - > .avatar - display block - width 44px - height 44px - margin 0 - border-radius 8px - vertical-align bottom - - @media (min-width 500px) - width 52px - height 52px + width 52px + height 52px > .main float left @@ -65,6 +69,7 @@ export default Vue.extend({ > header display flex + align-items baseline margin-bottom 2px white-space nowrap @@ -73,7 +78,7 @@ export default Vue.extend({ margin 0 0.5em 0 0 padding 0 overflow hidden - color #607073 + color isDark ? #fff : #607073 font-size 1em font-weight 700 text-align left @@ -86,24 +91,40 @@ export default Vue.extend({ > .username text-align left margin 0 - color #d1d8da + color isDark ? #606984 : #d1d8da - > .created-at + > .info margin-left auto - color #b2b8bb + font-size 0.9em + + > * + color isDark ? #606984 : #b2b8bb + + > .mobile + margin-right 6px + + > .visibility + margin-left 6px > .body + max-height 128px + overflow hidden > .text cursor default margin 0 padding 0 font-size 1.1em - color #717171 + color isDark ? #959ba7 : #717171 pre max-height 120px font-size 80% -</style> +.sub[data-darkmode] + root(true) +.sub: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 cccb8875b4..d66f5a1016 100644 --- a/src/client/app/mobile/views/components/note.vue +++ b/src/client/app/mobile/views/components/note.vue @@ -1,24 +1,18 @@ <template> <div class="note" :class="{ renote: isRenote }"> - <div class="reply-to" v-if="p.reply"> + <div class="reply-to" v-if="p.reply && (!os.isSignedIn || clientSettings.showReplyTarget)"> <x-sub :note="p.reply"/> </div> <div class="renote" v-if="isRenote"> - <p> - <router-link class="avatar-anchor" :to="note.user | userPage"> - <img class="avatar" :src="`${note.user.avatarUrl}?thumbnail&size=64`" alt="avatar"/> - </router-link> - %fa:retweet% - <span>{{ '%i18n:!@reposted-by%'.substr(0, '%i18n:!@reposted-by%'.indexOf('{')) }}</span> - <router-link class="name" :to="note.user | userPage">{{ note.user | userName }}</router-link> - <span>{{ '%i18n:!@reposted-by%'.substr('%i18n:!@reposted-by%'.indexOf('}') + 1) }}</span> - </p> + <mk-avatar class="avatar" :user="note.user"/> + %fa:retweet% + <span>{{ '%i18n:!@reposted-by%'.substr(0, '%i18n:!@reposted-by%'.indexOf('{')) }}</span> + <router-link class="name" :to="note.user | userPage">{{ note.user | userName }}</router-link> + <span>{{ '%i18n:!@reposted-by%'.substr('%i18n:!@reposted-by%'.indexOf('}') + 1) }}</span> <mk-time :time="note.createdAt"/> </div> <article> - <router-link class="avatar-anchor" :to="p.user | userPage"> - <img class="avatar" :src="`${p.user.avatarUrl}?thumbnail&size=96`" alt="avatar"/> - </router-link> + <mk-avatar class="avatar" :user="p.user"/> <div class="main"> <header> <router-link class="name" :to="p.user | userPage">{{ p.user | userName }}</router-link> @@ -29,36 +23,49 @@ <router-link class="created-at" :to="p | notePage"> <mk-time :time="p.createdAt"/> </router-link> + <span class="visibility" v-if="p.visibility != 'public'"> + <template v-if="p.visibility == 'home'">%fa:home%</template> + <template v-if="p.visibility == 'followers'">%fa:unlock%</template> + <template v-if="p.visibility == 'specified'">%fa:envelope%</template> + <template v-if="p.visibility == 'private'">%fa:lock%</template> + </span> </div> </header> <div class="body"> <p class="channel" v-if="p.channel != null"><a target="_blank">{{ p.channel.title }}</a>:</p> - <div class="text"> - <a class="reply" v-if="p.reply"> - %fa:reply% - </a> - <mk-note-html v-if="p.text" :text="p.text" :i="os.i" :class="$style.text"/> - <a class="rp" v-if="p.renote != null">RP:</a> + <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 ? '隠す' : 'もっと見る' }}</span> + </p> + <div class="content" v-show="p.cw == null || showContent"> + <div class="text"> + <span v-if="p.isHidden" style="opacity: 0.5">(この投稿は非公開です)</span> + <a class="reply" v-if="p.reply">%fa:reply%</a> + <mk-note-html v-if="p.text" :text="p.text" :i="os.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> + <mk-poll v-if="p.poll" :note="p" ref="pollViewer"/> + <div class="tags" v-if="p.tags && p.tags.length > 0"> + <router-link v-for="tag in p.tags" :key="tag" :to="`/search?q=#${tag}`">{{ tag }}</router-link> + </div> + <mk-url-preview v-for="url in urls" :url="url" :key="url"/> + <a class="location" v-if="p.geo" :href="`http://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> - <div class="media" v-if="p.media.length > 0"> - <mk-media-list :media-list="p.media"/> - </div> - <mk-poll v-if="p.poll" :note="p" ref="pollViewer"/> - <div class="tags" v-if="p.tags && p.tags.length > 0"> - <router-link v-for="tag in p.tags" :key="tag" :to="`/search?q=#${tag}`">{{ tag }}</router-link> - </div> - <mk-url-preview v-for="url in urls" :url="url" :key="url"/> - <a class="location" v-if="p.geo" :href="`http://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> <span class="app" v-if="p.app">via <b>{{ p.app.name }}</b></span> - <div class="renote" v-if="p.renote"> - <mk-note-preview :note="p.renote"/> - </div> </div> <footer> <mk-reactions-viewer :note="p" ref="reactionsViewer"/> <button @click="reply"> - %fa:reply%<p class="count" v-if="p.repliesCount > 0">{{ p.repliesCount }}</p> + <template v-if="p.reply">%fa:reply-all%</template> + <template v-else>%fa:reply%</template> + <p class="count" v-if="p.repliesCount > 0">{{ p.repliesCount }}</p> </button> <button @click="renote" title="Renote"> %fa:retweet%<p class="count" v-if="p.renoteCount > 0">{{ p.renoteCount }}</p> @@ -92,6 +99,7 @@ export default Vue.extend({ data() { return { + showContent: false, connection: null, connectionId: null }; @@ -142,7 +150,7 @@ export default Vue.extend({ // Draw map if (this.p.geo) { - const shouldShowMap = (this as any).os.isSignedIn ? (this as any).os.i.clientSettings.showMaps : true; + const shouldShowMap = (this as any).os.isSignedIn ? (this as any).clientSettings.showMaps : true; if (shouldShowMap) { (this as any).os.getGoogleMaps().then(maps => { const uluru = new maps.LatLng(this.p.geo.coordinates[1], this.p.geo.coordinates[0]); @@ -229,15 +237,9 @@ export default Vue.extend({ <style lang="stylus" scoped> @import '~const.styl' -.note +root(isDark) font-size 12px - border-bottom solid 1px #eaeaea - - &:first-child - border-radius 8px 8px 0 0 - - > .renote - border-radius 8px 8px 0 0 + border-bottom solid 1px isDark ? #1c2023 : #eaeaea &:last-of-type border-bottom none @@ -249,83 +251,78 @@ export default Vue.extend({ font-size 16px > .renote + display flex + align-items center + padding 8px 16px + line-height 28px color #9dbb00 - background linear-gradient(to bottom, #edfde2 0%, #fff 100%) + background isDark ? linear-gradient(to bottom, #314027 0%, #282c37 100%) : linear-gradient(to bottom, #edfde2 0%, #fff 100%) - > p - margin 0 - padding 8px 16px - line-height 28px + @media (min-width 500px) + padding 16px - @media (min-width 500px) - padding 16px + @media (min-width 600px) + padding 16px 32px + + .avatar + display inline-block + width 28px + height 28px + margin 0 8px 0 0 + border-radius 6px - .avatar-anchor - display inline-block + [data-fa] + margin-right 4px - .avatar - vertical-align bottom - width 28px - height 28px - margin 0 8px 0 0 - border-radius 6px + > span + flex-shrink 0 - [data-fa] - margin-right 4px + &:last-of-type + margin-right 8px - .name - font-weight bold + .name + overflow hidden + flex-shrink 1 + text-overflow ellipsis + white-space nowrap + font-weight bold > .mk-time - position absolute - top 8px - right 16px + display block + margin-left auto + flex-shrink 0 font-size 0.9em - line-height 28px - - @media (min-width 500px) - top 16px & + article padding-top 8px - > .reply-to - background rgba(0, 0, 0, 0.0125) - - > .mk-note-preview - background transparent - > article - padding 14px 16px 9px 16px + padding 16px 16px 9px + + @media (min-width 600px) + padding 32px 32px 22px &:after content "" display block clear both - > .avatar-anchor + > .avatar display block float left margin 0 10px 8px 0 - position -webkit-sticky - position sticky - top 62px + width 48px + height 48px + border-radius 6px + //position -webkit-sticky + //position sticky + //top 62px @media (min-width 500px) margin-right 16px - - > .avatar - display block - width 48px - height 48px - margin 0 - border-radius 6px - vertical-align bottom - - @media (min-width 500px) - width 58px - height 58px - border-radius 8px + width 58px + height 58px + border-radius 8px > .main float left @@ -336,7 +333,7 @@ export default Vue.extend({ > header display flex - align-items center + align-items baseline white-space nowrap @media (min-width 500px) @@ -347,7 +344,7 @@ export default Vue.extend({ margin 0 0.5em 0 0 padding 0 overflow hidden - color #627079 + color isDark ? #fff : #627079 font-size 1em font-weight bold text-decoration none @@ -360,122 +357,165 @@ export default Vue.extend({ margin 0 0.5em 0 0 padding 1px 6px font-size 12px - color #aaa - border solid 1px #ddd + color isDark ? #758188 : #aaa + border solid 1px isDark ? #57616f : #ddd border-radius 3px > .username margin 0 0.5em 0 0 - color #ccc + overflow hidden + text-overflow ellipsis + color isDark ? #606984 : #ccc > .info margin-left auto font-size 0.9em + > * + color isDark ? #606984 : #c0c0c0 + > .mobile margin-right 6px - color #c0c0c0 - > .created-at - color #c0c0c0 + > .visibility + margin-left 6px > .body - > .text + > .cw + cursor default display block margin 0 padding 0 overflow-wrap break-word font-size 1.1em - color #717171 - - >>> .quote - margin 8px - padding 6px 12px - color #aaa - border-left solid 3px #eee + color isDark ? #fff : #717171 - > .reply + > .text margin-right 8px - color #717171 - > .rp - margin-left 4px - font-style oblique - color #a0bf46 + > .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 - [data-is-me]:after - content "you" - padding 0 4px - margin-left 4px - font-size 80% - color $theme-color-foreground - background $theme-color - border-radius 4px + &:hover + background isDark ? #707b97 : #bbc4ce - .mk-url-preview - margin-top 8px + > .content - > .channel - margin 0 + > .text + display block + margin 0 + padding 0 + overflow-wrap break-word + font-size 1.1em + color isDark ? #fff : #717171 - > .tags - margin 4px 0 0 0 + >>> .title + display block + margin-bottom 4px + padding 4px + font-size 90% + text-align center + background isDark ? #2f3944 : #eef1f3 + border-radius 4px - > * - display inline-block - margin 0 8px 0 0 - padding 2px 8px 2px 16px - font-size 90% - color #8d969e - background #edf0f3 - border-radius 4px + >>> .code + margin 8px 0 + + >>> .quote + margin 8px + padding 6px 12px + color isDark ? #6f808e : #aaa + border-left solid 3px isDark ? #637182 : #eee + + > .reply + margin-right 8px + color isDark ? #99abbf : #717171 + + > .rp + margin-left 4px + font-style oblique + color #a0bf46 + + [data-is-me]:after + content "you" + padding 0 4px + margin-left 4px + font-size 80% + color $theme-color-foreground + background $theme-color + border-radius 4px + + .mk-url-preview + margin-top 8px + + > .channel + margin 0 + + > .tags + margin 4px 0 0 0 + + > * + display inline-block + margin 0 8px 0 0 + padding 2px 8px 2px 16px + font-size 90% + color #8d969e + background #edf0f3 + border-radius 4px + + &:before + content "" + display block + position absolute + top 0 + bottom 0 + left 4px + width 8px + height 8px + margin auto 0 + background #fff + border-radius 100% - &:before - content "" + > .media + > img display block - position absolute - top 0 - bottom 0 - left 4px - width 8px - height 8px - margin auto 0 - background #fff - border-radius 100% + max-width 100% - > .media - > img - display block - max-width 100% + > .location + margin 4px 0 + font-size 12px + color #ccc - > .location - margin 4px 0 - font-size 12px - color #ccc + > .map + width 100% + height 200px + + &:empty + display none + + > .mk-poll + font-size 80% - > .map - width 100% - height 200px + > .renote + margin 8px 0 - &:empty - display none + > .mk-note-preview + padding 16px + border dashed 1px isDark ? #4e945e : #c0dac6 + border-radius 8px > .app font-size 12px color #ccc - > .mk-poll - font-size 80% - - > .renote - margin 8px 0 - - > .mk-note-preview - padding 16px - border dashed 1px #c0dac6 - border-radius 8px - > footer > button margin 0 @@ -484,14 +524,14 @@ export default Vue.extend({ border none box-shadow none font-size 1em - color #ddd + color isDark ? #606984 : #ddd cursor pointer &:not(:last-child) margin-right 28px &:hover - color #666 + color isDark ? #9198af : #666 > .count display inline @@ -505,6 +545,12 @@ export default Vue.extend({ @media (max-width 350px) display none +.note[data-darkmode] + root(true) + +.note:not([data-darkmode]) + root(false) + </style> <style lang="stylus" module> diff --git a/src/client/app/mobile/views/components/notes.vue b/src/client/app/mobile/views/components/notes.vue index 573026d53e..53e232e521 100644 --- a/src/client/app/mobile/views/components/notes.vue +++ b/src/client/app/mobile/views/components/notes.vue @@ -1,30 +1,64 @@ <template> <div class="mk-notes"> + <div class="newer-indicator" :style="{ top: $store.state.uiHeaderHeight + 'px' }" v-show="queue.length > 0"></div> + <slot name="head"></slot> - <slot></slot> - <template v-for="(note, i) in _notes"> - <mk-note :note="note" :key="note.id" @update:note="onNoteUpdated(i, $event)"/> - <p class="date" v-if="i != notes.length - 1 && note._date != _notes[i + 1]._date"> - <span>%fa:angle-up%{{ note._datetext }}</span> - <span>%fa:angle-down%{{ _notes[i + 1]._datetext }}</span> - </p> - </template> - <footer> - <slot name="tail"></slot> + + <slot name="empty" v-if="notes.length == 0 && !fetching && requestInitPromise == null"></slot> + + <div class="init" v-if="fetching"> + %fa:spinner .pulse%%i18n:common.loading% + </div> + + <div v-if="!fetching && requestInitPromise != null"> + <p>読み込みに失敗しました。</p> + <button @click="resolveInitPromise">リトライ</button> + </div> + + <transition-group name="mk-notes" class="transition"> + <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"> + <span>%fa:angle-up%{{ note._datetext }}</span> + <span>%fa:angle-down%{{ _notes[i + 1]._datetext }}</span> + </p> + </template> + </transition-group> + + <footer v-if="more"> + <button @click="loadMore" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }"> + <template v-if="!moreFetching">%i18n:@load-more%</template> + <template v-if="moreFetching">%fa:spinner .pulse .fw%</template> + </button> </footer> </div> </template> <script lang="ts"> import Vue from 'vue'; +import getNoteSummary from '../../../../../renderers/get-note-summary'; + +const displayLimit = 30; export default Vue.extend({ props: { - notes: { - type: Array, - default: () => [] + more: { + type: Function, + required: false } }, + + data() { + return { + requestInitPromise: null as () => Promise<any[]>, + notes: [], + queue: [], + unreadCount: 0, + fetching: true, + moreFetching: false + }; + }, + computed: { _notes(): any[] { return (this.notes as any).map(note => { @@ -36,9 +70,132 @@ export default Vue.extend({ }); } }, + + mounted() { + document.addEventListener('visibilitychange', this.onVisibilitychange, false); + window.addEventListener('scroll', this.onScroll); + }, + + beforeDestroy() { + document.removeEventListener('visibilitychange', this.onVisibilitychange); + window.removeEventListener('scroll', this.onScroll); + }, + methods: { + isScrollTop() { + return window.scrollY <= 8; + }, + onNoteUpdated(i, note) { Vue.set((this as any).notes, i, note); + }, + + init(promiseGenerator: () => Promise<any[]>) { + this.requestInitPromise = promiseGenerator; + this.resolveInitPromise(); + }, + + resolveInitPromise() { + this.queue = []; + this.notes = []; + this.fetching = true; + + const promise = this.requestInitPromise(); + + promise.then(notes => { + this.notes = notes; + this.requestInitPromise = null; + this.fetching = false; + }, e => { + this.fetching = false; + }); + }, + + prepend(note, silent = false) { + //#region 弾く + const isMyNote = note.userId == (this as any).os.i.id; + const isPureRenote = note.renoteId != null && note.text == null && note.mediaIds.length == 0 && note.poll == null; + + if ((this as any).clientSettings.showMyRenotes === false) { + if (isMyNote && isPureRenote) { + return; + } + } + + if ((this as any).clientSettings.showRenotedMyNotes === false) { + if (isPureRenote && (note.renote.userId == (this as any).os.i.id)) { + return; + } + } + //#endregion + + // 投稿が自分のものではないかつ、タブが非表示またはスクロール位置が最上部ではないならタイトルで通知 + if ((document.hidden || !this.isScrollTop()) && note.userId !== (this as any).os.i.id) { + this.unreadCount++; + document.title = `(${this.unreadCount}) ${getNoteSummary(note)}`; + } + + if (this.isScrollTop()) { + // Prepend the note + this.notes.unshift(note); + + // オーバーフローしたら古い投稿は捨てる + if (this.notes.length >= displayLimit) { + this.notes = this.notes.slice(0, displayLimit); + } + } else { + this.queue.push(note); + } + }, + + append(note) { + this.notes.push(note); + }, + + tail() { + return this.notes[this.notes.length - 1]; + }, + + releaseQueue() { + this.queue.forEach(n => this.prepend(n, true)); + this.queue = []; + }, + + async loadMore() { + if (this.more == null) return; + if (this.moreFetching) return; + + this.moreFetching = true; + await this.more(); + this.moreFetching = false; + }, + + clearNotification() { + this.unreadCount = 0; + document.title = 'Misskey'; + }, + + onVisibilitychange() { + if (!document.hidden) { + this.clearNotification(); + } + }, + + onScroll() { + if (this.isScrollTop()) { + this.releaseQueue(); + this.clearNotification(); + } + + if ((this as any).clientSettings.fetchOnScroll !== false) { + // 親要素が display none だったら弾く + // https://github.com/syuilo/misskey/issues/1569 + // http://d.hatena.ne.jp/favril/20091105/1257403319 + if (this.$el.offsetHeight == 0) return; + + const current = window.scrollY + window.innerHeight; + if (current > document.body.offsetHeight - 8) this.loadMore(); + } } } }); @@ -47,10 +204,46 @@ export default Vue.extend({ <style lang="stylus" scoped> @import '~const.styl' -.mk-notes - background #fff +root(isDark) + overflow hidden + background isDark ? #282C37 : #fff border-radius 8px - box-shadow 0 0 0 1px rgba(0, 0, 0, 0.2) + box-shadow 0 0 2px rgba(#000, 0.1) + + @media (min-width 500px) + box-shadow 0 8px 32px rgba(#000, 0.1) + + .transition + .mk-notes-enter + .mk-notes-leave-to + opacity 0 + transform translateY(-30px) + + > * + transition transform .3s ease, opacity .3s ease + + > .date + display block + margin 0 + line-height 32px + text-align center + font-size 0.9em + color isDark ? #666b79 : #aaa + background isDark ? #242731 : #fdfdfd + border-bottom solid 1px isDark ? #1c2023 : #eaeaea + + span + margin 0 16px + + [data-fa] + margin-right 8px + + > .newer-indicator + position -webkit-sticky + position sticky + z-index 100 + height 3px + background $theme-color > .init padding 64px 0 @@ -73,27 +266,9 @@ export default Vue.extend({ font-size 3em color #ccc - > .date - display block - margin 0 - line-height 32px - text-align center - font-size 0.9em - color #aaa - background #fdfdfd - border-bottom solid 1px #eaeaea - - span - margin 0 16px - - [data-fa] - margin-right 8px - > footer text-align center - border-top solid 1px #eaeaea - border-bottom-left-radius 4px - border-bottom-right-radius 4px + border-top solid 1px isDark ? #1c2023 : #eaeaea &:empty display none @@ -102,10 +277,18 @@ export default Vue.extend({ margin 0 padding 16px width 100% - color $theme-color - border-radius 0 0 8px 8px + color #ccc + + @media (min-width 500px) + padding 20px &:disabled opacity 0.7 +.mk-notes[data-darkmode] + root(true) + +.mk-notes:not([data-darkmode]) + root(false) + </style> diff --git a/src/client/app/mobile/views/components/notification.vue b/src/client/app/mobile/views/components/notification.vue index 4f7c8968b2..c1b37563ce 100644 --- a/src/client/app/mobile/views/components/notification.vue +++ b/src/client/app/mobile/views/components/notification.vue @@ -1,15 +1,13 @@ <template> <div class="mk-notification"> <div class="notification reaction" v-if="notification.type == 'reaction'"> - <mk-time :time="notification.createdAt"/> - <router-link class="avatar-anchor" :to="notification.user | userPage"> - <img class="avatar" :src="`${notification.user.avatarUrl}?thumbnail&size=64`" alt="avatar"/> - </router-link> - <div class="text"> - <p> + <mk-avatar class="avatar" :user="notification.user"/> + <div> + <header> <mk-reaction-icon :reaction="notification.reaction"/> <router-link :to="notification.user | userPage">{{ notification.user | userName }}</router-link> - </p> + <mk-time :time="notification.createdAt"/> + </header> <router-link class="note-ref" :to="notification.note | notePage"> %fa:quote-left%{{ getNoteSummary(notification.note) }} %fa:quote-right% @@ -18,61 +16,55 @@ </div> <div class="notification renote" v-if="notification.type == 'renote'"> - <mk-time :time="notification.createdAt"/> - <router-link class="avatar-anchor" :to="notification.user | userPage"> - <img class="avatar" :src="`${notification.user.avatarUrl}?thumbnail&size=64`" alt="avatar"/> - </router-link> - <div class="text"> - <p> + <mk-avatar class="avatar" :user="notification.user"/> + <div> + <header> %fa:retweet% <router-link :to="notification.user | userPage">{{ notification.user | userName }}</router-link> - </p> + <mk-time :time="notification.createdAt"/> + </header> <router-link class="note-ref" :to="notification.note | notePage"> %fa:quote-left%{{ getNoteSummary(notification.note.renote) }}%fa:quote-right% </router-link> </div> </div> - <template v-if="notification.type == 'quote'"> - <mk-note :note="notification.note"/> - </template> - <div class="notification follow" v-if="notification.type == 'follow'"> - <mk-time :time="notification.createdAt"/> - <router-link class="avatar-anchor" :to="notification.user | userPage"> - <img class="avatar" :src="`${notification.user.avatarUrl}?thumbnail&size=64`" alt="avatar"/> - </router-link> - <div class="text"> - <p> + <mk-avatar class="avatar" :user="notification.user"/> + <div> + <header> %fa:user-plus% <router-link :to="notification.user | userPage">{{ notification.user | userName }}</router-link> - </p> + <mk-time :time="notification.createdAt"/> + </header> </div> </div> - <template v-if="notification.type == 'reply'"> - <mk-note :note="notification.note"/> - </template> - - <template v-if="notification.type == 'mention'"> - <mk-note :note="notification.note"/> - </template> - <div class="notification poll_vote" v-if="notification.type == 'poll_vote'"> - <mk-time :time="notification.createdAt"/> - <router-link class="avatar-anchor" :to="notification.user | userPage"> - <img class="avatar" :src="`${notification.user.avatarUrl}?thumbnail&size=64`" alt="avatar"/> - </router-link> - <div class="text"> - <p> + <mk-avatar class="avatar" :user="notification.user"/> + <div> + <header> %fa:chart-pie% <router-link :to="notification.user | userPage">{{ notification.user | userName }}</router-link> - </p> + <mk-time :time="notification.createdAt"/> + </header> <router-link class="note-ref" :to="notification.note | notePage"> %fa:quote-left%{{ getNoteSummary(notification.note) }}%fa:quote-right% </router-link> </div> </div> + + <template v-if="notification.type == 'quote'"> + <mk-note :note="notification.note"/> + </template> + + <template v-if="notification.type == 'reply'"> + <mk-note :note="notification.note"/> + </template> + + <template v-if="notification.type == 'mention'"> + <mk-note :note="notification.note"/> + </template> </div> </template> @@ -91,53 +83,63 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> -.mk-notification - +root(isDark) > .notification padding 16px + font-size 12px overflow-wrap break-word + @media (min-width 350px) + font-size 14px + + @media (min-width 500px) + font-size 16px + + @media (min-width 600px) + padding 24px 32px + &:after content "" display block clear both - > .mk-time - display inline - position absolute - top 16px - right 12px - vertical-align top - color rgba(0, 0, 0, 0.6) - font-size 0.9em - - > .avatar-anchor + > .avatar display block float left + width 36px + height 36px + border-radius 6px - img - min-width 36px - min-height 36px - max-width 36px - max-height 36px - border-radius 6px + @media (min-width 500px) + width 42px + height 42px - > .text + > div float right width calc(100% - 36px) padding-left 8px - p - margin 0 + @media (min-width 500px) + width calc(100% - 42px) + + > header + display flex + align-items baseline + white-space nowrap i, .mk-reaction-icon margin-right 4px + > .mk-time + margin-left auto + color isDark ? #606984 : #c0c0c0 + font-size 0.9em + > .note-preview - color rgba(0, 0, 0, 0.7) + color isDark ? #fff : #717171 > .note-ref - color rgba(0, 0, 0, 0.7) + color isDark ? #fff : #717171 [data-fa] font-size 1em @@ -147,12 +149,17 @@ export default Vue.extend({ margin-right 3px &.renote - .text p i + > div > header i color #77B255 &.follow - .text p i + > div > header i color #53c7ce -</style> +.mk-notification[data-darkmode] + root(true) +.mk-notification:not([data-darkmode]) + root(false) + +</style> diff --git a/src/client/app/mobile/views/components/notifications.vue b/src/client/app/mobile/views/components/notifications.vue index ad43a27b98..8ab66940c4 100644 --- a/src/client/app/mobile/views/components/notifications.vue +++ b/src/client/app/mobile/views/components/notifications.vue @@ -1,18 +1,20 @@ <template> <div class="mk-notifications"> - <div class="notifications" v-if="notifications.length != 0"> + <transition-group name="mk-notifications" class="transition notifications"> <template v-for="(notification, i) in _notifications"> <mk-notification :notification="notification" :key="notification.id"/> - <p class="date" v-if="i != notifications.length - 1 && notification._date != _notifications[i + 1]._date"> + <p class="date" :key="notification.id + '_date'" v-if="i != notifications.length - 1 && notification._date != _notifications[i + 1]._date"> <span>%fa:angle-up%{{ notification._datetext }}</span> <span>%fa:angle-down%{{ _notifications[i + 1]._datetext }}</span> </p> </template> - </div> + </transition-group> + <button class="more" v-if="moreNotifications" @click="fetchMoreNotifications" :disabled="fetchingMoreNotifications"> <template v-if="fetchingMoreNotifications">%fa:spinner .pulse .fw%</template> {{ fetchingMoreNotifications ? '%i18n:!common.loading%' : '%i18n:!@more%' }} </button> + <p class="empty" v-if="notifications.length == 0 && !fetching">%i18n:@empty%</p> <p class="fetching" v-if="fetching">%fa:spinner .pulse .fw%%i18n:common.loading%<mk-ellipsis/></p> </div> @@ -101,28 +103,29 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> -.mk-notifications - margin 8px auto - padding 0 - max-width 500px - width calc(100% - 16px) - background #fff +root(isDark) + margin 0 auto + background isDark ? #282C37 :#fff border-radius 8px - box-shadow 0 0 0 1px rgba(0, 0, 0, 0.2) + box-shadow 0 0 2px rgba(#000, 0.1) + overflow hidden @media (min-width 500px) - margin 16px auto - width calc(100% - 32px) + box-shadow 0 8px 32px rgba(#000, 0.1) - > .notifications + .transition + .mk-notifications-enter + .mk-notifications-leave-to + opacity 0 + transform translateY(-30px) - > .mk-notification - margin 0 auto - max-width 500px - border-bottom solid 1px rgba(0, 0, 0, 0.05) + > * + transition transform .3s ease, opacity .3s ease - &:last-child - border-bottom none + > .notifications + + > .mk-notification:not(:last-child) + border-bottom solid 1px isDark ? #1c2023 : #eaeaea > .date display block @@ -130,9 +133,9 @@ export default Vue.extend({ line-height 32px text-align center font-size 0.8em - color #aaa - background #fdfdfd - border-bottom solid 1px rgba(0, 0, 0, 0.05) + color isDark ? #666b79 : #aaa + background isDark ? #242731 : #fdfdfd + border-bottom solid 1px isDark ? #1c2023 : #eaeaea span margin 0 16px @@ -145,7 +148,7 @@ export default Vue.extend({ width 100% padding 16px color #555 - border-top solid 1px rgba(0, 0, 0, 0.05) + border-top solid 1px rgba(#000, 0.05) > [data-fa] margin-right 4px @@ -165,4 +168,10 @@ export default Vue.extend({ > [data-fa] margin-right 4px +.mk-notifications[data-darkmode] + root(true) + +.mk-notifications:not([data-darkmode]) + root(false) + </style> diff --git a/src/client/app/mobile/views/components/post-form.vue b/src/client/app/mobile/views/components/post-form.vue index 861e8653ba..6d80b3046b 100644 --- a/src/client/app/mobile/views/components/post-form.vue +++ b/src/client/app/mobile/views/components/post-form.vue @@ -10,6 +10,11 @@ </header> <div class="form"> <mk-note-preview v-if="reply" :note="reply"/> + <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">+ユーザーを追加</a> + </div> + <input v-show="useCw" v-model="cw" placeholder="内容への注釈 (オプション)"> <textarea v-model="text" ref="text" :disabled="posting" :placeholder="reply ? '%i18n:!@reply-placeholder%' : '%i18n:!@note-placeholder%'"></textarea> <div class="attaches" v-show="files.length != 0"> <x-draggable class="files" :list="files" :options="{ animation: 150 }"> @@ -20,11 +25,15 @@ </div> <mk-poll-editor v-if="poll" ref="poll" @destroyed="poll = false"/> <mk-uploader ref="uploader" @uploaded="attachMedia" @change="onChangeUploadings"/> - <button class="upload" @click="chooseFile">%fa:upload%</button> - <button class="drive" @click="chooseFileFromDrive">%fa:cloud%</button> - <button class="kao" @click="kao">%fa:R smile%</button> - <button class="poll" @click="poll = true">%fa:chart-pie%</button> - <button class="geo" @click="geo ? removeGeo() : setGeo()">%fa:map-marker-alt%</button> + <footer> + <button class="upload" @click="chooseFile">%fa:upload%</button> + <button class="drive" @click="chooseFileFromDrive">%fa:cloud%</button> + <button class="kao" @click="kao">%fa:R smile%</button> + <button class="poll" @click="poll = true">%fa:chart-pie%</button> + <button class="poll" @click="useCw = !useCw">%fa:eye-slash%</button> + <button class="geo" @click="geo ? removeGeo() : setGeo()">%fa:map-marker-alt%</button> + <button class="visibility" @click="setVisibility" ref="visibilityButton">%fa:lock%</button> + </footer> <input ref="file" class="file" type="file" accept="image/*" multiple="multiple" @change="onChangeFile"/> </div> </div> @@ -33,13 +42,17 @@ <script lang="ts"> import Vue from 'vue'; import * as XDraggable from 'vuedraggable'; +import MkVisibilityChooser from '../../../common/views/components/visibility-chooser.vue'; import getKao from '../../../common/scripts/get-kao'; export default Vue.extend({ components: { - XDraggable + XDraggable, + MkVisibilityChooser }, + props: ['reply'], + data() { return { posting: false, @@ -47,21 +60,33 @@ export default Vue.extend({ uploadings: [], files: [], poll: false, - geo: null + geo: null, + visibility: 'public', + visibleUsers: [], + useCw: false, + cw: null }; }, + mounted() { + if (this.reply && this.reply.user.host != null) { + this.text = `@${this.reply.user.username}@${this.reply.user.host} `; + } + this.$nextTick(() => { this.focus(); }); }, + methods: { focus() { (this.$refs.text as any).focus(); }, + chooseFile() { (this.$refs.file as any).click(); }, + chooseFileFromDrive() { (this as any).apis.chooseDriveFile({ multiple: true @@ -69,23 +94,29 @@ export default Vue.extend({ files.forEach(this.attachMedia); }); }, + attachMedia(driveFile) { this.files.push(driveFile); this.$emit('change-attached-media', this.files); }, + detachMedia(file) { this.files = this.files.filter(x => x.id != file.id); this.$emit('change-attached-media', this.files); }, + onChangeFile() { Array.from((this.$refs.file as any).files).forEach(this.upload); }, + upload(file) { (this.$refs.uploader as any).upload(file); }, + onChangeUploadings(uploads) { this.$emit('change-uploadings', uploads); }, + setGeo() { if (navigator.geolocation == null) { alert('お使いの端末は位置情報に対応していません'); @@ -100,23 +131,54 @@ export default Vue.extend({ enableHighAccuracy: true }); }, + removeGeo() { this.geo = null; }, + + setVisibility() { + const w = (this as any).os.new(MkVisibilityChooser, { + source: this.$refs.visibilityButton, + compact: true, + v: this.visibility + }); + w.$once('chosen', v => { + this.visibility = v; + }); + }, + + addVisibleUser() { + (this as any).apis.input({ + title: 'ユーザー名を入力してください' + }).then(username => { + (this as any).api('users/show', { + username + }).then(user => { + this.visibleUsers.push(user); + }); + }); + }, + + removeVisibleUser(user) { + this.visibleUsers = this.visibleUsers.filter(u => u != user); + }, + clear() { this.text = ''; this.files = []; this.poll = false; this.$emit('change-attached-media'); }, + post() { this.posting = true; - const viaMobile = (this as any).os.i.clientSettings.disableViaMobile !== true; + const viaMobile = (this as any).clientSettings.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, replyId: this.reply ? this.reply.id : undefined, poll: this.poll ? (this.$refs.poll as any).get() : undefined, + cw: this.useCw ? this.cw || '' : undefined, geo: this.geo ? { coordinates: [this.geo.longitude, this.geo.latitude], altitude: this.geo.altitude, @@ -125,6 +187,8 @@ export default Vue.extend({ heading: isNaN(this.geo.heading) ? null : this.geo.heading, speed: this.geo.speed, } : null, + visibility: this.visibility, + visibleUserIds: this.visibility == 'specified' ? this.visibleUsers.map(u => u.id) : undefined, viaMobile: viaMobile }).then(data => { this.$emit('note'); @@ -133,10 +197,12 @@ export default Vue.extend({ this.posting = false; }); }, + cancel() { this.$emit('cancel'); this.$destroy(); }, + kao() { this.text += getKao(); } @@ -147,29 +213,33 @@ export default Vue.extend({ <style lang="stylus" scoped> @import '~const.styl' -.mk-post-form +root(isDark) max-width 500px width calc(100% - 16px) margin 8px auto - background #fff + background isDark ? #282C37 : #fff border-radius 8px - box-shadow 0 0 0 1px rgba(0, 0, 0, 0.2) + box-shadow 0 0 2px rgba(#000, 0.1) @media (min-width 500px) margin 16px auto width calc(100% - 32px) + box-shadow 0 8px 32px rgba(#000, 0.1) + + @media (min-width 600px) + margin 32px auto > header - z-index 1 + z-index 1000 height 50px - box-shadow 0 1px 0 0 rgba(0, 0, 0, 0.1) + box-shadow 0 1px 0 0 isDark ? rgba(#000, 0.2) : rgba(#000, 0.1) > .cancel padding 0 width 50px line-height 50px font-size 24px - color #555 + color isDark ? #9baec8 : #555 > div position absolute @@ -203,6 +273,38 @@ export default Vue.extend({ > .mk-note-preview padding 16px + > .visibleUsers + margin-bottom 8px + font-size 14px + + > span + margin-right 16px + color isDark ? #fff : #666 + + > input + z-index 1 + + > input + > textarea + display block + padding 12px + margin 0 + width 100% + font-size 16px + color isDark ? #fff : #333 + background isDark ? #191d23 : #fff + border none + border-radius 0 + box-shadow 0 1px 0 0 isDark ? rgba(#000, 0.2) : rgba(#000, 0.1) + + &:disabled + opacity 0.5 + + > textarea + max-width 100% + min-width 100% + min-height 80px + > .attaches > .files @@ -236,40 +338,30 @@ export default Vue.extend({ > .file display none - > textarea - display block - padding 12px - margin 0 - width 100% - max-width 100% - min-width 100% - min-height 80px - font-size 16px - color #333 - border none - border-bottom solid 1px #ddd - border-radius 0 + > footer + white-space nowrap + overflow auto + -webkit-overflow-scrolling touch + overflow-scrolling touch - &:disabled - opacity 0.5 + > * + display inline-block + padding 0 + margin 0 + width 48px + height 48px + font-size 20px + color #657786 + background transparent + outline none + border none + border-radius 0 + box-shadow none - > .upload - > .drive - > .kao - > .poll - > .geo - display inline-block - padding 0 - margin 0 - width 48px - height 48px - font-size 20px - color #657786 - background transparent - outline none - border none - border-radius 0 - box-shadow none +.mk-post-form[data-darkmode] + root(true) -</style> +.mk-post-form:not([data-darkmode]) + root(false) +</style> 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 54cc74f7f5..cc50977a58 100644 --- a/src/client/app/mobile/views/components/sub-note-content.vue +++ b/src/client/app/mobile/views/components/sub-note-content.vue @@ -1,6 +1,7 @@ <template> <div class="mk-sub-note-content"> <div class="body"> + <span v-if="note.isHidden" style="opacity: 0.5">(この投稿は非公開です)</span> <a class="reply" v-if="note.replyId">%fa:reply%</a> <mk-note-html v-if="note.text" :text="note.text" :i="os.i"/> <a class="rp" v-if="note.renoteId">RP: ...</a> diff --git a/src/client/app/mobile/views/components/timeline.vue b/src/client/app/mobile/views/components/timeline.vue deleted file mode 100644 index 11b82aa456..0000000000 --- a/src/client/app/mobile/views/components/timeline.vue +++ /dev/null @@ -1,113 +0,0 @@ -<template> -<div class="mk-timeline"> - <mk-friends-maker v-if="alone"/> - <mk-notes :notes="notes"> - <div class="init" v-if="fetching"> - %fa:spinner .pulse%%i18n:common.loading% - </div> - <div class="empty" v-if="!fetching && notes.length == 0"> - %fa:R comments% - %i18n:@empty% - </div> - <button v-if="!fetching && existMore" @click="more" :disabled="moreFetching" slot="tail"> - <span v-if="!moreFetching">%i18n:@load-more%</span> - <span v-if="moreFetching">%i18n:common.loading%<mk-ellipsis/></span> - </button> - </mk-notes> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; - -const limit = 10; - -export default Vue.extend({ - props: { - date: { - type: Date, - required: false, - default: null - } - }, - data() { - return { - fetching: true, - moreFetching: false, - notes: [], - existMore: false, - connection: null, - connectionId: null - }; - }, - computed: { - alone(): boolean { - return (this as any).os.i.followingCount == 0; - } - }, - mounted() { - this.connection = (this as any).os.stream.getConnection(); - this.connectionId = (this as any).os.stream.use(); - - this.connection.on('note', this.onNote); - this.connection.on('follow', this.onChangeFollowing); - this.connection.on('unfollow', this.onChangeFollowing); - -this.fetch(); - }, - beforeDestroy() { - this.connection.off('note', this.onNote); - this.connection.off('follow', this.onChangeFollowing); - this.connection.off('unfollow', this.onChangeFollowing); - (this as any).os.stream.dispose(this.connectionId); - }, - methods: { - fetch(cb?) { - this.fetching = true; - (this as any).api('notes/timeline', { - limit: limit + 1, - untilDate: this.date ? (this.date as any).getTime() : undefined - }).then(notes => { - if (notes.length == limit + 1) { - notes.pop(); - this.existMore = true; - } - this.notes = notes; - this.fetching = false; - this.$emit('loaded'); - if (cb) cb(); - }); - }, - more() { - this.moreFetching = true; - (this as any).api('notes/timeline', { - limit: limit + 1, - untilId: this.notes[this.notes.length - 1].id - }).then(notes => { - if (notes.length == limit + 1) { - notes.pop(); - this.existMore = true; - } else { - this.existMore = false; - } - this.notes = this.notes.concat(notes); - this.moreFetching = false; - }); - }, - onNote(note) { - this.notes.unshift(note); - - const isTop = window.scrollY > 8; - if (isTop) this.notes.pop(); - }, - onChangeFollowing() { - this.fetch(); - } - } -}); -</script> - -<style lang="stylus" scoped> -.mk-friends-maker - margin-bottom 8px -</style> diff --git a/src/client/app/mobile/views/components/ui.header.vue b/src/client/app/mobile/views/components/ui.header.vue index f1b24bf2da..509463333d 100644 --- a/src/client/app/mobile/views/components/ui.header.vue +++ b/src/client/app/mobile/views/components/ui.header.vue @@ -32,6 +32,8 @@ export default Vue.extend({ }; }, mounted() { + this.$store.commit('setUiHeaderHeight', 48); + if ((this as any).os.isSignedIn) { this.connection = (this as any).os.stream.getConnection(); this.connectionId = (this as any).os.stream.use(); @@ -57,9 +59,10 @@ export default Vue.extend({ } }); - const ago = (new Date().getTime() - new Date((this as any).os.i.lastUsedAt).getTime()) / 1000 + const ago = (new Date().getTime() - new Date((this as any).os.i.lastUsedAt).getTime()) / 1000; const isHisasiburi = ago >= 3600; (this as any).os.i.lastUsedAt = new Date(); + (this as any).os.bakeMe(); if (isHisasiburi) { (this.$refs.welcomeback as any).style.display = 'block'; (this.$refs.main as any).style.overflow = 'hidden'; @@ -141,7 +144,7 @@ export default Vue.extend({ <style lang="stylus" scoped> @import '~const.styl' -.header +root(isDark) $height = 48px position fixed @@ -150,6 +153,9 @@ export default Vue.extend({ width 100% box-shadow 0 1px 0 rgba(#000, 0.075) + &, * + user-select none + > .main color rgba(#fff, 0.9) @@ -162,7 +168,7 @@ export default Vue.extend({ -webkit-backdrop-filter blur(12px) backdrop-filter blur(12px) //background-color rgba(#1b2023, 0.75) - background-color #1b2023 + background-color isDark ? #313543 : #595f6f > p display none @@ -239,4 +245,10 @@ export default Vue.extend({ line-height $height border-left solid 1px rgba(#000, 0.1) +.header[data-darkmode] + root(true) + +.header:not([data-darkmode]) + root(false) + </style> diff --git a/src/client/app/mobile/views/components/ui.nav.vue b/src/client/app/mobile/views/components/ui.nav.vue index 68cdacb3b5..5c65d52237 100644 --- a/src/client/app/mobile/views/components/ui.nav.vue +++ b/src/client/app/mobile/views/components/ui.nav.vue @@ -15,19 +15,20 @@ </router-link> <div class="links"> <ul> - <li><router-link to="/">%fa:home%%i18n:@home%%fa:angle-right%</router-link></li> - <li><router-link to="/i/notifications">%fa:R bell%%i18n:@notifications%<template v-if="hasUnreadNotifications">%fa:circle%</template>%fa:angle-right%</router-link></li> - <li><router-link to="/i/messaging">%fa:R comments%%i18n:@messaging%<template v-if="hasUnreadMessagingMessages">%fa:circle%</template>%fa:angle-right%</router-link></li> - <li><router-link to="/othello">%fa:gamepad%ゲーム<template v-if="hasGameInvitations">%fa:circle%</template>%fa:angle-right%</router-link></li> + <li><router-link to="/" :data-active="$route.name == 'index'">%fa:home%%i18n:@home%%fa:angle-right%</router-link></li> + <li><router-link to="/i/notifications" :data-active="$route.name == 'notifications'">%fa:R bell%%i18n:@notifications%<template v-if="hasUnreadNotifications">%fa:circle%</template>%fa:angle-right%</router-link></li> + <li><router-link to="/i/messaging" :data-active="$route.name == 'messaging'">%fa:R comments%%i18n:@messaging%<template v-if="hasUnreadMessagingMessages">%fa:circle%</template>%fa:angle-right%</router-link></li> + <li><router-link to="/othello" :data-active="$route.name == 'othello'">%fa:gamepad%ゲーム<template v-if="hasGameInvitations">%fa:circle%</template>%fa:angle-right%</router-link></li> </ul> <ul> - <li><router-link to="/i/drive">%fa:cloud%%i18n:@drive%%fa:angle-right%</router-link></li> + <li><router-link to="/i/drive" :data-active="$route.name == 'drive'">%fa:cloud%%i18n:@drive%%fa:angle-right%</router-link></li> </ul> <ul> <li><a @click="search">%fa:search%%i18n:@search%%fa:angle-right%</a></li> </ul> <ul> <li><router-link to="/i/settings">%fa:cog%%i18n:@settings%%fa:angle-right%</router-link></li> + <li @click="dark"><p><template v-if="_darkmode_">%fa:moon%</template><template v-else>%fa:R moon%</template><span>ダークモード</span></p></li> </ul> </div> <a :href="aboutUrl"><p class="about">%i18n:@about%</p></a> @@ -113,6 +114,9 @@ export default Vue.extend({ }, onOthelloNoInvites() { this.hasGameInvitations = false; + }, + dark() { + (this as any)._updateDarkmode_(!(this as any)._darkmode_); } } }); @@ -121,7 +125,9 @@ export default Vue.extend({ <style lang="stylus" scoped> @import '~const.styl' -.nav +root(isDark) + $color = isDark ? #c9d2e0 : #777 + .backdrop position fixed top 0 @@ -129,7 +135,7 @@ export default Vue.extend({ z-index 1025 width 100% height 100% - background rgba(0, 0, 0, 0.2) + background isDark ? rgba(#000, 0.7) : rgba(#000, 0.2) .body position fixed @@ -140,8 +146,7 @@ export default Vue.extend({ height 100% overflow auto -webkit-overflow-scrolling touch - color #777 - background #fff + background isDark ? #16191f : #fff .me display block @@ -162,7 +167,7 @@ export default Vue.extend({ left 80px padding 0 width calc(100% - 112px) - color #777 + color $color line-height 96px overflow hidden text-overflow ellipsis @@ -182,14 +187,22 @@ export default Vue.extend({ font-size 1em line-height 1em - a + a, p display block + margin 0 padding 0 20px line-height 3rem line-height calc(1rem + 30px) - color #777 + color $color text-decoration none + &[data-active] + color $theme-color-foreground + background $theme-color + + > [data-fa]:last-child + color $theme-color-foreground + > [data-fa]:first-child margin-right 0.5em @@ -205,18 +218,17 @@ export default Vue.extend({ padding 0 20px font-size 1.2em line-height calc(1rem + 30px) - color #ccc + color $color + opacity 0.5 .about margin 0 padding 1em 0 text-align center font-size 0.8em + color $color opacity 0.5 - a - color #777 - .nav-enter-active, .nav-leave-active { opacity: 1; @@ -239,4 +251,10 @@ export default Vue.extend({ opacity: 0; } +.nav[data-darkmode] + root(true) + +.nav:not([data-darkmode]) + root(false) + </style> diff --git a/src/client/app/mobile/views/components/user-list-timeline.vue b/src/client/app/mobile/views/components/user-list-timeline.vue new file mode 100644 index 0000000000..59d6abbbc1 --- /dev/null +++ b/src/client/app/mobile/views/components/user-list-timeline.vue @@ -0,0 +1,93 @@ +<template> +<div> + <mk-notes ref="timeline" :more="existMore ? more : null"/> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { UserListStream } from '../../../common/scripts/streaming/user-list'; + +const fetchLimit = 10; + +export default Vue.extend({ + props: ['list'], + data() { + return { + fetching: true, + moreFetching: false, + existMore: false, + connection: null + }; + }, + watch: { + $route: 'init' + }, + mounted() { + this.init(); + }, + beforeDestroy() { + this.connection.close(); + }, + methods: { + init() { + if (this.connection) this.connection.close(); + this.connection = new UserListStream((this as any).os, (this as any).os.i, this.list.id); + this.connection.on('note', this.onNote); + this.connection.on('userAdded', this.onUserAdded); + this.connection.on('userRemoved', this.onUserRemoved); + + this.fetch(); + }, + fetch() { + this.fetching = true; + + (this.$refs.timeline as any).init(() => new Promise((res, rej) => { + (this as any).api('notes/user-list-timeline', { + listId: this.list.id, + limit: fetchLimit + 1, + includeMyRenotes: (this as any).clientSettings.showMyRenotes, + includeRenotedMyNotes: (this as any).clientSettings.showRenotedMyNotes + }).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; + + (this as any).api('notes/user-list-timeline', { + listId: this.list.id, + limit: fetchLimit + 1, + untilId: (this.$refs.timeline as any).tail().id, + includeMyRenotes: (this as any).clientSettings.showMyRenotes, + includeRenotedMyNotes: (this as any).clientSettings.showRenotedMyNotes + }).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; + }); + }, + onNote(note) { + // Prepend a note + (this.$refs.timeline as any).prepend(note); + }, + onUserAdded() { + this.fetch(); + }, + onUserRemoved() { + this.fetch(); + } + } +}); +</script> diff --git a/src/client/app/mobile/views/components/user-preview.vue b/src/client/app/mobile/views/components/user-preview.vue index 23a83b5e3a..d258360911 100644 --- a/src/client/app/mobile/views/components/user-preview.vue +++ b/src/client/app/mobile/views/components/user-preview.vue @@ -1,8 +1,6 @@ <template> <div class="mk-user-preview"> - <router-link class="avatar-anchor" :to="user | userPage"> - <img class="avatar" :src="`${user.avatarUrl}?thumbnail&size=64`" alt="avatar"/> - </router-link> + <mk-avatar class="avatar" :user="user"/> <div class="main"> <header> <router-link class="name" :to="user | userPage">{{ user | userName }}</router-link> @@ -40,26 +38,19 @@ export default Vue.extend({ display block clear both - > .avatar-anchor + > .avatar display block float left margin 0 10px 0 0 + width 48px + height 48px + border-radius 6px @media (min-width 500px) margin-right 16px - - > .avatar - display block - width 48px - height 48px - margin 0 - border-radius 6px - vertical-align bottom - - @media (min-width 500px) - width 58px - height 58px - border-radius 8px + width 58px + height 58px + border-radius 8px > .main float left diff --git a/src/client/app/mobile/views/components/user-timeline.vue b/src/client/app/mobile/views/components/user-timeline.vue index 40b3be035e..3ceb876596 100644 --- a/src/client/app/mobile/views/components/user-timeline.vue +++ b/src/client/app/mobile/views/components/user-timeline.vue @@ -1,17 +1,10 @@ <template> <div class="mk-user-timeline"> - <mk-notes :notes="notes"> - <div class="init" v-if="fetching"> - %fa:spinner .pulse%%i18n:common.loading% - </div> - <div class="empty" v-if="!fetching && notes.length == 0"> + <mk-notes ref="timeline" :more="existMore ? more : null"> + <div slot="empty"> %fa:R comments% {{ withMedia ? '%i18n:!@no-notes-with-media%' : '%i18n:!@no-notes%' }} </div> - <button v-if="!fetching && existMore" @click="more" :disabled="moreFetching" slot="tail"> - <span v-if="!moreFetching">%i18n:@load-more%</span> - <span v-if="moreFetching">%i18n:common.loading%<mk-ellipsis/></span> - </button> </mk-notes> </div> </template> @@ -19,58 +12,68 @@ <script lang="ts"> import Vue from 'vue'; -const limit = 10; +const fetchLimit = 10; export default Vue.extend({ props: ['user', 'withMedia'], + data() { return { fetching: true, - notes: [], existMore: false, moreFetching: false }; }, + + computed: { + canFetchMore(): boolean { + return !this.moreFetching && !this.fetching && this.existMore; + } + }, + mounted() { - (this as any).api('users/notes', { - userId: this.user.id, - withMedia: this.withMedia, - limit: limit + 1 - }).then(notes => { - if (notes.length == limit + 1) { - notes.pop(); - this.existMore = true; - } - this.notes = notes; - this.fetching = false; - this.$emit('loaded'); - }); + this.fetch(); }, + methods: { + fetch() { + this.fetching = true; + (this.$refs.timeline as any).init(() => new Promise((res, rej) => { + (this as any).api('users/notes', { + userId: this.user.id, + withMedia: this.withMedia, + limit: fetchLimit + 1 + }).then(notes => { + if (notes.length == fetchLimit + 1) { + notes.pop(); + this.existMore = true; + } + res(notes); + this.fetching = false; + this.$emit('loaded'); + }, rej); + })); + }, + more() { + if (!this.canFetchMore) return; + this.moreFetching = true; (this as any).api('users/notes', { userId: this.user.id, withMedia: this.withMedia, - limit: limit + 1, - untilId: this.notes[this.notes.length - 1].id + limit: fetchLimit + 1, + untilId: (this.$refs.timeline as any).tail().id }).then(notes => { - if (notes.length == limit + 1) { + if (notes.length == fetchLimit + 1) { notes.pop(); - this.existMore = true; } else { this.existMore = false; } - this.notes = this.notes.concat(notes); + notes.forEach(n => (this.$refs.timeline as any).append(n)); this.moreFetching = false; }); } } }); </script> - -<style lang="stylus" scoped> -.mk-user-timeline - max-width 600px - margin 0 auto -</style> diff --git a/src/client/app/mobile/views/components/users-list.vue b/src/client/app/mobile/views/components/users-list.vue index 8fa7a9cbe6..6175067459 100644 --- a/src/client/app/mobile/views/components/users-list.vue +++ b/src/client/app/mobile/views/components/users-list.vue @@ -1,8 +1,8 @@ <template> <div class="mk-users-list"> <nav> - <span :data-is-active="mode == 'all'" @click="mode = 'all'">%i18n:@all%<span>{{ count }}</span></span> - <span v-if="os.isSignedIn && youKnowCount" :data-is-active="mode == 'iknow'" @click="mode = 'iknow'">%i18n:@known%<span>{{ youKnowCount }}</span></span> + <span :data-active="mode == 'all'" @click="mode = 'all'">%i18n:@all%<span>{{ count }}</span></span> + <span v-if="os.isSignedIn && youKnowCount" :data-active="mode == 'iknow'" @click="mode = 'iknow'">%i18n:@known%<span>{{ youKnowCount }}</span></span> </nav> <div class="users" v-if="!fetching && users.length != 0"> <mk-user-preview v-for="u in users" :user="u" :key="u.id"/> @@ -74,7 +74,7 @@ export default Vue.extend({ justify-content center margin 0 auto max-width 600px - border-bottom solid 1px rgba(0, 0, 0, 0.2) + border-bottom solid 1px rgba(#000, 0.2) > span display block @@ -85,7 +85,7 @@ export default Vue.extend({ color #657786 border-bottom solid 2px transparent - &[data-is-active] + &[data-active] font-weight bold color $theme-color border-color $theme-color @@ -97,7 +97,7 @@ export default Vue.extend({ font-size 12px line-height 1 color #fff - background rgba(0, 0, 0, 0.3) + background rgba(#000, 0.3) border-radius 20px > .users @@ -106,14 +106,14 @@ export default Vue.extend({ width calc(100% - 16px) background #fff border-radius 8px - box-shadow 0 0 0 1px rgba(0, 0, 0, 0.2) + box-shadow 0 0 0 1px rgba(#000, 0.2) @media (min-width 500px) margin 16px auto width calc(100% - 32px) > * - border-bottom solid 1px rgba(0, 0, 0, 0.05) + border-bottom solid 1px rgba(#000, 0.05) > .no margin 0 diff --git a/src/client/app/mobile/views/components/widget-container.vue b/src/client/app/mobile/views/components/widget-container.vue index 7319c90849..1bdc875763 100644 --- a/src/client/app/mobile/views/components/widget-container.vue +++ b/src/client/app/mobile/views/components/widget-container.vue @@ -28,7 +28,7 @@ export default Vue.extend({ .mk-widget-container background #eee border-radius 8px - box-shadow 0 0 0 1px rgba(0, 0, 0, 0.2) + box-shadow 0 0 0 1px rgba(#000, 0.2) overflow hidden &.hideHeader diff --git a/src/client/app/mobile/views/pages/dashboard.vue b/src/client/app/mobile/views/pages/dashboard.vue new file mode 100644 index 0000000000..a5ca6cb4a2 --- /dev/null +++ b/src/client/app/mobile/views/pages/dashboard.vue @@ -0,0 +1,195 @@ +<template> +<mk-ui> + <span slot="header">%fa:home%ダッシュボード</span> + <template slot="func"> + <button @click="customizing = !customizing">%fa:cog%</button> + </template> + <main> + <template v-if="customizing"> + <header> + <select v-model="widgetAdderSelected"> + <option value="profile">プロフィール</option> + <option value="calendar">カレンダー</option> + <option value="activity">アクティビティ</option> + <option value="rss">RSSリーダー</option> + <option value="photo-stream">フォトストリーム</option> + <option value="slideshow">スライドショー</option> + <option value="version">バージョン</option> + <option value="access-log">アクセスログ</option> + <option value="server">サーバー情報</option> + <option value="donation">寄付のお願い</option> + <option value="nav">ナビゲーション</option> + <option value="tips">ヒント</option> + </select> + <button @click="addWidget">追加</button> + <p><a @click="hint">カスタマイズのヒント</a></p> + </header> + <x-draggable + :list="widgets" + :options="{ handle: '.handle', animation: 150 }" + @sort="onWidgetSort" + > + <div v-for="widget in widgets" class="customize-container" :key="widget.id"> + <header> + <span class="handle">%fa:bars%</span>{{ widget.name }}<button class="remove" @click="removeWidget(widget)">%fa:times%</button> + </header> + <div @click="widgetFunc(widget.id)"> + <component :is="`mkw-${widget.name}`" :widget="widget" :ref="widget.id" :is-customize-mode="true" :is-mobile="true"/> + </div> + </div> + </x-draggable> + </template> + <template v-else> + <component class="widget" v-for="widget in widgets" :is="`mkw-${widget.name}`" :key="widget.id" :ref="widget.id" :widget="widget" :is-mobile="true" @chosen="warp"/> + </template> + </main> +</mk-ui> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import * as XDraggable from 'vuedraggable'; +import * as uuid from 'uuid'; + +export default Vue.extend({ + components: { + XDraggable + }, + data() { + return { + showNav: false, + widgets: [], + customizing: false, + widgetAdderSelected: null + }; + }, + created() { + if ((this as any).clientSettings.mobileHome == null) { + Vue.set((this as any).clientSettings, 'mobileHome', [{ + name: 'calendar', + id: 'a', data: {} + }, { + name: 'activity', + id: 'b', data: {} + }, { + name: 'rss', + id: 'c', data: {} + }, { + name: 'photo-stream', + id: 'd', data: {} + }, { + name: 'donation', + id: 'e', data: {} + }, { + name: 'nav', + id: 'f', data: {} + }, { + name: 'version', + id: 'g', data: {} + }]); + this.widgets = (this as any).clientSettings.mobileHome; + this.saveHome(); + } else { + this.widgets = (this as any).clientSettings.mobileHome; + } + + this.$watch('clientSettings', i => { + this.widgets = (this as any).clientSettings.mobileHome; + }, { + deep: true + }); + }, + + mounted() { + document.title = 'Misskey'; + }, + + methods: { + onHomeUpdated(data) { + if (data.home) { + (this as any).clientSettings.mobileHome = data.home; + this.widgets = data.home; + } else { + const w = (this as any).clientSettings.mobileHome.find(w => w.id == data.id); + if (w != null) { + w.data = data.data; + this.$refs[w.id][0].preventSave = true; + this.$refs[w.id][0].props = w.data; + this.widgets = (this as any).clientSettings.mobileHome; + } + } + }, + hint() { + alert('ウィジェットを追加/削除したり並べ替えたりできます。ウィジェットを移動するには「三」をドラッグします。ウィジェットを削除するには「x」をタップします。いくつかのウィジェットはタップすることで表示を変更できます。'); + }, + widgetFunc(id) { + const w = this.$refs[id][0]; + if (w.func) w.func(); + }, + onWidgetSort() { + this.saveHome(); + }, + addWidget() { + const widget = { + name: this.widgetAdderSelected, + id: uuid(), + data: {} + }; + + this.widgets.unshift(widget); + this.saveHome(); + }, + removeWidget(widget) { + this.widgets = this.widgets.filter(w => w.id != widget.id); + this.saveHome(); + }, + saveHome() { + (this as any).clientSettings.mobileHome = this.widgets; + (this as any).api('i/update_mobile_home', { + home: this.widgets + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +main + margin 0 auto + max-width 500px + + @media (min-width 500px) + padding 8px + + > header + padding 8px + background #fff + + .widget + margin 8px + + .customize-container + margin 8px + background #fff + + > header + line-height 32px + background #eee + + > .handle + padding 0 8px + + > .remove + position absolute + top 0 + right 0 + padding 0 8px + line-height 32px + + > div + padding 8px + + > * + pointer-events none + +</style> diff --git a/src/client/app/mobile/views/pages/followers.vue b/src/client/app/mobile/views/pages/followers.vue index f3c75f71e9..33ade94e35 100644 --- a/src/client/app/mobile/views/pages/followers.vue +++ b/src/client/app/mobile/views/pages/followers.vue @@ -40,9 +40,6 @@ export default Vue.extend({ created() { this.fetch(); }, - mounted() { - document.documentElement.style.background = '#313a42'; - }, methods: { fetch() { Progress.start(); diff --git a/src/client/app/mobile/views/pages/following.vue b/src/client/app/mobile/views/pages/following.vue index 88368ff778..c6d6d44281 100644 --- a/src/client/app/mobile/views/pages/following.vue +++ b/src/client/app/mobile/views/pages/following.vue @@ -39,9 +39,6 @@ export default Vue.extend({ created() { this.fetch(); }, - mounted() { - document.documentElement.style.background = '#313a42'; - }, methods: { fetch() { Progress.start(); diff --git a/src/client/app/mobile/views/pages/home.timeline.vue b/src/client/app/mobile/views/pages/home.timeline.vue new file mode 100644 index 0000000000..4c1c344db1 --- /dev/null +++ b/src/client/app/mobile/views/pages/home.timeline.vue @@ -0,0 +1,149 @@ +<template> +<div> + <mk-friends-maker v-if="src == 'home' && alone" style="margin-bottom:8px"/> + + <mk-notes ref="timeline" :more="existMore ? more : null"> + <div slot="empty"> + %fa:R comments% + %i18n:@empty% + </div> + </mk-notes> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +const fetchLimit = 10; + +export default Vue.extend({ + props: { + src: { + type: String, + required: true + } + }, + + data() { + return { + fetching: true, + moreFetching: false, + existMore: false, + connection: null, + connectionId: null, + unreadCount: 0, + date: null + }; + }, + + computed: { + alone(): boolean { + return (this as any).os.i.followingCount == 0; + }, + + stream(): any { + return this.src == 'home' + ? (this as any).os.stream + : this.src == 'local' + ? (this as any).os.streams.localTimelineStream + : (this as any).os.streams.globalTimelineStream; + }, + + endpoint(): string { + return this.src == 'home' + ? 'notes/timeline' + : this.src == 'local' + ? 'notes/local-timeline' + : 'notes/global-timeline'; + }, + + canFetchMore(): boolean { + return !this.moreFetching && !this.fetching && this.existMore; + } + }, + + mounted() { + this.connection = this.stream.getConnection(); + this.connectionId = this.stream.use(); + + this.connection.on('note', this.onNote); + if (this.src == 'home') { + this.connection.on('follow', this.onChangeFollowing); + this.connection.on('unfollow', this.onChangeFollowing); + } + + this.fetch(); + }, + + beforeDestroy() { + this.connection.off('note', this.onNote); + if (this.src == 'home') { + this.connection.off('follow', this.onChangeFollowing); + this.connection.off('unfollow', this.onChangeFollowing); + } + this.stream.dispose(this.connectionId); + }, + + methods: { + fetch() { + this.fetching = true; + + (this.$refs.timeline as any).init(() => new Promise((res, rej) => { + (this as any).api(this.endpoint, { + limit: fetchLimit + 1, + untilDate: this.date ? this.date.getTime() : undefined, + includeMyRenotes: (this as any).clientSettings.showMyRenotes, + includeRenotedMyNotes: (this as any).clientSettings.showRenotedMyNotes + }).then(notes => { + if (notes.length == fetchLimit + 1) { + notes.pop(); + this.existMore = true; + } + res(notes); + this.fetching = false; + this.$emit('loaded'); + }, rej); + })); + }, + + more() { + if (!this.canFetchMore) return; + + this.moreFetching = true; + + (this as any).api(this.endpoint, { + limit: fetchLimit + 1, + untilId: (this.$refs.timeline as any).tail().id, + includeMyRenotes: (this as any).clientSettings.showMyRenotes, + includeRenotedMyNotes: (this as any).clientSettings.showRenotedMyNotes + }).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; + }); + }, + + onNote(note) { + // Prepend a note + (this.$refs.timeline as any).prepend(note); + }, + + onChangeFollowing() { + this.fetch(); + }, + + focus() { + (this.$refs.timeline as any).focus(); + }, + + warp(date) { + this.date = date; + this.fetch(); + } + } +}); +</script> diff --git a/src/client/app/mobile/views/pages/home.vue b/src/client/app/mobile/views/pages/home.vue index 3d94dd7ce6..ad6d5ed408 100644 --- a/src/client/app/mobile/views/pages/home.vue +++ b/src/client/app/mobile/views/pages/home.vue @@ -1,59 +1,42 @@ <template> <mk-ui> - <span slot="header" @click="showTl = !showTl"> - <template v-if="showTl">%fa:home%%i18n:@timeline%</template> - <template v-else>%fa:home%ウィジェット</template> + <span slot="header" @click="showNav = true"> + <span> + <span v-if="src == 'home'">%fa:home%ホーム</span> + <span v-if="src == 'local'">%fa:R comments%ローカル</span> + <span v-if="src == 'global'">%fa:globe%グローバル</span> + <span v-if="src.startsWith('list')">%fa:list%{{ list.title }}</span> + </span> <span style="margin-left:8px"> - <template v-if="showTl">%fa:angle-down%</template> + <template v-if="!showNav">%fa:angle-down%</template> <template v-else>%fa:angle-up%</template> </span> </span> + <template slot="func"> - <button @click="fn" v-if="showTl">%fa:pencil-alt%</button> - <button @click="customizing = !customizing" v-else>%fa:cog%</button> + <button @click="fn">%fa:pencil-alt%</button> </template> - <main> - <div class="tl"> - <mk-timeline @loaded="onLoaded" v-show="showTl"/> + + <main :data-darkmode="_darkmode_"> + <div class="nav" v-if="showNav"> + <div class="bg" @click="showNav = false"></div> + <div class="body"> + <div> + <span :data-active="src == 'home'" @click="src = 'home'">%fa:home% ホーム</span> + <span :data-active="src == 'local'" @click="src = 'local'">%fa:R comments% ローカル</span> + <span :data-active="src == 'global'" @click="src = 'global'">%fa:globe% グローバル</span> + <template v-if="lists"> + <span v-for="l in lists" :data-active="src == 'list:' + l.id" @click="src = 'list:' + l.id; list = l" :key="l.id">%fa:list% {{ l.title }}</span> + </template> + </div> + </div> </div> - <div class="widgets" v-show="!showTl"> - <template v-if="customizing"> - <header> - <select v-model="widgetAdderSelected"> - <option value="profile">プロフィール</option> - <option value="calendar">カレンダー</option> - <option value="activity">アクティビティ</option> - <option value="rss">RSSリーダー</option> - <option value="photo-stream">フォトストリーム</option> - <option value="slideshow">スライドショー</option> - <option value="version">バージョン</option> - <option value="access-log">アクセスログ</option> - <option value="server">サーバー情報</option> - <option value="donation">寄付のお願い</option> - <option value="nav">ナビゲーション</option> - <option value="tips">ヒント</option> - </select> - <button @click="addWidget">追加</button> - <p><a @click="hint">カスタマイズのヒント</a></p> - </header> - <x-draggable - :list="widgets" - :options="{ handle: '.handle', animation: 150 }" - @sort="onWidgetSort" - > - <div v-for="widget in widgets" class="customize-container" :key="widget.id"> - <header> - <span class="handle">%fa:bars%</span>{{ widget.name }}<button class="remove" @click="removeWidget(widget)">%fa:times%</button> - </header> - <div @click="widgetFunc(widget.id)"> - <component :is="`mkw-${widget.name}`" :widget="widget" :ref="widget.id" :is-customize-mode="true" :is-mobile="true"/> - </div> - </div> - </x-draggable> - </template> - <template v-else> - <component class="widget" v-for="widget in widgets" :is="`mkw-${widget.name}`" :key="widget.id" :ref="widget.id" :widget="widget" :is-mobile="true" @chosen="warp"/> - </template> + + <div class="tl"> + <x-tl v-if="src == 'home'" ref="tl" key="home" src="home" @loaded="onLoaded"/> + <x-tl v-if="src == 'local'" ref="tl" key="local" src="local"/> + <x-tl v-if="src == 'global'" ref="tl" key="global" src="global"/> + <mk-user-list-timeline v-if="src.startsWith('list:')" ref="tl" :key="list.id" :list="list"/> </div> </main> </mk-ui> @@ -61,144 +44,58 @@ <script lang="ts"> import Vue from 'vue'; -import * as XDraggable from 'vuedraggable'; -import * as uuid from 'uuid'; import Progress from '../../../common/scripts/loading'; -import getNoteSummary from '../../../../../renderers/get-note-summary'; +import XTl from './home.timeline.vue'; export default Vue.extend({ components: { - XDraggable + XTl }, + data() { return { - connection: null, - connectionId: null, - unreadCount: 0, - showTl: true, - widgets: [], - customizing: false, - widgetAdderSelected: null + src: 'home', + list: null, + lists: null, + showNav: false }; }, - created() { - if ((this as any).os.i.clientSettings.mobileHome == null) { - Vue.set((this as any).os.i.clientSettings, 'mobileHome', [{ - name: 'calendar', - id: 'a', data: {} - }, { - name: 'activity', - id: 'b', data: {} - }, { - name: 'rss', - id: 'c', data: {} - }, { - name: 'photo-stream', - id: 'd', data: {} - }, { - name: 'donation', - id: 'e', data: {} - }, { - name: 'nav', - id: 'f', data: {} - }, { - name: 'version', - id: 'g', data: {} - }]); - this.widgets = (this as any).os.i.clientSettings.mobileHome; - this.saveHome(); - } else { - this.widgets = (this as any).os.i.clientSettings.mobileHome; + + watch: { + src() { + this.showNav = false; + }, + + showNav(v) { + if (v && this.lists === null) { + (this as any).api('users/lists/list').then(lists => { + this.lists = lists; + }); + } } + }, - this.$watch('os.i.clientSettings', i => { - this.widgets = (this as any).os.i.clientSettings.mobileHome; - }, { - deep: true - }); + created() { + if ((this as any).os.i.followingCount == 0) { + this.src = 'local'; + } }, + mounted() { document.title = 'Misskey'; - document.documentElement.style.background = '#313a42'; - - this.connection = (this as any).os.stream.getConnection(); - this.connectionId = (this as any).os.stream.use(); - - this.connection.on('note', this.onStreamNote); - this.connection.on('mobile_home_updated', this.onHomeUpdated); - document.addEventListener('visibilitychange', this.onVisibilitychange, false); Progress.start(); }, - beforeDestroy() { - this.connection.off('note', this.onStreamNote); - this.connection.off('mobile_home_updated', this.onHomeUpdated); - (this as any).os.stream.dispose(this.connectionId); - document.removeEventListener('visibilitychange', this.onVisibilitychange); - }, + methods: { fn() { (this as any).apis.post(); }, + onLoaded() { Progress.done(); }, - onStreamNote(note) { - if (document.hidden && note.userId !== (this as any).os.i.id) { - this.unreadCount++; - document.title = `(${this.unreadCount}) ${getNoteSummary(note)}`; - } - }, - onVisibilitychange() { - if (!document.hidden) { - this.unreadCount = 0; - document.title = 'Misskey'; - } - }, - onHomeUpdated(data) { - if (data.home) { - (this as any).os.i.clientSettings.mobileHome = data.home; - this.widgets = data.home; - } else { - const w = (this as any).os.i.clientSettings.mobileHome.find(w => w.id == data.id); - if (w != null) { - w.data = data.data; - this.$refs[w.id][0].preventSave = true; - this.$refs[w.id][0].props = w.data; - this.widgets = (this as any).os.i.clientSettings.mobileHome; - } - } - }, - hint() { - alert('ウィジェットを追加/削除したり並べ替えたりできます。ウィジェットを移動するには「三」をドラッグします。ウィジェットを削除するには「x」をタップします。いくつかのウィジェットはタップすることで表示を変更できます。'); - }, - widgetFunc(id) { - const w = this.$refs[id][0]; - if (w.func) w.func(); - }, - onWidgetSort() { - this.saveHome(); - }, - addWidget() { - const widget = { - name: this.widgetAdderSelected, - id: uuid(), - data: {} - }; - this.widgets.unshift(widget); - this.saveHome(); - }, - removeWidget(widget) { - this.widgets = this.widgets.filter(w => w.id != widget.id); - this.saveHome(); - }, - saveHome() { - (this as any).os.i.clientSettings.mobileHome = this.widgets; - (this as any).api('i/update_mobile_home', { - home: this.widgets - }); - }, warp() { } @@ -207,53 +104,74 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> -main +@import '~const.styl' - > .tl - > .mk-timeline - max-width 600px +root(isDark) + > .nav + > .bg + position fixed + z-index 10000 + top 0 + left 0 + width 100% + height 100% + background rgba(#000, 0.5) + + > .body + position fixed + z-index 10001 + top 56px + left 0 + right 0 + width 300px margin 0 auto - padding 8px + background isDark ? #272f3a : #fff + border-radius 8px + box-shadow 0 0 16px rgba(#000, 0.1) - @media (min-width 500px) - padding 16px + $balloon-size = 16px - > .widgets - margin 0 auto - max-width 500px + &:after + content "" + display block + position absolute + top -($balloon-size * 2) + 1.5px + left s('calc(50% - %s)', $balloon-size) + border-top solid $balloon-size transparent + border-left solid $balloon-size transparent + border-right solid $balloon-size transparent + border-bottom solid $balloon-size isDark ? #272f3a : #fff - @media (min-width 500px) - padding 8px + > div + padding 8px 0 - > header - padding 8px - background #fff + > * + display block + padding 8px 16px + color isDark ? #cdd0d8 : #666 - .widget - margin 8px + &[data-active] + color $theme-color-foreground + background $theme-color - .customize-container - margin 8px - background #fff + &:not([data-active]):hover + background isDark ? #353e4a : #eee - > header - line-height 32px - background #eee + > .tl + max-width 680px + margin 0 auto + padding 8px - > .handle - padding 0 8px + @media (min-width 500px) + padding 16px - > .remove - position absolute - top 0 - right 0 - padding 0 8px - line-height 32px + @media (min-width 600px) + padding 32px - > div - padding 8px +main[data-darkmode] + root(true) - > * - pointer-events none +main:not([data-darkmode]) + root(false) </style> diff --git a/src/client/app/mobile/views/pages/note.vue b/src/client/app/mobile/views/pages/note.vue index c866be8a14..146d89d22b 100644 --- a/src/client/app/mobile/views/pages/note.vue +++ b/src/client/app/mobile/views/pages/note.vue @@ -2,11 +2,13 @@ <mk-ui> <span slot="header">%fa:R sticky-note%%i18n:@title%</span> <main v-if="!fetching"> - <a v-if="note.next" :href="note.next">%fa:angle-up%%i18n:@next%</a> <div> <mk-note-detail :note="note"/> </div> - <a v-if="note.prev" :href="note.prev">%fa:angle-down%%i18n:@prev%</a> + <footer> + <router-link v-if="note.prev" :to="note.prev">%fa:angle-left% %i18n:@prev%</router-link> + <router-link v-if="note.next" :to="note.next">%i18n:@next% %fa:angle-right%</router-link> + </footer> </main> </mk-ui> </template> @@ -30,7 +32,6 @@ export default Vue.extend({ }, mounted() { document.title = 'Misskey'; - document.documentElement.style.background = '#313a42'; }, methods: { fetch() { @@ -53,33 +54,24 @@ export default Vue.extend({ <style lang="stylus" scoped> main text-align center + padding 8px - > div - margin 8px auto - padding 0 - max-width 500px - width calc(100% - 16px) - - @media (min-width 500px) - margin 16px auto - width calc(100% - 32px) - - > a - display inline-block + @media (min-width 500px) + padding 16px - &:first-child - margin-top 8px + @media (min-width 600px) + padding 32px - @media (min-width 500px) - margin-top 16px - - &:last-child - margin-bottom 8px + > div + margin 0 auto + padding 0 + max-width 600px - @media (min-width 500px) - margin-bottom 16px + > footer + margin-top 16px - > [data-fa] - margin-right 4px + > a + display inline-block + margin 0 16px </style> diff --git a/src/client/app/mobile/views/pages/notifications.vue b/src/client/app/mobile/views/pages/notifications.vue index cd2b633676..d0c0fe9535 100644 --- a/src/client/app/mobile/views/pages/notifications.vue +++ b/src/client/app/mobile/views/pages/notifications.vue @@ -2,7 +2,10 @@ <mk-ui> <span slot="header">%fa:R bell%%i18n:@notifications%</span> <template slot="func"><button @click="fn">%fa:check%</button></template> - <mk-notifications @fetched="onFetched"/> + + <main> + <mk-notifications @fetched="onFetched"/> + </main> </mk-ui> </template> @@ -13,7 +16,6 @@ import Progress from '../../../common/scripts/loading'; export default Vue.extend({ mounted() { document.title = 'Misskey | %i18n:@notifications%'; - document.documentElement.style.background = '#313a42'; Progress.start(); }, @@ -30,3 +32,20 @@ export default Vue.extend({ } }); </script> + +<style lang="stylus" scoped> +@import '~const.styl' + +main + width 100% + max-width 680px + margin 0 auto + padding 8px + + @media (min-width 500px) + padding 16px + + @media (min-width 600px) + padding 32px + +</style> diff --git a/src/client/app/mobile/views/pages/profile-setting.vue b/src/client/app/mobile/views/pages/profile-setting.vue index 59da71c67d..7048cdef31 100644 --- a/src/client/app/mobile/views/pages/profile-setting.vue +++ b/src/client/app/mobile/views/pages/profile-setting.vue @@ -59,7 +59,6 @@ export default Vue.extend({ }, mounted() { document.title = 'Misskey | %i18n:@title%'; - document.documentElement.style.background = '#313a42'; }, methods: { setAvatar() { @@ -137,7 +136,7 @@ export default Vue.extend({ .form position relative background #fff - box-shadow 0 0 0 1px rgba(0, 0, 0, 0.2) + box-shadow 0 0 0 1px rgba(#000, 0.2) border-radius 8px &:before @@ -146,7 +145,7 @@ export default Vue.extend({ position absolute bottom -20px left calc(50% - 10px) - border-top solid 10px rgba(0, 0, 0, 0.2) + border-top solid 10px rgba(#000, 0.2) border-right solid 10px transparent border-bottom solid 10px transparent border-left solid 10px transparent diff --git a/src/client/app/mobile/views/pages/search.vue b/src/client/app/mobile/views/pages/search.vue index 31035f666a..f038a6f81f 100644 --- a/src/client/app/mobile/views/pages/search.vue +++ b/src/client/app/mobile/views/pages/search.vue @@ -39,7 +39,6 @@ export default Vue.extend({ }, mounted() { document.title = `%i18n:@search%: ${this.q} | Misskey`; - document.documentElement.style.background = '#313a42'; this.fetch(); }, @@ -85,7 +84,7 @@ export default Vue.extend({ width calc(100% - 16px) background #fff border-radius 8px - box-shadow 0 0 0 1px rgba(0, 0, 0, 0.2) + box-shadow 0 0 0 1px rgba(#000, 0.2) @media (min-width 500px) margin 16px auto diff --git a/src/client/app/mobile/views/pages/selectdrive.vue b/src/client/app/mobile/views/pages/selectdrive.vue index 741559ed0b..d730e4fcff 100644 --- a/src/client/app/mobile/views/pages/selectdrive.vue +++ b/src/client/app/mobile/views/pages/selectdrive.vue @@ -62,7 +62,7 @@ export default Vue.extend({ width 100% z-index 1000 background #fff - box-shadow 0 1px rgba(0, 0, 0, 0.1) + box-shadow 0 1px rgba(#000, 0.1) > h1 margin 0 diff --git a/src/client/app/mobile/views/pages/settings.vue b/src/client/app/mobile/views/pages/settings.vue index 8ae087749f..0e9c5ea962 100644 --- a/src/client/app/mobile/views/pages/settings.vue +++ b/src/client/app/mobile/views/pages/settings.vue @@ -34,7 +34,6 @@ export default Vue.extend({ }, mounted() { document.title = 'Misskey | %i18n:@settings%'; - document.documentElement.style.background = '#313a42'; }, methods: { signout() { @@ -63,7 +62,7 @@ export default Vue.extend({ width calc(100% - 32px) list-style none background #fff - border solid 1px rgba(0, 0, 0, 0.2) + border solid 1px rgba(#000, 0.2) border-radius $radius > li @@ -71,7 +70,7 @@ export default Vue.extend({ border-bottom solid 1px #ddd &:hover - background rgba(0, 0, 0, 0.1) + background rgba(#000, 0.1) &:first-child border-top-left-radius $radius diff --git a/src/client/app/mobile/views/pages/signup.vue b/src/client/app/mobile/views/pages/signup.vue index 9dc07a4b86..b8245beb00 100644 --- a/src/client/app/mobile/views/pages/signup.vue +++ b/src/client/app/mobile/views/pages/signup.vue @@ -40,7 +40,7 @@ export default Vue.extend({ .form background #fff - border solid 1px rgba(0, 0, 0, 0.2) + border solid 1px rgba(#000, 0.2) border-radius 8px overflow hidden diff --git a/src/client/app/mobile/views/pages/user.vue b/src/client/app/mobile/views/pages/user.vue index 04db482df2..27482dc215 100644 --- a/src/client/app/mobile/views/pages/user.vue +++ b/src/client/app/mobile/views/pages/user.vue @@ -1,14 +1,15 @@ <template> <mk-ui> - <span slot="header" v-if="!fetching">%fa:user% {{ user | userName }}</span> - <main v-if="!fetching"> - <div class="is-remote" v-if="user.host != null"><p>%fa:exclamation-triangle% %i18n:@is-remote% <a :href="user.url || user.uri" target="_blank">%i18n:@view-remote%</a></p></div> + <template slot="header" v-if="!fetching"><img :src="`${user.avatarUrl}?thumbnail&size=64`" alt="">{{ user | userName }}</template> + <main v-if="!fetching" :data-darkmode="_darkmode_"> + <div class="is-suspended" v-if="user.isSuspended"><p>%fa:exclamation-triangle% %i18n:@is-suspended%</p></div> + <div class="is-remote" v-if="user.host != null"><p>%fa:exclamation-triangle% %i18n:@is-remote%<a :href="user.url || user.uri" target="_blank">%i18n:@view-remote%</a></p></div> <header> - <div class="banner" :style="user.bannerUrl ? `background-image: url(${user.bannerUrl}?thumbnail&size=1024)` : ''"></div> + <div class="banner" :style="style"></div> <div class="body"> <div class="top"> <a class="avatar"> - <img :src="`${user.avatarUrl}?thumbnail&size=200`" alt="avatar"/> + <img :src="user.avatarUrl" alt="avatar"/> </a> <mk-follow-button v-if="os.isSignedIn && os.i.id != user.id" :user="user"/> </div> @@ -44,9 +45,9 @@ </header> <nav> <div class="nav-container"> - <a :data-is-active="page == 'home'" @click="page = 'home'">%i18n:@overview%</a> - <a :data-is-active="page == 'notes'" @click="page = 'notes'">%i18n:@timeline%</a> - <a :data-is-active="page == 'media'" @click="page = 'media'">%i18n:@media%</a> + <a :data-active="page == 'home'" @click="page = 'home'">%fa:home% %i18n:@overview%</a> + <a :data-active="page == 'notes'" @click="page = 'notes'">%fa:R comment-alt% %i18n:@timeline%</a> + <a :data-active="page == 'media'" @click="page = 'media'">%fa:image% %i18n:@media%</a> </div> </nav> <div class="body"> @@ -79,6 +80,13 @@ export default Vue.extend({ computed: { age(): number { return age(this.user.profile.birthday); + }, + style(): any { + if (this.user.bannerUrl == null) return {}; + return { + backgroundColor: this.user.bannerColor ? `rgb(${ this.user.bannerColor.join(',') })` : null, + backgroundImage: `url(${ this.user.bannerUrl })` + }; } }, watch: { @@ -87,9 +95,6 @@ export default Vue.extend({ created() { this.fetch(); }, - mounted() { - document.documentElement.style.background = '#313a42'; - }, methods: { fetch() { Progress.start(); @@ -109,27 +114,38 @@ export default Vue.extend({ <style lang="stylus" scoped> @import '~const.styl' -main +root(isDark) + $bg = isDark ? #22252f : #f7f7f7 + + > .is-suspended > .is-remote - padding 16px - color #573c08 - background #fff0db + &.is-suspended + color #570808 + background #ffdbdb + + &.is-remote + color #573c08 + background #fff0db > p margin 0 auto - max-width 1024px + padding 14px + max-width 600px + font-size 14px > a font-weight bold @media (max-width 500px) + padding 12px font-size 12px > header + background $bg > .banner padding-bottom 33.3% - background-color #1b1b1b + background-color isDark ? #5f7273 : #cacaca background-size cover background-position center @@ -156,13 +172,14 @@ main left -2px bottom -2px width 100% - border 3px solid #313a42 + background $bg + border 3px solid $bg border-radius 6px @media (min-width 500px) left -4px bottom -4px - border 4px solid #313a42 + border 4px solid $bg border-radius 12px > .mk-follow-button @@ -176,26 +193,26 @@ main margin 0 line-height 22px font-size 20px - color #fff + color isDark ? #fff : #757c82 > .username display inline-block line-height 20px font-size 16px font-weight bold - color #657786 + color isDark ? #657786 : #969ea5 > .followed margin-left 8px padding 2px 4px font-size 12px - color #657786 - background #f8f8f8 + color isDark ? #657786 : #fff + background isDark ? #f8f8f8 : #a7bec7 border-radius 4px > .description margin 8px 0 - color #fff + color isDark ? #fff : #757c82 > .info margin 8px 0 @@ -203,14 +220,14 @@ main > p display inline margin 0 16px 0 0 - color #a9b9c1 + color isDark ? #a9b9c1 : #90989c > i margin-right 4px > .status > a - color #657786 + color isDark ? #657786 : #818a92 &:not(:last-child) margin-right 16px @@ -218,7 +235,7 @@ main > b margin-right 4px font-size 16px - color #fff + color isDark ? #fff : #787e86 > i font-size 14px @@ -226,9 +243,9 @@ main > nav position -webkit-sticky position sticky - top 48px - box-shadow 0 4px 4px rgba(0, 0, 0, 0.3) - background-color #313a42 + top 47px + box-shadow 0 4px 4px isDark ? rgba(#000, 0.3) : rgba(#000, 0.07) + background-color $bg z-index 1 > .nav-container @@ -241,21 +258,36 @@ main display block flex 1 1 text-align center - line-height 52px - font-size 14px + line-height 48px + font-size 12px text-decoration none - color #657786 + color isDark ? #657786 : #9ca1a5 border-bottom solid 2px transparent - &[data-is-active] + @media (min-width 400px) + line-height 52px + font-size 14px + + &[data-active] font-weight bold color $theme-color border-color $theme-color > .body + max-width 680px + margin 0 auto padding 8px @media (min-width 500px) padding 16px + @media (min-width 600px) + padding 32px + +main[data-darkmode] + root(true) + +main:not([data-darkmode]) + root(false) + </style> diff --git a/src/client/app/mobile/views/pages/user/home.vue b/src/client/app/mobile/views/pages/user/home.vue index 4ba2ffd1df..d02daf5027 100644 --- a/src/client/app/mobile/views/pages/user/home.vue +++ b/src/client/app/mobile/views/pages/user/home.vue @@ -54,30 +54,39 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> -.root.home +root(isDark) max-width 600px margin 0 auto > .mk-note-detail margin 0 0 8px 0 + @media (min-width 500px) + margin 0 0 16px 0 + > section - background #eee + background isDark ? #21242f : #eee border-radius 8px - box-shadow 0 0 0 1px rgba(0, 0, 0, 0.2) + box-shadow 0 4px 16px rgba(#000, 0.1) &:not(:last-child) margin-bottom 8px + @media (min-width 500px) + margin-bottom 16px + > h2 margin 0 padding 8px 10px font-size 15px font-weight normal - color #465258 - background #fff + color isDark ? #b8c5cc : #465258 + background isDark ? #282c37 : #fff border-radius 8px 8px 0 0 + @media (min-width 500px) + padding 10px 16px + > i margin-right 6px @@ -89,6 +98,12 @@ export default Vue.extend({ display block margin 16px text-align center - color #cad2da + color isDark ? #cad2da : #929aa0 + +.root.home[data-darkmode] + root(true) + +.root.home:not([data-darkmode]) + root(false) </style> diff --git a/src/client/app/mobile/views/pages/welcome.vue b/src/client/app/mobile/views/pages/welcome.vue index 27baf8bee4..64cfa5a46c 100644 --- a/src/client/app/mobile/views/pages/welcome.vue +++ b/src/client/app/mobile/views/pages/welcome.vue @@ -1,33 +1,33 @@ <template> <div class="welcome"> - <h1><b>Misskey</b>へようこそ</h1> - <p>Twitter風ミニブログSNS、Misskeyへようこそ。共有したいことを投稿したり、タイムラインでみんなの投稿を読むこともできます。<br><a href="/signup">アカウントを作成する</a></p> - <div class="form"> - <p>%fa:lock% ログイン</p> - <div> - <form @submit.prevent="onSubmit"> - <input v-model="username" type="text" pattern="^[a-zA-Z0-9_]+$" placeholder="ユーザー名" autofocus required @change="onUsernameChange"/> - <input v-model="password" type="password" placeholder="パスワード" required/> - <input v-if="user && user.twoFactorEnabled" v-model="token" type="number" placeholder="トークン" required/> - <button type="submit" :disabled="signing">{{ signing ? 'ログインしています' : 'ログイン' }}</button> - </form> + <div> + <h1><b>Misskey</b>へようこそ</h1> + <p>Twitter風ミニブログSNS、Misskeyへようこそ。共有したいことを投稿したり、タイムラインでみんなの投稿を読むこともできます。<br><a href="/signup">アカウントを作成する</a></p> + <div class="form"> + <p>%fa:lock% ログイン</p> <div> - <a :href="`${apiUrl}/signin/twitter`">Twitterでログイン</a> + <form @submit.prevent="onSubmit"> + <input v-model="username" type="text" pattern="^[a-zA-Z0-9_]+$" placeholder="ユーザー名" autofocus required @change="onUsernameChange"/> + <input v-model="password" type="password" placeholder="パスワード" required/> + <input v-if="user && user.twoFactorEnabled" v-model="token" type="number" placeholder="トークン" required/> + <button type="submit" :disabled="signing">{{ signing ? 'ログインしています' : 'ログイン' }}</button> + </form> + <div> + <a :href="`${apiUrl}/signin/twitter`">Twitterでログイン</a> + </div> </div> </div> + <div class="tl"> + <p>%fa:comments R% タイムラインを見てみる</p> + <mk-welcome-timeline/> + </div> + <div class="users"> + <mk-avatar class="avatar" v-for="user in users" :key="user.id" :user="user"/> + </div> + <footer> + <small>{{ copyright }}</small> + </footer> </div> - <div class="tl"> - <p>%fa:comments R% タイムラインを見てみる</p> - <mk-welcome-timeline/> - </div> - <div class="users"> - <router-link v-for="user in users" :key="user.id" class="avatar-anchor" :to="`/@${user.username}`"> - <img class="avatar" :src="`${user.avatarUrl}?thumbnail&size=64`" alt="avatar"/> - </router-link> - </div> - <footer> - <small>{{ copyright }}</small> - </footer> </div> </template> @@ -84,123 +84,116 @@ export default Vue.extend({ <style lang="stylus" scoped> .welcome - padding 16px - margin 0 auto - max-width 500px - - h1 - margin 0 - padding 8px - font-size 1.5em - font-weight normal - color #cacac3 - - & + p - margin 0 0 16px 0 - padding 0 8px 0 8px - color #949fa9 + background linear-gradient(to bottom, #1e1d65, #bd6659) - .form - margin-bottom 16px - background #fff - border solid 1px rgba(0, 0, 0, 0.2) - border-radius 8px - overflow hidden + > div + padding 16px + margin 0 auto + max-width 500px - > p + h1 margin 0 - padding 12px 20px - color #555 - background #f5f5f5 - border-bottom solid 1px #ddd + padding 8px + font-size 1.5em + font-weight normal + color #cacac3 - > div + & + p + margin 0 0 16px 0 + padding 0 8px 0 8px + color #949fa9 - > form - padding 16px + .form + margin-bottom 16px + background #fff + border solid 1px rgba(#000, 0.2) + border-radius 8px + overflow hidden + + > p + margin 0 + padding 12px 20px + color #555 + background #f5f5f5 border-bottom solid 1px #ddd - input - display block - padding 12px - margin 0 0 16px 0 - width 100% - font-size 1em - color rgba(0, 0, 0, 0.7) - background #fff - outline none - border solid 1px #ddd - border-radius 4px + > div + + > form + padding 16px + border-bottom solid 1px #ddd - button - display block - width 100% - padding 10px - margin 0 - color #333 - font-size 1em - 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 - border-radius 4px + input + display block + padding 12px + margin 0 0 16px 0 + width 100% + font-size 1em + color rgba(#000, 0.7) + background #fff + outline none + border solid 1px #ddd + border-radius 4px - &:active - background-color #767676 - background-image none - border-color #444 - box-shadow 0 1px 3px rgba(0, 0, 0, 0.075), inset 0 0 5px rgba(0, 0, 0, 0.2) + button + display block + width 100% + padding 10px + margin 0 + color #333 + font-size 1em + 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 + border-radius 4px - > div - padding 16px - text-align center + &:active + background-color #767676 + background-image none + border-color #444 + box-shadow 0 1px 3px rgba(#000, 0.075), inset 0 0 5px rgba(#000, 0.2) - > .tl - background #fff - border solid 1px rgba(0, 0, 0, 0.2) - border-radius 8px - overflow hidden + > div + padding 16px + text-align center - > p - margin 0 - padding 12px 20px - color #555 - background #f5f5f5 - border-bottom solid 1px #ddd + > .tl + background #fff + border solid 1px rgba(#000, 0.2) + border-radius 8px + overflow hidden - > .mk-welcome-timeline - max-height 300px - overflow auto + > p + margin 0 + padding 12px 20px + color #555 + background #f5f5f5 + border-bottom solid 1px #ddd - > .users - margin 12px 0 0 0 + > .mk-welcome-timeline + max-height 300px + overflow auto - > * - display inline-block - margin 4px + > .users + margin 12px 0 0 0 > * display inline-block + margin 4px width 38px height 38px - vertical-align top border-radius 6px - > footer - text-align center - color #fff - - > small - display block - margin 16px 0 0 0 - opacity 0.7 + > footer + text-align center + color #fff -</style> + > small + display block + margin 16px 0 0 0 + opacity 0.7 -<style lang="stylus"> -html -body - background linear-gradient(to bottom, #1e1d65, #bd6659) </style> diff --git a/src/client/app/mobile/views/widgets/activity.vue b/src/client/app/mobile/views/widgets/activity.vue index 48dcafb3ed..7763be41f5 100644 --- a/src/client/app/mobile/views/widgets/activity.vue +++ b/src/client/app/mobile/views/widgets/activity.vue @@ -21,6 +21,7 @@ export default define({ methods: { func() { this.props.compact = !this.props.compact; + this.save(); } } }); diff --git a/src/client/app/mobile/views/widgets/profile.vue b/src/client/app/mobile/views/widgets/profile.vue index 502f886ceb..59c1ec7c0e 100644 --- a/src/client/app/mobile/views/widgets/profile.vue +++ b/src/client/app/mobile/views/widgets/profile.vue @@ -34,7 +34,7 @@ export default define({ display block width 100% height 100% - background rgba(0, 0, 0, 0.5) + background rgba(#000, 0.5) .avatar display block @@ -47,7 +47,7 @@ export default define({ left ((100px - 58px) / 2) border none border-radius 100% - box-shadow 0 0 16px rgba(0, 0, 0, 0.5) + box-shadow 0 0 16px rgba(#000, 0.5) .name display block @@ -58,6 +58,6 @@ export default define({ line-height 100px color #fff font-weight bold - text-shadow 0 0 8px rgba(0, 0, 0, 0.5) + text-shadow 0 0 8px rgba(#000, 0.5) </style> diff --git a/src/client/app/store.ts b/src/client/app/store.ts new file mode 100644 index 0000000000..0bdfdef6a0 --- /dev/null +++ b/src/client/app/store.ts @@ -0,0 +1,92 @@ +import Vuex from 'vuex'; +import MiOS from './mios'; + +const defaultSettings = { + home: [], + fetchOnScroll: true, + showMaps: true, + showPostFormOnTopOfTl: false, + circleIcons: true, + gradientWindowHeader: false, + showReplyTarget: true, + showMyRenotes: true, + showRenotedMyNotes: true +}; + +export default (os: MiOS) => new Vuex.Store({ + plugins: [store => { + store.subscribe((mutation, state) => { + if (mutation.type.startsWith('settings/')) { + localStorage.setItem('settings', JSON.stringify(state.settings.data)); + } + }); + }], + + state: { + uiHeaderHeight: 0 + }, + + mutations: { + setUiHeaderHeight(state, height) { + state.uiHeaderHeight = height; + } + }, + + modules: { + settings: { + namespaced: true, + + state: { + data: defaultSettings + }, + + mutations: { + set(state, x: { key: string; value: any }) { + state.data[x.key] = x.value; + }, + + setHome(state, data) { + state.data.home = data; + }, + + setHomeWidget(state, x) { + const w = state.data.home.find(w => w.id == x.id); + if (w) { + w.data = x.data; + } + }, + + addHomeWidget(state, widget) { + state.data.home.unshift(widget); + } + }, + + actions: { + merge(ctx, settings) { + Object.entries(settings).forEach(([key, value]) => { + ctx.commit('set', { key, value }); + }); + }, + + set(ctx, x) { + ctx.commit('set', x); + + if (os.isSignedIn) { + os.api('i/update_client_setting', { + name: x.key, + value: x.value + }); + } + }, + + addHomeWidget(ctx, widget) { + ctx.commit('addHomeWidget', widget); + + os.api('i/update_home', { + home: ctx.state.data.home + }); + } + } + } + } +}); diff --git a/src/client/docs/api/endpoints/view.pug b/src/client/docs/api/endpoints/view.pug index d271a5517a..f8795c8442 100644 --- a/src/client/docs/api/endpoints/view.pug +++ b/src/client/docs/api/endpoints/view.pug @@ -2,7 +2,7 @@ extends ../../layout.pug include ../mixins block meta - link(rel="stylesheet" href="/assets/api/endpoints/style.css") + link(rel="stylesheet" href="/docs/assets/api/endpoints/style.css") block main h1= endpoint diff --git a/src/client/docs/api/entities/note.yaml b/src/client/docs/api/entities/note.yaml index 718d331d13..6fd26543bb 100644 --- a/src/client/docs/api/entities/note.yaml +++ b/src/client/docs/api/entities/note.yaml @@ -29,12 +29,6 @@ props: desc: ja: "投稿の本文 (ローカルの場合Markdown風のフォーマット)" en: "The text of this note (in Markdown like format if local)" - - name: "textHtml" - type: "string" - optional: true - desc: - ja: "投稿の本文 (HTML) (投稿時は無視)" - en: "The text of this note (in HTML. Ignored when posting.)" - name: "mediaIds" type: "id(DriveFile)[]" optional: true diff --git a/src/client/docs/api/entities/post.yaml b/src/client/docs/api/entities/post.yaml index 718d331d13..6fd26543bb 100644 --- a/src/client/docs/api/entities/post.yaml +++ b/src/client/docs/api/entities/post.yaml @@ -29,12 +29,6 @@ props: desc: ja: "投稿の本文 (ローカルの場合Markdown風のフォーマット)" en: "The text of this note (in Markdown like format if local)" - - name: "textHtml" - type: "string" - optional: true - desc: - ja: "投稿の本文 (HTML) (投稿時は無視)" - en: "The text of this note (in HTML. Ignored when posting.)" - name: "mediaIds" type: "id(DriveFile)[]" optional: true diff --git a/src/client/docs/api/entities/view.pug b/src/client/docs/api/entities/view.pug index 2156463dc7..ac938151a7 100644 --- a/src/client/docs/api/entities/view.pug +++ b/src/client/docs/api/entities/view.pug @@ -2,7 +2,7 @@ extends ../../layout.pug include ../mixins block meta - link(rel="stylesheet" href="/assets/api/entities/style.css") + link(rel="stylesheet" href="/docs/assets/api/entities/style.css") block main h1= name diff --git a/src/client/docs/api/mixins.pug b/src/client/docs/api/mixins.pug index 686bf6a2b6..913135a85f 100644 --- a/src/client/docs/api/mixins.pug +++ b/src/client/docs/api/mixins.pug @@ -14,13 +14,13 @@ mixin propTable(props) if prop.kind == 'id' if prop.entity | ( - a(href=`/${lang}/api/entities/${kebab(prop.entity)}`)= prop.entity + a(href=`/docs/${lang}/api/entities/${kebab(prop.entity)}`)= prop.entity | ID) else | (ID) else if prop.kind == 'entity' | ( - a(href=`/${lang}/api/entities/${kebab(prop.entity)}`)= prop.entity + a(href=`/docs/${lang}/api/entities/${kebab(prop.entity)}`)= prop.entity | ) else if prop.kind == 'object' if prop.def diff --git a/src/client/docs/follow.ja.pug b/src/client/docs/follow.ja.pug new file mode 100644 index 0000000000..f0e83bc8fd --- /dev/null +++ b/src/client/docs/follow.ja.pug @@ -0,0 +1,9 @@ +h1 フォロー +p ユーザーをフォローすると、タイムラインにそのユーザーの投稿が表示されるようになります。ただし、他のユーザーに対する返信は含まれません。 +p ユーザーをフォローするには、ユーザーページの「フォロー」ボタンをクリックします。フォローを解除するには、もう一度クリックします。 + +section + h2 ストーキング + p ユーザーをフォローしている状態では、さらに「ストーキング」モードをオンにすることができます。ストーキングを行うと、タイムラインにそのユーザーの全ての投稿が表示されるようになります。つまり、他のユーザーに対する返信も含まれることになります。 + p ストーキングするには、ユーザーページの「ストークする」をクリックします。ストーキングをやめるには、もう一度クリックします。 + p ストーキングしていることは相手に通知されません。 diff --git a/src/config/load.ts b/src/config/load.ts index 9f4e2151f3..fea89b989a 100644 --- a/src/config/load.ts +++ b/src/config/load.ts @@ -53,5 +53,5 @@ function normalizeUrl(url: string) { function urlError(url: string) { console.error(`「${url}」は、正しいURLではありません。先頭に http:// または https:// をつけ忘れてないかなど確認してください。`); - process.exit(); + process.exit(99); } diff --git a/src/config/types.ts b/src/config/types.ts index b181f2c8c1..dff3f7d37c 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -40,6 +40,12 @@ export type Source = { site_key: string; secret_key: string; }; + + /** + * ゴーストアカウントのID + */ + ghost?: string; + accesslog?: string; twitter?: { consumer_key: string; diff --git a/src/const.json b/src/const.json index 65dc734fab..b4f3b63ac7 100644 --- a/src/const.json +++ b/src/const.json @@ -1,5 +1,5 @@ { "copyright": "Copyright (c) 2014-2018 syuilo", - "themeColor": "#5cbb2d", + "themeColor": "#f66e4f", "themeColorForeground": "#fff" } diff --git a/src/crypto_key.cc b/src/crypto_key.cc index c8e4d8f7f0..fe67b14532 100644 --- a/src/crypto_key.cc +++ b/src/crypto_key.cc @@ -22,14 +22,14 @@ NAN_METHOD(extractPublic) const auto source = BIO_new_mem_buf(sourceBuf, sourceLength); if (source == nullptr) { Nan::ThrowError("Memory allocation failed"); - delete sourceBuf; + delete[] sourceBuf; return; } const auto rsa = PEM_read_bio_RSAPrivateKey(source, nullptr, nullptr, nullptr); BIO_free(source); - delete sourceBuf; + delete[] sourceBuf; if (rsa == nullptr) { Nan::ThrowError("Decode failed"); diff --git a/src/drive/gen-thumbnail.ts b/src/drive/gen-thumbnail.ts new file mode 100644 index 0000000000..455705cd3a --- /dev/null +++ b/src/drive/gen-thumbnail.ts @@ -0,0 +1,25 @@ +import * as stream from 'stream'; +import * as Gm from 'gm'; +import { IDriveFile, getDriveFileBucket } from '../models/drive-file'; + +const gm = Gm.subClass({ + imageMagick: true +}); + +export default async function(file: IDriveFile): Promise<stream.Readable> { + if (!/^image\/.*$/.test(file.contentType)) return null; + + const bucket = await getDriveFileBucket(); + const readable = bucket.openDownloadStream(file._id); + + const g = gm(readable); + + const stream = g + .resize(256, 256) + .compress('jpeg') + .quality(70) + .interlace('line') + .stream(); + + return stream; +} diff --git a/src/models/drive-file-thumbnail.ts b/src/models/drive-file-thumbnail.ts new file mode 100644 index 0000000000..46de24379f --- /dev/null +++ b/src/models/drive-file-thumbnail.ts @@ -0,0 +1,61 @@ +import * as mongo from 'mongodb'; +import monkDb, { nativeDbConn } from '../db/mongodb'; + +const DriveFileThumbnail = monkDb.get<IDriveFileThumbnail>('driveFileThumbnails.files'); +DriveFileThumbnail.createIndex('metadata.originalId', { sparse: true, unique: true }); +export default DriveFileThumbnail; + +export const DriveFileThumbnailChunk = monkDb.get('driveFileThumbnails.chunks'); + +export const getDriveFileThumbnailBucket = async (): Promise<mongo.GridFSBucket> => { + const db = await nativeDbConn(); + const bucket = new mongo.GridFSBucket(db, { + bucketName: 'driveFileThumbnails' + }); + return bucket; +}; + +export type IMetadata = { + originalId: mongo.ObjectID; +}; + +export type IDriveFileThumbnail = { + _id: mongo.ObjectID; + uploadDate: Date; + md5: string; + filename: string; + contentType: string; + metadata: IMetadata; +}; + +/** + * DriveFileThumbnailを物理削除します + */ +export async function deleteDriveFileThumbnail(driveFile: string | mongo.ObjectID | IDriveFileThumbnail) { + let d: IDriveFileThumbnail; + + // Populate + if (mongo.ObjectID.prototype.isPrototypeOf(driveFile)) { + d = await DriveFileThumbnail.findOne({ + _id: driveFile + }); + } else if (typeof driveFile === 'string') { + d = await DriveFileThumbnail.findOne({ + _id: new mongo.ObjectID(driveFile) + }); + } else { + d = driveFile as IDriveFileThumbnail; + } + + if (d == null) return; + + // このDriveFileThumbnailのチャンクをすべて削除 + await DriveFileThumbnailChunk.remove({ + files_id: d._id + }); + + // このDriveFileThumbnailを削除 + await DriveFileThumbnail.remove({ + _id: d._id + }); +} diff --git a/src/models/drive-file.ts b/src/models/drive-file.ts index fc9c150724..f8cad36f9a 100644 --- a/src/models/drive-file.ts +++ b/src/models/drive-file.ts @@ -6,14 +6,16 @@ import monkDb, { nativeDbConn } from '../db/mongodb'; import Note, { deleteNote } from './note'; import MessagingMessage, { deleteMessagingMessage } from './messaging-message'; import User from './user'; +import DriveFileThumbnail, { deleteDriveFileThumbnail } from './drive-file-thumbnail'; const DriveFile = monkDb.get<IDriveFile>('driveFiles.files'); - +DriveFile.createIndex('md5'); DriveFile.createIndex('metadata.uri', { sparse: true, unique: true }); - export default DriveFile; -const getGridFSBucket = async (): Promise<mongo.GridFSBucket> => { +export const DriveFileChunk = monkDb.get('driveFiles.chunks'); + +export const getDriveFileBucket = async (): Promise<mongo.GridFSBucket> => { const db = await nativeDbConn(); const bucket = new mongo.GridFSBucket(db, { bucketName: 'driveFiles' @@ -21,14 +23,16 @@ const getGridFSBucket = async (): Promise<mongo.GridFSBucket> => { return bucket; }; -export { getGridFSBucket }; - export type IMetadata = { properties: any; userId: mongo.ObjectID; + _user: any; folderId: mongo.ObjectID; comment: string; - uri: string; + uri?: string; + url?: string; + deletedAt?: Date; + isExpired?: boolean; }; export type IDriveFile = { @@ -92,8 +96,13 @@ export async function deleteDriveFile(driveFile: string | mongo.ObjectID | IDriv } } + // このDriveFileのDriveFileThumbnailをすべて削除 + await Promise.all(( + await DriveFileThumbnail.find({ 'metadata.originalId': d._id }) + ).map(x => deleteDriveFileThumbnail(x))); + // このDriveFileのチャンクをすべて削除 - await monkDb.get('driveFiles.chunks').remove({ + await DriveFileChunk.remove({ files_id: d._id }); diff --git a/src/models/favorite.ts b/src/models/favorite.ts index b2c5828088..d24833f191 100644 --- a/src/models/favorite.ts +++ b/src/models/favorite.ts @@ -1,7 +1,10 @@ import * as mongo from 'mongodb'; +import deepcopy = require('deepcopy'); import db from '../db/mongodb'; +import { pack as packNote } from './note'; const Favorite = db.get<IFavorite>('favorites'); +Favorite.createIndex(['userId', 'noteId'], { unique: true }); export default Favorite; export type IFavorite = { @@ -37,3 +40,35 @@ export async function deleteFavorite(favorite: string | mongo.ObjectID | IFavori _id: f._id }); } + +/** + * Pack a favorite for API response + */ +export const pack = ( + favorite: any, + me: any +) => new Promise<any>(async (resolve, reject) => { + let _favorite: any; + + // Populate the favorite if 'favorite' is ID + if (mongo.ObjectID.prototype.isPrototypeOf(favorite)) { + _favorite = await Favorite.findOne({ + _id: favorite + }); + } else if (typeof favorite === 'string') { + _favorite = await Favorite.findOne({ + _id: new mongo.ObjectID(favorite) + }); + } else { + _favorite = deepcopy(favorite); + } + + // Rename _id to id + _favorite.id = _favorite._id; + delete _favorite._id; + + // Populate note + _favorite.note = await packNote(_favorite.noteId, me); + + resolve(_favorite); +}); diff --git a/src/models/following.ts b/src/models/following.ts index f10e349ee9..4712379a70 100644 --- a/src/models/following.ts +++ b/src/models/following.ts @@ -10,6 +10,17 @@ export type IFollowing = { createdAt: Date; followeeId: mongo.ObjectID; followerId: mongo.ObjectID; + stalk: boolean; + + // 非正規化 + _followee: { + host: string; + inbox?: string; + }, + _follower: { + host: string; + inbox?: string; + } }; /** diff --git a/src/models/messaging-message.ts b/src/models/messaging-message.ts index 9d62fab4fa..a6a50fc8cf 100644 --- a/src/models/messaging-message.ts +++ b/src/models/messaging-message.ts @@ -12,7 +12,6 @@ export interface IMessagingMessage { _id: mongo.ObjectID; createdAt: Date; text: string; - textHtml: string; userId: mongo.ObjectID; recipientId: mongo.ObjectID; isRead: boolean; diff --git a/src/models/note-reaction.ts b/src/models/note-reaction.ts index 9bf467f222..706ae54c16 100644 --- a/src/models/note-reaction.ts +++ b/src/models/note-reaction.ts @@ -1,4 +1,5 @@ import * as mongo from 'mongodb'; +import $ from 'cafy'; import deepcopy = require('deepcopy'); import db from '../db/mongodb'; import Reaction from './note-reaction'; @@ -16,6 +17,18 @@ export interface INoteReaction { reaction: string; } +export const validateReaction = $.str.or([ + 'like', + 'love', + 'laugh', + 'hmm', + 'surprise', + 'congrats', + 'angry', + 'confused', + 'pudding' +]); + /** * NoteReactionを物理削除します */ diff --git a/src/models/note.ts b/src/models/note.ts index 3059593540..f42bb2a49d 100644 --- a/src/models/note.ts +++ b/src/models/note.ts @@ -12,11 +12,11 @@ import NoteWatching, { deleteNoteWatching } from './note-watching'; import NoteReaction from './note-reaction'; import Favorite, { deleteFavorite } from './favorite'; import Notification, { deleteNotification } from './notification'; +import Following from './following'; const Note = db.get<INote>('notes'); - Note.createIndex('uri', { sparse: true, unique: true }); - +Note.createIndex('userId'); export default Note; export function isValidText(text: string): boolean { @@ -24,7 +24,7 @@ export function isValidText(text: string): boolean { } export function isValidCw(text: string): boolean { - return text.length <= 100 && text.trim() != ''; + return text.length <= 100; } export type INote = { @@ -38,7 +38,6 @@ export type INote = { poll: any; // todo text: string; tags: string[]; - textHtml: string; cw: string; userId: mongo.ObjectID; appId: mongo.ObjectID; @@ -47,7 +46,18 @@ export type INote = { repliesCount: number; reactionCounts: any; mentions: mongo.ObjectID[]; - visibility: 'public' | 'unlisted' | 'private' | 'direct'; + + /** + * public ... 公開 + * home ... ホームタイムライン(ユーザーページのタイムライン含む)のみに流す + * followers ... フォロワーのみ + * specified ... visibleUserIds で指定したユーザーのみ + * private ... 自分のみ + */ + visibility: 'public' | 'home' | 'followers' | 'specified' | 'private'; + + visibleUserIds: mongo.ObjectID[]; + geo: { coordinates: number[]; altitude: number; @@ -58,6 +68,7 @@ export type INote = { }; uri: string; + // 非正規化 _reply?: { userId: mongo.ObjectID; }; @@ -66,9 +77,7 @@ export type INote = { }; _user: { host: string; - account: { - inbox?: string; - }; + inbox?: string; }; }; @@ -153,9 +162,9 @@ export const pack = async ( detail: boolean } ) => { - const opts = options || { - detail: true, - }; + const opts = Object.assign({ + detail: true + }, options); // Me const meId: mongo.ObjectID = me @@ -183,12 +192,61 @@ export const pack = async ( if (!_note) throw `invalid note arg ${note}`; + let hide = false; + + // visibility が private かつ投稿者のIDが自分のIDではなかったら非表示 + if (_note.visibility == 'private' && (meId == null || !meId.equals(_note.userId))) { + hide = true; + } + + // visibility が specified かつ自分が指定されていなかったら非表示 + if (_note.visibility == 'specified') { + if (meId == null) { + hide = true; + } else if (meId.equals(_note.userId)) { + hide = false; + } else { + // 指定されているかどうか + const specified = _note.visibleUserIds.some(id => id.equals(meId)); + + if (specified) { + hide = false; + } else { + hide = true; + } + } + } + + // visibility が followers かつ自分が投稿者のフォロワーでなかったら非表示 + if (_note.visibility == 'followers') { + if (meId == null) { + hide = true; + } else if (meId.equals(_note.userId)) { + hide = false; + } else { + // フォロワーかどうか + const following = await Following.findOne({ + followeeId: _note.userId, + followerId: meId + }); + + if (following == null) { + hide = true; + } else { + hide = false; + } + } + } + const id = _note._id; // Rename _id to id _note.id = _note._id; delete _note._id; + delete _note._user; + delete _note._reply; + delete _note.repost; delete _note.mentions; if (_note.geo) delete _note.geo.type; @@ -206,49 +264,16 @@ export const pack = async ( } // Populate media - if (_note.mediaIds) { - _note.media = Promise.all(_note.mediaIds.map(fileId => - packFile(fileId) - )); - } + _note.media = hide ? [] : Promise.all(_note.mediaIds.map(fileId => + packFile(fileId) + )); // When requested a detailed note data if (opts.detail) { - // Get previous note info - _note.prev = (async () => { - const prev = await Note.findOne({ - userId: _note.userId, - _id: { - $lt: id - } - }, { - fields: { - _id: true - }, - sort: { - _id: -1 - } - }); - return prev ? prev._id : null; - })(); - - // Get next note info - _note.next = (async () => { - const next = await Note.findOne({ - userId: _note.userId, - _id: { - $gt: id - } - }, { - fields: { - _id: true - }, - sort: { - _id: 1 - } - }); - return next ? next._id : null; - })(); + //#region 重いので廃止 + _note.prev = null; + _note.next = null; + //#endregion if (_note.replyId) { // Populate reply to note @@ -265,7 +290,7 @@ export const pack = async ( } // Poll - if (meId && _note.poll) { + if (meId && _note.poll && !hide) { _note.poll = (async (poll) => { const vote = await PollVote .findOne({ @@ -306,5 +331,12 @@ export const pack = async ( // resolve promises in _note object _note = await rap(_note); + if (hide) { + _note.mediaIds = []; + _note.text = null; + _note.poll = null; + _note.isHidden = true; + } + return _note; }; diff --git a/src/models/sw-subscription.ts b/src/models/sw-subscription.ts index 621ac8a9b6..a38edd3a50 100644 --- a/src/models/sw-subscription.ts +++ b/src/models/sw-subscription.ts @@ -38,4 +38,3 @@ export async function deleteSwSubscription(swSubscription: string | mongo.Object _id: s._id }); } - diff --git a/src/models/user-list.ts b/src/models/user-list.ts new file mode 100644 index 0000000000..7100fced7e --- /dev/null +++ b/src/models/user-list.ts @@ -0,0 +1,67 @@ +import * as mongo from 'mongodb'; +import deepcopy = require('deepcopy'); +import db from '../db/mongodb'; + +const UserList = db.get<IUserList>('userList'); +export default UserList; + +export interface IUserList { + _id: mongo.ObjectID; + createdAt: Date; + title: string; + userId: mongo.ObjectID; + userIds: mongo.ObjectID[]; +} + +/** + * UserListを物理削除します + */ +export async function deleteUserList(userList: string | mongo.ObjectID | IUserList) { + let u: IUserList; + + // Populate + if (mongo.ObjectID.prototype.isPrototypeOf(userList)) { + u = await UserList.findOne({ + _id: userList + }); + } else if (typeof userList === 'string') { + u = await UserList.findOne({ + _id: new mongo.ObjectID(userList) + }); + } else { + u = userList as IUserList; + } + + if (u == null) return; + + // このUserListを削除 + await UserList.remove({ + _id: u._id + }); +} + +export const pack = ( + userList: string | mongo.ObjectID | IUserList +) => new Promise<any>(async (resolve, reject) => { + let _userList: any; + + if (mongo.ObjectID.prototype.isPrototypeOf(userList)) { + _userList = await UserList.findOne({ + _id: userList + }); + } else if (typeof userList === 'string') { + _userList = await UserList.findOne({ + _id: new mongo.ObjectID(userList) + }); + } else { + _userList = deepcopy(userList); + } + + if (!_userList) throw `invalid userList arg ${userList}`; + + // Rename _id to id + _userList.id = _userList._id; + delete _userList._id; + + resolve(_userList); +}); diff --git a/src/models/user.ts b/src/models/user.ts index 741306fd27..108111ceca 100644 --- a/src/models/user.ts +++ b/src/models/user.ts @@ -1,11 +1,12 @@ import * as mongo from 'mongodb'; import deepcopy = require('deepcopy'); +import sequential = require('promise-sequential'); import rap from '@prezzemolo/rap'; import db from '../db/mongodb'; -import Note, { INote, pack as packNote, deleteNote } from './note'; +import Note, { pack as packNote, deleteNote } from './note'; import Following, { deleteFollowing } from './following'; import Mute, { deleteMute } from './mute'; -import getFriends from '../server/api/common/get-friends'; +import { getFriendIds } from '../server/api/common/get-friends'; import config from '../config'; import AccessToken, { deleteAccessToken } from './access-token'; import NoteWatching, { deleteNoteWatching } from './note-watching'; @@ -20,6 +21,7 @@ import FollowingLog, { deleteFollowingLog } from './following-log'; import FollowedLog, { deleteFollowedLog } from './followed-log'; import SwSubscription, { deleteSwSubscription } from './sw-subscription'; import Notification, { deleteNotification } from './notification'; +import UserList, { deleteUserList } from './user-list'; const User = db.get<IUser>('users'); @@ -35,7 +37,7 @@ export default User; type IUserBase = { _id: mongo.ObjectID; createdAt: Date; - deletedAt: Date; + deletedAt?: Date; followersCount: number; followingCount: number; name?: string; @@ -47,10 +49,8 @@ type IUserBase = { bannerId: mongo.ObjectID; data: any; description: string; - latestNote: INote; pinnedNoteId: mongo.ObjectID; isSuspended: boolean; - keywords: string[]; host: string; }; @@ -80,13 +80,14 @@ export interface ILocalUser extends IUserBase { isPro: boolean; twoFactorSecret: string; twoFactorEnabled: boolean; - twoFactorTempSecret: string; + twoFactorTempSecret?: string; clientSettings: any; settings: any; } export interface IRemoteUser extends IUserBase { inbox: string; + endpoints: string[]; uri: string; url?: string; publicKey: { @@ -114,7 +115,7 @@ export function validatePassword(password: string): boolean { } export function isValidName(name?: string): boolean { - return name === null || (typeof name == 'string' && name.length < 30 && name.trim() != ''); + return name === null || (typeof name == 'string' && name.length < 50 && name.trim() != ''); } export function isValidDescription(description: string): boolean { @@ -167,9 +168,9 @@ export async function deleteUser(user: string | mongo.ObjectID | IUser) { ).map(x => deleteAccessToken(x))); // このユーザーのNoteをすべて削除 - await Promise.all(( - await Note.find({ userId: u._id }) - ).map(x => deleteNote(x))); + //await sequential(( + // await Note.find({ userId: u._id }) + //).map(x => () => deleteNote(x))); // このユーザーのNoteReactionをすべて削除 await Promise.all(( @@ -261,6 +262,20 @@ export async function deleteUser(user: string | mongo.ObjectID | IUser) { await Notification.find({ notifierId: u._id }) ).map(x => deleteNotification(x))); + // このユーザーのUserListをすべて削除 + await Promise.all(( + await UserList.find({ userId: u._id }) + ).map(x => deleteUserList(x))); + + // このユーザーが入っているすべてのUserListからこのユーザーを削除 + await Promise.all(( + await UserList.find({ userIds: u._id }) + ).map(x => + UserList.update({ _id: x._id }, { + $pull: { userIds: u._id } + }) + )); + // このユーザーを削除 await User.remove({ _id: u._id @@ -332,9 +347,6 @@ export const pack = ( _user.id = _user._id; delete _user._id; - // Remove needless properties - delete _user.latestNote; - if (_user.host == null) { // Remove private properties delete _user.keypair; @@ -359,6 +371,8 @@ export const pack = ( if (!opts.detail) { delete _user.twoFactorEnabled; } + } else { + delete _user.publicKey; } _user.avatarUrl = _user.avatarId != null @@ -377,33 +391,30 @@ export const pack = ( } if (meId && !meId.equals(_user.id)) { - // Whether the user is following - _user.isFollowing = (async () => { - const follow = await Following.findOne({ + const [following1, following2, mute] = await Promise.all([ + Following.findOne({ followerId: meId, followeeId: _user.id - }); - return follow !== null; - })(); - - // Whether the user is followed - _user.isFollowed = (async () => { - const follow2 = await Following.findOne({ + }), + Following.findOne({ followerId: _user.id, followeeId: meId - }); - return follow2 !== null; - })(); + }), + Mute.findOne({ + muterId: meId, + muteeId: _user.id + }) + ]); + + // Whether the user is following + _user.isFollowing = following1 !== null; + _user.isStalking = following1 && following1.stalk; + + // Whether the user is followed + _user.isFollowed = following2 !== null; // Whether the user is muted - _user.isMuted = (async () => { - const mute = await Mute.findOne({ - muterId: meId, - muteeId: _user.id, - deletedAt: { $exists: false } - }); - return mute !== null; - })(); + _user.isMuted = mute !== null; } if (opts.detail) { @@ -415,7 +426,7 @@ export const pack = ( } if (meId && !meId.equals(_user.id)) { - const myFollowingIds = await getFriends(meId); + const myFollowingIds = await getFriendIds(meId); // Get following you know count _user.followingYouKnowCount = Following.count({ @@ -448,3 +459,7 @@ function img(url) { }; } */ + +export function getGhost(): Promise<ILocalUser> { + return User.findOne({ _id: new mongo.ObjectId(config.ghost) }); +} diff --git a/src/publishers/stream.ts b/src/publishers/stream.ts index 2ecbfa0dd8..dcc03e39f1 100644 --- a/src/publishers/stream.ts +++ b/src/publishers/stream.ts @@ -25,6 +25,10 @@ class MisskeyEvent { this.publish(`note-stream:${noteId}`, type, typeof value === 'undefined' ? null : value); } + public publishUserListStream(listId: ID, type: string, value?: any): void { + this.publish(`user-list-stream:${listId}`, type, typeof value === 'undefined' ? null : value); + } + public publishMessagingStream(userId: ID, otherpartyId: ID, type: string, value?: any): void { this.publish(`messaging-stream:${userId}-${otherpartyId}`, type, typeof value === 'undefined' ? null : value); } @@ -69,6 +73,7 @@ export default ev.publishUserStream.bind(ev); export const publishLocalTimelineStream = ev.publishLocalTimelineStream.bind(ev); export const publishGlobalTimelineStream = ev.publishGlobalTimelineStream.bind(ev); export const publishDriveStream = ev.publishDriveStream.bind(ev); +export const publishUserListStream = ev.publishUserListStream.bind(ev); export const publishNoteStream = ev.publishNoteStream.bind(ev); export const publishMessagingStream = ev.publishMessagingStream.bind(ev); export const publishMessagingIndexStream = ev.publishMessagingIndexStream.bind(ev); diff --git a/src/queue/index.ts b/src/queue/index.ts index 88e475dd5d..3f82b30b35 100644 --- a/src/queue/index.ts +++ b/src/queue/index.ts @@ -2,6 +2,7 @@ import { createQueue } from 'kue'; import config from '../config'; import http from './processors/http'; +import { ILocalUser } from '../models/user'; const queue = createQueue({ redis: { @@ -14,17 +15,20 @@ const queue = createQueue({ export function createHttp(data) { return queue .create('http', data) + .removeOnComplete(true) + .events(false) .attempts(8) .backoff({ delay: 16384, type: 'exponential' }); } -export function deliver(user, content, to) { +export function deliver(user: ILocalUser, content, to) { createHttp({ + title: 'deliver', type: 'deliver', user, content, to - }).removeOnComplete(true).save(); + }).save(); } export default function() { diff --git a/src/queue/processors/http/deliver.ts b/src/queue/processors/http/deliver.ts index cf843fad07..2c4bbe9bf0 100644 --- a/src/queue/processors/http/deliver.ts +++ b/src/queue/processors/http/deliver.ts @@ -7,13 +7,14 @@ export default async (job: kue.Job, done): Promise<void> => { await request(job.data.user, job.data.to, job.data.content); done(); } catch (res) { + if (res.statusCode == null) return done(); if (res.statusCode >= 400 && res.statusCode < 500) { // HTTPステータスコード4xxはクライアントエラーであり、それはつまり // 何回再送しても成功することはないということなのでエラーにはしないでおく done(); } else { console.warn(`deliver failed: ${res.statusMessage}`); - done(new Error(res.statusMessage)); + done(res.statusMessage); } } }; diff --git a/src/queue/processors/http/process-inbox.ts b/src/queue/processors/http/process-inbox.ts index 75ff5918f6..dfafe64a78 100644 --- a/src/queue/processors/http/process-inbox.ts +++ b/src/queue/processors/http/process-inbox.ts @@ -33,6 +33,11 @@ export default async (job: kue.Job, done): Promise<void> => { } user = await User.findOne({ usernameLower: username, host: host.toLowerCase() }) as IRemoteUser; + + // アクティビティを送信してきたユーザーがまだMisskeyサーバーに登録されていなかったら登録する + if (user === null) { + user = await resolvePerson(activity.actor); + } } else { user = await User.findOne({ host: { $ne: null }, diff --git a/src/remote/activitypub/kernel/announce/note.ts b/src/remote/activitypub/kernel/announce/note.ts index a288dd499a..fe645b07b5 100644 --- a/src/remote/activitypub/kernel/announce/note.ts +++ b/src/remote/activitypub/kernel/announce/note.ts @@ -5,6 +5,7 @@ import post from '../../../../services/note/create'; import { IRemoteUser } from '../../../../models/user'; import { IAnnounce, INote } from '../../type'; import { fetchNote, resolveNote } from '../../models/note'; +import { resolvePerson } from '../../models/person'; const log = debug('misskey:activitypub'); @@ -30,16 +31,22 @@ export default async function(resolver: Resolver, actor: IRemoteUser, activity: //#region Visibility let visibility = 'public'; - if (!activity.to.includes('https://www.w3.org/ns/activitystreams#Public')) visibility = 'unlisted'; - if (activity.cc.length == 0) visibility = 'private'; - // TODO - if (visibility != 'public') throw new Error('unspported visibility'); + let visibleUsers = []; + if (!note.to.includes('https://www.w3.org/ns/activitystreams#Public')) { + if (note.cc.includes('https://www.w3.org/ns/activitystreams#Public')) { + visibility = 'home'; + } else { + visibility = 'specified'; + visibleUsers = await Promise.all(note.to.map(uri => resolvePerson(uri))); + } + } if (activity.cc.length == 0) visibility = 'followers'; //#endergion await post(actor, { createdAt: new Date(activity.published), renote, visibility, + visibleUsers, uri }); } diff --git a/src/remote/activitypub/kernel/delete/note.ts b/src/remote/activitypub/kernel/delete/note.ts index 64c342d39b..b2868f69a3 100644 --- a/src/remote/activitypub/kernel/delete/note.ts +++ b/src/remote/activitypub/kernel/delete/note.ts @@ -22,7 +22,6 @@ export default async function(actor: IRemoteUser, uri: string): Promise<void> { $set: { deletedAt: new Date(), text: null, - textHtml: null, mediaIds: [], poll: null } diff --git a/src/remote/activitypub/kernel/follow.ts b/src/remote/activitypub/kernel/follow.ts index 6a8b5a1bec..7e31eb32ea 100644 --- a/src/remote/activitypub/kernel/follow.ts +++ b/src/remote/activitypub/kernel/follow.ts @@ -1,3 +1,4 @@ +import * as mongo from 'mongodb'; import User, { IRemoteUser } from '../../../models/user'; import config from '../../../config'; import follow from '../../../services/following/create'; @@ -10,7 +11,9 @@ export default async (actor: IRemoteUser, activity: IFollow): Promise<void> => { return null; } - const followee = await User.findOne({ _id: id.split('/').pop() }); + const followee = await User.findOne({ + _id: new mongo.ObjectID(id.split('/').pop()) + }); if (followee === null) { throw new Error('followee not found'); diff --git a/src/remote/activitypub/kernel/like.ts b/src/remote/activitypub/kernel/like.ts index 4941608588..17ec73f12b 100644 --- a/src/remote/activitypub/kernel/like.ts +++ b/src/remote/activitypub/kernel/like.ts @@ -1,7 +1,9 @@ +import * as mongo from 'mongodb'; import Note from '../../../models/note'; import { IRemoteUser } from '../../../models/user'; import { ILike } from '../type'; import create from '../../../services/note/reaction/create'; +import { validateReaction } from '../../../models/note-reaction'; export default async (actor: IRemoteUser, activity: ILike) => { const id = typeof activity.object == 'string' ? activity.object : activity.object.id; @@ -9,12 +11,21 @@ export default async (actor: IRemoteUser, activity: ILike) => { // Transform: // https://misskey.ex/notes/xxxx to // xxxx - const noteId = id.split('/').pop(); + const noteId = new mongo.ObjectID(id.split('/').pop()); const note = await Note.findOne({ _id: noteId }); if (note === null) { throw new Error(); } - await create(actor, note, 'pudding'); + let reaction = 'pudding'; + + // 他のMisskeyインスタンスからのリアクション + if (activity._misskey_reaction) { + if (validateReaction.ok(activity._misskey_reaction)) { + reaction = activity._misskey_reaction; + } + } + + await create(actor, note, reaction); }; diff --git a/src/remote/activitypub/kernel/undo/follow.ts b/src/remote/activitypub/kernel/undo/follow.ts index a85cb0305d..c0b10c1898 100644 --- a/src/remote/activitypub/kernel/undo/follow.ts +++ b/src/remote/activitypub/kernel/undo/follow.ts @@ -1,3 +1,4 @@ +import * as mongo from 'mongodb'; import User, { IRemoteUser } from '../../../../models/user'; import config from '../../../../config'; import unfollow from '../../../../services/following/delete'; @@ -10,7 +11,9 @@ export default async (actor: IRemoteUser, activity: IFollow): Promise<void> => { return null; } - const followee = await User.findOne({ _id: id.split('/').pop() }); + const followee = await User.findOne({ + _id: new mongo.ObjectID(id.split('/').pop()) + }); if (followee === null) { throw new Error('followee not found'); diff --git a/src/remote/activitypub/misc/get-note-html.ts b/src/remote/activitypub/misc/get-note-html.ts new file mode 100644 index 0000000000..5bca4eed62 --- /dev/null +++ b/src/remote/activitypub/misc/get-note-html.ts @@ -0,0 +1,23 @@ +import { INote } from "../../../models/note"; +import toHtml from '../../../text/html'; +import parse from '../../../text/parse'; +import config from '../../../config'; + +export default function(note: INote) { + if (note.text == null) return null; + + let html = toHtml(parse(note.text)); + + if (note.poll != null) { + const url = `${config.url}/notes/${note._id}`; + // TODO: i18n + html += `<p><a href="${url}">【Misskeyで投票を見る】</a></p>`; + } + + if (note.renoteId != null) { + const url = `${config.url}/notes/${note.renoteId}`; + html += `<p>RE: <a href="${url}">${url}</a></p>`; + } + + return html; +} diff --git a/src/remote/activitypub/models/image.ts b/src/remote/activitypub/models/image.ts index d7bc5aff2f..0d5a690c6c 100644 --- a/src/remote/activitypub/models/image.ts +++ b/src/remote/activitypub/models/image.ts @@ -11,6 +11,11 @@ const log = debug('misskey:activitypub'); * Imageを作成します。 */ export async function createImage(actor: IRemoteUser, value): Promise<IDriveFile> { + // 投稿者が凍結されていたらスキップ + if (actor.isSuspended) { + return null; + } + const image = await new Resolver().resolve(value); if (image.url == null) { @@ -19,7 +24,7 @@ export async function createImage(actor: IRemoteUser, value): Promise<IDriveFile log(`Creating the Image: ${image.url}`); - return await uploadFromUrl(image.url, actor); + return await uploadFromUrl(image.url, actor, null, image.url); } /** diff --git a/src/remote/activitypub/models/note.ts b/src/remote/activitypub/models/note.ts index ab6dd99a77..91e700ef6f 100644 --- a/src/remote/activitypub/models/note.ts +++ b/src/remote/activitypub/models/note.ts @@ -1,4 +1,5 @@ -import { JSDOM } from 'jsdom'; +import * as mongo from 'mongodb'; +import * as parse5 from 'parse5'; import * as debug from 'debug'; import config from '../../../config'; @@ -12,6 +13,76 @@ import { IRemoteUser } from '../../../models/user'; const log = debug('misskey:activitypub'); +function parse(html: string): string { + const dom = parse5.parseFragment(html) as parse5.AST.Default.Document; + + let text = ''; + + dom.childNodes.forEach(n => analyze(n)); + + return text.trim(); + + function getText(node) { + if (node.nodeName == '#text') return node.value; + + if (node.childNodes) { + return node.childNodes.map(n => getText(n)).join(''); + } + + return ''; + } + + function analyze(node) { + switch (node.nodeName) { + case '#text': + text += node.value; + break; + + case 'br': + text += '\n'; + break; + + case 'a': + const txt = getText(node); + + // メンション + if (txt.startsWith('@')) { + const part = txt.split('@'); + + if (part.length == 2) { + //#region ホスト名部分が省略されているので復元する + const href = new URL(node.attrs.find(x => x.name == 'href').value); + const acct = txt + '@' + href.hostname; + text += acct; + break; + //#endregion + } else if (part.length == 3) { + text += txt; + break; + } + } + + if (node.childNodes) { + node.childNodes.forEach(n => analyze(n)); + } + break; + + case 'p': + text += '\n\n'; + if (node.childNodes) { + node.childNodes.forEach(n => analyze(n)); + } + break; + + default: + if (node.childNodes) { + node.childNodes.forEach(n => analyze(n)); + } + break; + } + } +} + /** * Noteをフェッチします。 * @@ -22,7 +93,8 @@ export async function fetchNote(value: string | IObject, resolver?: Resolver): P // URIがこのサーバーを指しているならデータベースからフェッチ if (uri.startsWith(config.url + '/')) { - return await Note.findOne({ _id: uri.split('/').pop() }); + const id = new mongo.ObjectID(uri.split('/').pop()); + return await Note.findOne({ _id: id }); } //#region このサーバーに既に登録されていたらそれを返す @@ -45,7 +117,8 @@ export async function createNote(value: any, resolver?: Resolver, silent = false const object = await resolver.resolve(value) as any; if (object == null || object.type !== 'Note') { - throw new Error('invalid note'); + log(`invalid note: ${object}`); + return null; } const note: INoteActivityStreamsObject = object; @@ -55,12 +128,23 @@ export async function createNote(value: any, resolver?: Resolver, silent = false // 投稿者をフェッチ const actor = await resolvePerson(note.attributedTo) as IRemoteUser; + // 投稿者が凍結されていたらスキップ + if (actor.isSuspended) { + return null; + } + //#region Visibility let visibility = 'public'; - if (!note.to.includes('https://www.w3.org/ns/activitystreams#Public')) visibility = 'unlisted'; - if (note.cc.length == 0) visibility = 'private'; - // TODO - if (visibility != 'public') throw new Error('unspported visibility'); + let visibleUsers = []; + if (!note.to.includes('https://www.w3.org/ns/activitystreams#Public')) { + if (note.cc.includes('https://www.w3.org/ns/activitystreams#Public')) { + visibility = 'home'; + } else { + visibility = 'specified'; + visibleUsers = await Promise.all(note.to.map(uri => resolvePerson(uri))); + } + } + if (note.cc.length == 0) visibility = 'followers'; //#endergion // 添付メディア @@ -73,7 +157,8 @@ export async function createNote(value: any, resolver?: Resolver, silent = false // リプライ const reply = note.inReplyTo ? await resolveNote(note.inReplyTo, resolver) : null; - const { window } = new JSDOM(note.content); + // テキストのパース + const text = parse(note.content); // ユーザーの情報が古かったらついでに更新しておく if (actor.updatedAt == null || Date.now() - actor.updatedAt.getTime() > 1000 * 60 * 60 * 24) { @@ -85,10 +170,11 @@ export async function createNote(value: any, resolver?: Resolver, silent = false media, reply, renote: undefined, - text: window.document.body.textContent, + text: text, viaMobile: false, geo: undefined, visibility, + visibleUsers, uri: note.id }, silent); } diff --git a/src/remote/activitypub/models/person.ts b/src/remote/activitypub/models/person.ts index b755b2603a..33280f3d89 100644 --- a/src/remote/activitypub/models/person.ts +++ b/src/remote/activitypub/models/person.ts @@ -1,3 +1,4 @@ +import * as mongo from 'mongodb'; import { JSDOM } from 'jsdom'; import { toUnicode } from 'punycode'; import * as debug from 'debug'; @@ -21,7 +22,8 @@ export async function fetchPerson(value: string | IObject, resolver?: Resolver): // URIがこのサーバーを指しているならデータベースからフェッチ if (uri.startsWith(config.url + '/')) { - return await User.findOne({ _id: uri.split('/').pop() }); + const id = new mongo.ObjectID(uri.split('/').pop()); + return await User.findOne({ _id: id }); } //#region このサーバーに既に登録されていたらそれを返す @@ -47,6 +49,7 @@ export async function createPerson(value: any, resolver?: Resolver): Promise<IUs object == null || object.type !== 'Person' || typeof object.preferredUsername !== 'string' || + typeof object.inbox !== 'string' || !validateUsername(object.preferredUsername) || !isValidName(object.name == '' ? null : object.name) ) { @@ -78,27 +81,39 @@ export async function createPerson(value: any, resolver?: Resolver): Promise<IUs const summaryDOM = JSDOM.fragment(person.summary); // Create user - const user = await User.insert({ - avatarId: null, - bannerId: null, - createdAt: Date.parse(person.published) || null, - description: summaryDOM.textContent, - followersCount, - followingCount, - notesCount, - name: person.name, - driveCapacity: 1024 * 1024 * 8, // 8MiB - username: person.preferredUsername, - usernameLower: person.preferredUsername.toLowerCase(), - host, - publicKey: { - id: person.publicKey.id, - publicKeyPem: person.publicKey.publicKeyPem - }, - inbox: person.inbox, - uri: person.id, - url: person.url - }) as IRemoteUser; + let user: IRemoteUser; + try { + user = await User.insert({ + avatarId: null, + bannerId: null, + createdAt: Date.parse(person.published) || null, + description: summaryDOM.textContent, + followersCount, + followingCount, + notesCount, + name: person.name, + driveCapacity: 1024 * 1024 * 8, // 8MiB + username: person.preferredUsername, + usernameLower: person.preferredUsername.toLowerCase(), + host, + publicKey: { + id: person.publicKey.id, + publicKeyPem: person.publicKey.publicKeyPem + }, + inbox: person.inbox, + endpoints: person.endpoints, + uri: person.id, + url: person.url + }) as IRemoteUser; + } catch (e) { + // duplicate key error + if (e.code === 11000) { + throw new Error('already registered'); + } + + console.error(e); + throw e; + } //#region アイコンとヘッダー画像をフェッチ const [avatarId, bannerId] = (await Promise.all([ @@ -194,7 +209,8 @@ export async function updatePerson(value: string | IObject, resolver?: Resolver) followingCount, notesCount, name: person.name, - url: person.url + url: person.url, + endpoints: person.endpoints } }); } diff --git a/src/remote/activitypub/renderer/like.ts b/src/remote/activitypub/renderer/like.ts index 061a10ba84..33e1341a20 100644 --- a/src/remote/activitypub/renderer/like.ts +++ b/src/remote/activitypub/renderer/like.ts @@ -1,8 +1,9 @@ import config from '../../../config'; import { ILocalUser } from '../../../models/user'; -export default (user: ILocalUser, note) => ({ +export default (user: ILocalUser, note, reaction: string) => ({ type: 'Like', actor: `${config.url}/users/${user._id}`, - object: note.uri ? note.uri : `${config.url}/notes/${note._id}` + object: note.uri ? note.uri : `${config.url}/notes/${note._id}`, + _misskey_reaction: reaction }); diff --git a/src/remote/activitypub/renderer/note.ts b/src/remote/activitypub/renderer/note.ts index c364b13249..a05c12b388 100644 --- a/src/remote/activitypub/renderer/note.ts +++ b/src/remote/activitypub/renderer/note.ts @@ -4,6 +4,7 @@ import config from '../../../config'; import DriveFile from '../../../models/drive-file'; import Note, { INote } from '../../../models/note'; import User from '../../../models/user'; +import toHtml from '../misc/get-note-html'; export default async function renderNote(note: INote, dive = true) { const promisedFiles = note.mediaIds @@ -48,7 +49,7 @@ export default async function renderNote(note: INote, dive = true) { id: `${config.url}/notes/${note._id}`, type: 'Note', attributedTo, - content: note.textHtml, + content: toHtml(note), published: note.createdAt.toISOString(), to: 'https://www.w3.org/ns/activitystreams#Public', cc: `${attributedTo}/followers`, diff --git a/src/remote/activitypub/renderer/person.ts b/src/remote/activitypub/renderer/person.ts index f1c8056a75..424305f8d3 100644 --- a/src/remote/activitypub/renderer/person.ts +++ b/src/remote/activitypub/renderer/person.ts @@ -10,6 +10,7 @@ export default user => { id, inbox: `${id}/inbox`, outbox: `${id}/outbox`, + sharedInbox: `${config.url}/inbox`, url: `${config.url}/@${user.username}`, preferredUsername: user.username, name: user.name, diff --git a/src/remote/activitypub/request.ts b/src/remote/activitypub/request.ts index 85f43eb91d..e6861fdb3e 100644 --- a/src/remote/activitypub/request.ts +++ b/src/remote/activitypub/request.ts @@ -40,5 +40,10 @@ export default (user: ILocalUser, url: string, object) => new Promise((resolve, keyId: `acct:${user.username}@${config.host}` }); + // Signature: Signature ... => Signature: ... + let sig = req.getHeader('Signature').toString(); + sig = sig.replace(/^Signature /, ''); + req.setHeader('Signature', sig); + req.end(JSON.stringify(object)); }); diff --git a/src/remote/activitypub/type.ts b/src/remote/activitypub/type.ts index 08e5493dd4..ca38ec2227 100644 --- a/src/remote/activitypub/type.ts +++ b/src/remote/activitypub/type.ts @@ -15,6 +15,7 @@ export interface IObject { icon?: any; image?: any; url?: string; + tag?: any[]; } export interface IActivity extends IObject { @@ -49,6 +50,7 @@ export interface IPerson extends IObject { followers: any; following: any; outbox: any; + endpoints: string[]; } export const isCollection = (object: IObject): object is ICollection => @@ -82,6 +84,7 @@ export interface IAccept extends IActivity { export interface ILike extends IActivity { type: 'Like'; + _misskey_reaction: string; } export interface IAnnounce extends IActivity { diff --git a/src/remote/resolve-user.ts b/src/remote/resolve-user.ts index b6048842bf..c612a8c949 100644 --- a/src/remote/resolve-user.ts +++ b/src/remote/resolve-user.ts @@ -1,11 +1,16 @@ import { toUnicode, toASCII } from 'punycode'; -import User from '../models/user'; +import User, { IUser } from '../models/user'; import webFinger from './webfinger'; import config from '../config'; import { createPerson } from './activitypub/models/person'; -export default async (username, _host, option) => { +export default async (username, _host, option?): Promise<IUser> => { const usernameLower = username.toLowerCase(); + + if (_host == null) { + return await User.findOne({ usernameLower }); + } + const hostAscii = toASCII(_host).toLowerCase(); const host = toUnicode(hostAscii); diff --git a/src/renderers/get-note-summary.ts b/src/renderers/get-note-summary.ts index fc7482ca16..0844c0b184 100644 --- a/src/renderers/get-note-summary.ts +++ b/src/renderers/get-note-summary.ts @@ -1,8 +1,12 @@ /** * 投稿を表す文字列を取得します。 - * @param {*} note 投稿 + * @param {*} note (packされた)投稿 */ const summarize = (note: any): string => { + if (note.isHidden) { + return '(非公開の投稿)'; + } + let summary = ''; // チャンネル diff --git a/src/server/activitypub.ts b/src/server/activitypub.ts index e27e2552f3..3c07a3e2f2 100644 --- a/src/server/activitypub.ts +++ b/src/server/activitypub.ts @@ -1,3 +1,5 @@ +import * as mongo from 'mongodb'; +import * as Koa from 'koa'; import * as Router from 'koa-router'; const json = require('koa-json-body'); const httpSignature = require('http-signature'); @@ -18,8 +20,7 @@ const router = new Router(); //#region Routing -// inbox -router.post('/users/:user/inbox', json(), ctx => { +function inbox(ctx: Koa.Context) { let signature; ctx.req.headers.authorization = 'Signature ' + ctx.req.headers.signature; @@ -38,7 +39,11 @@ router.post('/users/:user/inbox', json(), ctx => { }).save(); ctx.status = 202; -}); +} + +// inbox +router.post('/inbox', json(), inbox); +router.post('/users/:user/inbox', json(), inbox); // note router.get('/notes/:note', async (ctx, next) => { @@ -49,7 +54,7 @@ router.get('/notes/:note', async (ctx, next) => { } const note = await Note.findOne({ - _id: ctx.params.note + _id: new mongo.ObjectID(ctx.params.note) }); if (note === null) { @@ -62,7 +67,7 @@ router.get('/notes/:note', async (ctx, next) => { // outbot router.get('/users/:user/outbox', async ctx => { - const userId = ctx.params.user; + const userId = new mongo.ObjectID(ctx.params.user); const user = await User.findOne({ _id: userId }); @@ -84,7 +89,7 @@ router.get('/users/:user/outbox', async ctx => { // publickey router.get('/users/:user/publickey', async ctx => { - const userId = ctx.params.user; + const userId = new mongo.ObjectID(ctx.params.user); const user = await User.findOne({ _id: userId }); @@ -102,7 +107,7 @@ router.get('/users/:user/publickey', async ctx => { // user router.get('/users/:user', async ctx => { - const userId = ctx.params.user; + const userId = new mongo.ObjectID(ctx.params.user); const user = await User.findOne({ _id: userId }); diff --git a/src/server/api/common/get-friends.ts b/src/server/api/common/get-friends.ts index c1cc3957d8..50ba71ea96 100644 --- a/src/server/api/common/get-friends.ts +++ b/src/server/api/common/get-friends.ts @@ -1,10 +1,10 @@ import * as mongodb from 'mongodb'; import Following from '../../../models/following'; -export default async (me: mongodb.ObjectID, includeMe: boolean = true) => { +export const getFriendIds = async (me: mongodb.ObjectID, includeMe = true) => { // Fetch relation to other users who the I follows // SELECT followee - const myfollowing = await Following + const followings = await Following .find({ followerId: me }, { @@ -14,7 +14,7 @@ export default async (me: mongodb.ObjectID, includeMe: boolean = true) => { }); // ID list of other users who the I follows - const myfollowingIds = myfollowing.map(follow => follow.followeeId); + const myfollowingIds = followings.map(following => following.followeeId); if (includeMe) { myfollowingIds.push(me); @@ -22,3 +22,26 @@ export default async (me: mongodb.ObjectID, includeMe: boolean = true) => { return myfollowingIds; }; + +export const getFriends = async (me: mongodb.ObjectID, includeMe = true) => { + // Fetch relation to other users who the I follows + const followings = await Following + .find({ + followerId: me + }); + + // ID list of other users who the I follows + const myfollowings = followings.map(following => ({ + id: following.followeeId, + stalk: following.stalk + })); + + if (includeMe) { + myfollowings.push({ + id: me, + stalk: true + }); + } + + return myfollowings; +}; diff --git a/src/server/api/common/read-messaging-message.ts b/src/server/api/common/read-messaging-message.ts index c52f9363b5..28854e186e 100644 --- a/src/server/api/common/read-messaging-message.ts +++ b/src/server/api/common/read-messaging-message.ts @@ -57,6 +57,8 @@ export default ( .count({ recipientId: userId, isRead: false + }, { + limit: 1 }); if (count == 0) { diff --git a/src/server/api/common/read-notification.ts b/src/server/api/common/read-notification.ts index 9bd41519fb..7b9faf4cf4 100644 --- a/src/server/api/common/read-notification.ts +++ b/src/server/api/common/read-notification.ts @@ -43,6 +43,8 @@ export default ( .count({ notifieeId: userId, isRead: false + }, { + limit: 1 }); if (count == 0) { diff --git a/src/server/api/endpoints.ts b/src/server/api/endpoints.ts index e0223c23e0..734b8273f1 100644 --- a/src/server/api/endpoints.ts +++ b/src/server/api/endpoints.ts @@ -234,6 +234,12 @@ const endpoints: Endpoint[] = [ }, { + name: 'i/favorites', + withCredential: true, + kind: 'favorites-read' + }, + + { name: 'othello/match', withCredential: true }, @@ -409,6 +415,27 @@ const endpoints: Endpoint[] = [ }, { + name: 'users/lists/show', + withCredential: true, + kind: 'account-read' + }, + { + name: 'users/lists/create', + withCredential: true, + kind: 'account-write' + }, + { + name: 'users/lists/push', + withCredential: true, + kind: 'account-write' + }, + { + name: 'users/lists/list', + withCredential: true, + kind: 'account-read' + }, + + { name: 'following/create', withCredential: true, limit: { @@ -426,6 +453,24 @@ const endpoints: Endpoint[] = [ }, kind: 'following-write' }, + { + name: 'following/stalk', + withCredential: true, + limit: { + duration: ms('1hour'), + max: 100 + }, + kind: 'following-write' + }, + { + name: 'following/unstalk', + withCredential: true, + limit: { + duration: ms('1hour'), + max: 100 + }, + kind: 'following-write' + }, { name: 'notes' @@ -480,6 +525,14 @@ const endpoints: Endpoint[] = [ } }, { + name: 'notes/user-list-timeline', + withCredential: true, + limit: { + duration: ms('10minutes'), + max: 100 + } + }, + { name: 'notes/mentions', withCredential: true, limit: { diff --git a/src/server/api/endpoints/aggregation/posts.ts b/src/server/api/endpoints/aggregation/posts.ts index cc2a48b53d..d348cadae9 100644 --- a/src/server/api/endpoints/aggregation/posts.ts +++ b/src/server/api/endpoints/aggregation/posts.ts @@ -6,13 +6,10 @@ import Note from '../../../../models/note'; /** * Aggregate notes - * - * @param {any} params - * @return {Promise<any>} */ module.exports = params => new Promise(async (res, rej) => { // Get 'limit' parameter - const [limit = 365, limitErr] = $(params.limit).optional.number().range(1, 365).$; + const [limit = 365, limitErr] = $.num.optional().range(1, 365).get(params.limit); if (limitErr) return rej('invalid limit param'); const datas = await Note diff --git a/src/server/api/endpoints/aggregation/users.ts b/src/server/api/endpoints/aggregation/users.ts index 19776ed297..b116c1454b 100644 --- a/src/server/api/endpoints/aggregation/users.ts +++ b/src/server/api/endpoints/aggregation/users.ts @@ -6,13 +6,10 @@ import User from '../../../../models/user'; /** * Aggregate users - * - * @param {any} params - * @return {Promise<any>} */ module.exports = params => new Promise(async (res, rej) => { // Get 'limit' parameter - const [limit = 365, limitErr] = $(params.limit).optional.number().range(1, 365).$; + const [limit = 365, limitErr] = $.num.optional().range(1, 365).get(params.limit); if (limitErr) return rej('invalid limit param'); const users = await User diff --git a/src/server/api/endpoints/aggregation/users/activity.ts b/src/server/api/endpoints/aggregation/users/activity.ts index 318cce77a5..9109487ac6 100644 --- a/src/server/api/endpoints/aggregation/users/activity.ts +++ b/src/server/api/endpoints/aggregation/users/activity.ts @@ -1,7 +1,7 @@ /** * Module dependencies */ -import $ from 'cafy'; +import $ from 'cafy'; import ID from '../../../../../cafy-id'; import User from '../../../../../models/user'; import Note from '../../../../../models/note'; @@ -9,17 +9,14 @@ import Note from '../../../../../models/note'; /** * Aggregate activity of a user - * - * @param {any} params - * @return {Promise<any>} */ module.exports = (params) => new Promise(async (res, rej) => { // Get 'limit' parameter - const [limit = 365, limitErr] = $(params.limit).optional.number().range(1, 365).$; + const [limit = 365, limitErr] = $.num.optional().range(1, 365).get(params.limit); if (limitErr) return rej('invalid limit param'); // Get 'userId' parameter - const [userId, userIdErr] = $(params.userId).id().$; + const [userId, userIdErr] = $.type(ID).get(params.userId); if (userIdErr) return rej('invalid userId param'); // Lookup user diff --git a/src/server/api/endpoints/aggregation/users/followers.ts b/src/server/api/endpoints/aggregation/users/followers.ts index 7ccb2a3066..dfcaf8462f 100644 --- a/src/server/api/endpoints/aggregation/users/followers.ts +++ b/src/server/api/endpoints/aggregation/users/followers.ts @@ -1,19 +1,16 @@ /** * Module dependencies */ -import $ from 'cafy'; +import $ from 'cafy'; import ID from '../../../../../cafy-id'; import User from '../../../../../models/user'; import FollowedLog from '../../../../../models/followed-log'; /** * Aggregate followers of a user - * - * @param {any} params - * @return {Promise<any>} */ module.exports = (params) => new Promise(async (res, rej) => { // Get 'userId' parameter - const [userId, userIdErr] = $(params.userId).id().$; + const [userId, userIdErr] = $.type(ID).get(params.userId); if (userIdErr) return rej('invalid userId param'); // Lookup user diff --git a/src/server/api/endpoints/aggregation/users/following.ts b/src/server/api/endpoints/aggregation/users/following.ts index 45e246495b..5f826fd71c 100644 --- a/src/server/api/endpoints/aggregation/users/following.ts +++ b/src/server/api/endpoints/aggregation/users/following.ts @@ -1,19 +1,16 @@ /** * Module dependencies */ -import $ from 'cafy'; +import $ from 'cafy'; import ID from '../../../../../cafy-id'; import User from '../../../../../models/user'; import FollowingLog from '../../../../../models/following-log'; /** * Aggregate following of a user - * - * @param {any} params - * @return {Promise<any>} */ module.exports = (params) => new Promise(async (res, rej) => { // Get 'userId' parameter - const [userId, userIdErr] = $(params.userId).id().$; + const [userId, userIdErr] = $.type(ID).get(params.userId); if (userIdErr) return rej('invalid userId param'); // Lookup user diff --git a/src/server/api/endpoints/aggregation/users/post.ts b/src/server/api/endpoints/aggregation/users/post.ts index e6170d83e2..11f9ef14cd 100644 --- a/src/server/api/endpoints/aggregation/users/post.ts +++ b/src/server/api/endpoints/aggregation/users/post.ts @@ -1,19 +1,16 @@ /** * Module dependencies */ -import $ from 'cafy'; +import $ from 'cafy'; import ID from '../../../../../cafy-id'; import User from '../../../../../models/user'; import Note from '../../../../../models/note'; /** * Aggregate note of a user - * - * @param {any} params - * @return {Promise<any>} */ module.exports = (params) => new Promise(async (res, rej) => { // Get 'userId' parameter - const [userId, userIdErr] = $(params.userId).id().$; + const [userId, userIdErr] = $.type(ID).get(params.userId); if (userIdErr) return rej('invalid userId param'); // Lookup user diff --git a/src/server/api/endpoints/aggregation/users/reaction.ts b/src/server/api/endpoints/aggregation/users/reaction.ts index 881c7ea693..2de2840258 100644 --- a/src/server/api/endpoints/aggregation/users/reaction.ts +++ b/src/server/api/endpoints/aggregation/users/reaction.ts @@ -1,7 +1,7 @@ /** * Module dependencies */ -import $ from 'cafy'; +import $ from 'cafy'; import ID from '../../../../../cafy-id'; import User from '../../../../../models/user'; import Reaction from '../../../../../models/note-reaction'; @@ -13,7 +13,7 @@ import Reaction from '../../../../../models/note-reaction'; */ module.exports = (params) => new Promise(async (res, rej) => { // Get 'userId' parameter - const [userId, userIdErr] = $(params.userId).id().$; + const [userId, userIdErr] = $.type(ID).get(params.userId); if (userIdErr) return rej('invalid userId param'); // Lookup user diff --git a/src/server/api/endpoints/app/create.ts b/src/server/api/endpoints/app/create.ts index 4a55d33f2d..553bd2381a 100644 --- a/src/server/api/endpoints/app/create.ts +++ b/src/server/api/endpoints/app/create.ts @@ -67,24 +67,24 @@ import App, { isValidNameId, pack } from '../../../../models/app'; */ module.exports = async (params, user) => new Promise(async (res, rej) => { // Get 'nameId' parameter - const [nameId, nameIdErr] = $(params.nameId).string().pipe(isValidNameId).$; + const [nameId, nameIdErr] = $.str.pipe(isValidNameId).get(params.nameId); if (nameIdErr) return rej('invalid nameId param'); // Get 'name' parameter - const [name, nameErr] = $(params.name).string().$; + const [name, nameErr] = $.str.get(params.name); if (nameErr) return rej('invalid name param'); // Get 'description' parameter - const [description, descriptionErr] = $(params.description).string().$; + const [description, descriptionErr] = $.str.get(params.description); if (descriptionErr) return rej('invalid description param'); // Get 'permission' parameter - const [permission, permissionErr] = $(params.permission).array('string').unique().$; + const [permission, permissionErr] = $.arr($.str).unique().get(params.permission); if (permissionErr) return rej('invalid permission param'); // Get 'callbackUrl' parameter // TODO: Check it is valid url - const [callbackUrl = null, callbackUrlErr] = $(params.callbackUrl).optional.nullable.string().$; + const [callbackUrl = null, callbackUrlErr] = $.str.optional().nullable().get(params.callbackUrl); if (callbackUrlErr) return rej('invalid callbackUrl param'); // Generate secret diff --git a/src/server/api/endpoints/app/name_id/available.ts b/src/server/api/endpoints/app/name_id/available.ts index ec2d692412..135bb7d2b4 100644 --- a/src/server/api/endpoints/app/name_id/available.ts +++ b/src/server/api/endpoints/app/name_id/available.ts @@ -42,7 +42,7 @@ import { isValidNameId } from '../../../../../models/app'; */ module.exports = async (params) => new Promise(async (res, rej) => { // Get 'nameId' parameter - const [nameId, nameIdErr] = $(params.nameId).string().pipe(isValidNameId).$; + const [nameId, nameIdErr] = $.str.pipe(isValidNameId).get(params.nameId); if (nameIdErr) return rej('invalid nameId param'); // Get exist diff --git a/src/server/api/endpoints/app/show.ts b/src/server/api/endpoints/app/show.ts index 99a2093b68..8d742ab182 100644 --- a/src/server/api/endpoints/app/show.ts +++ b/src/server/api/endpoints/app/show.ts @@ -1,7 +1,7 @@ /** * Module dependencies */ -import $ from 'cafy'; +import $ from 'cafy'; import ID from '../../../../cafy-id'; import App, { pack } from '../../../../models/app'; /** @@ -41,11 +41,11 @@ module.exports = (params, user, app) => new Promise(async (res, rej) => { const isSecure = user != null && app == null; // Get 'appId' parameter - const [appId, appIdErr] = $(params.appId).optional.id().$; + const [appId, appIdErr] = $.type(ID).optional().get(params.appId); if (appIdErr) return rej('invalid appId param'); // Get 'nameId' parameter - const [nameId, nameIdErr] = $(params.nameId).optional.string().$; + const [nameId, nameIdErr] = $.str.optional().get(params.nameId); if (nameIdErr) return rej('invalid nameId param'); if (appId === undefined && nameId === undefined) { diff --git a/src/server/api/endpoints/auth/accept.ts b/src/server/api/endpoints/auth/accept.ts index b6297d663d..695fbb0803 100644 --- a/src/server/api/endpoints/auth/accept.ts +++ b/src/server/api/endpoints/auth/accept.ts @@ -40,7 +40,7 @@ import AccessToken from '../../../../models/access-token'; */ module.exports = (params, user) => new Promise(async (res, rej) => { // Get 'token' parameter - const [token, tokenErr] = $(params.token).string().$; + const [token, tokenErr] = $.str.get(params.token); if (tokenErr) return rej('invalid token param'); // Fetch token diff --git a/src/server/api/endpoints/auth/session/generate.ts b/src/server/api/endpoints/auth/session/generate.ts index 7c475dbe26..d649a8d902 100644 --- a/src/server/api/endpoints/auth/session/generate.ts +++ b/src/server/api/endpoints/auth/session/generate.ts @@ -46,7 +46,7 @@ import config from '../../../../../config'; */ module.exports = (params) => new Promise(async (res, rej) => { // Get 'appSecret' parameter - const [appSecret, appSecretErr] = $(params.appSecret).string().$; + const [appSecret, appSecretErr] = $.str.get(params.appSecret); if (appSecretErr) return rej('invalid appSecret param'); // Lookup app diff --git a/src/server/api/endpoints/auth/session/show.ts b/src/server/api/endpoints/auth/session/show.ts index f7f0b087b7..434cc264a0 100644 --- a/src/server/api/endpoints/auth/session/show.ts +++ b/src/server/api/endpoints/auth/session/show.ts @@ -53,7 +53,7 @@ import AuthSess, { pack } from '../../../../../models/auth-session'; */ module.exports = (params, user) => new Promise(async (res, rej) => { // Get 'token' parameter - const [token, tokenErr] = $(params.token).string().$; + const [token, tokenErr] = $.str.get(params.token); if (tokenErr) return rej('invalid token param'); // Lookup session diff --git a/src/server/api/endpoints/auth/session/userkey.ts b/src/server/api/endpoints/auth/session/userkey.ts index ddb67cb451..3026b477f1 100644 --- a/src/server/api/endpoints/auth/session/userkey.ts +++ b/src/server/api/endpoints/auth/session/userkey.ts @@ -51,7 +51,7 @@ import { pack } from '../../../../../models/user'; */ module.exports = (params) => new Promise(async (res, rej) => { // Get 'appSecret' parameter - const [appSecret, appSecretErr] = $(params.appSecret).string().$; + const [appSecret, appSecretErr] = $.str.get(params.appSecret); if (appSecretErr) return rej('invalid appSecret param'); // Lookup app @@ -64,7 +64,7 @@ module.exports = (params) => new Promise(async (res, rej) => { } // Get 'token' parameter - const [token, tokenErr] = $(params.token).string().$; + const [token, tokenErr] = $.str.get(params.token); if (tokenErr) return rej('invalid token param'); // Fetch token diff --git a/src/server/api/endpoints/channels.ts b/src/server/api/endpoints/channels.ts index 582e6ba43b..ceef4b9cb9 100644 --- a/src/server/api/endpoints/channels.ts +++ b/src/server/api/endpoints/channels.ts @@ -1,7 +1,7 @@ /** * Module dependencies */ -import $ from 'cafy'; +import $ from 'cafy'; import ID from '../../../cafy-id'; import Channel, { pack } from '../../../models/channel'; /** @@ -13,15 +13,15 @@ import Channel, { pack } from '../../../models/channel'; */ module.exports = (params, me) => new Promise(async (res, rej) => { // Get 'limit' parameter - const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$; + const [limit = 10, limitErr] = $.num.optional().range(1, 100).get(params.limit); if (limitErr) return rej('invalid limit param'); // Get 'sinceId' parameter - const [sinceId, sinceIdErr] = $(params.sinceId).optional.id().$; + const [sinceId, sinceIdErr] = $.type(ID).optional().get(params.sinceId); if (sinceIdErr) return rej('invalid sinceId param'); // Get 'untilId' parameter - const [untilId, untilIdErr] = $(params.untilId).optional.id().$; + const [untilId, untilIdErr] = $.type(ID).optional().get(params.untilId); if (untilIdErr) return rej('invalid untilId param'); // Check if both of sinceId and untilId is specified diff --git a/src/server/api/endpoints/channels/create.ts b/src/server/api/endpoints/channels/create.ts index 0f0f558c8a..0e3c9dc5ac 100644 --- a/src/server/api/endpoints/channels/create.ts +++ b/src/server/api/endpoints/channels/create.ts @@ -8,14 +8,10 @@ import { pack } from '../../../../models/channel'; /** * Create a channel - * - * @param {any} params - * @param {any} user - * @return {Promise<any>} */ module.exports = async (params, user) => new Promise(async (res, rej) => { // Get 'title' parameter - const [title, titleErr] = $(params.title).string().range(1, 100).$; + const [title, titleErr] = $.str.range(1, 100).get(params.title); if (titleErr) return rej('invalid title param'); // Create a channel diff --git a/src/server/api/endpoints/channels/notes.ts b/src/server/api/endpoints/channels/notes.ts index d636aa0d10..463152e74a 100644 --- a/src/server/api/endpoints/channels/notes.ts +++ b/src/server/api/endpoints/channels/notes.ts @@ -1,28 +1,24 @@ /** * Module dependencies */ -import $ from 'cafy'; +import $ from 'cafy'; import ID from '../../../../cafy-id'; import { default as Channel, IChannel } from '../../../../models/channel'; import Note, { pack } from '../../../../models/note'; /** * Show a notes of a channel - * - * @param {any} params - * @param {any} user - * @return {Promise<any>} */ module.exports = (params, user) => new Promise(async (res, rej) => { // Get 'limit' parameter - const [limit = 1000, limitErr] = $(params.limit).optional.number().range(1, 1000).$; + const [limit = 1000, limitErr] = $.num.optional().range(1, 1000).get(params.limit); if (limitErr) return rej('invalid limit param'); // Get 'sinceId' parameter - const [sinceId, sinceIdErr] = $(params.sinceId).optional.id().$; + const [sinceId, sinceIdErr] = $.type(ID).optional().get(params.sinceId); if (sinceIdErr) return rej('invalid sinceId param'); // Get 'untilId' parameter - const [untilId, untilIdErr] = $(params.untilId).optional.id().$; + const [untilId, untilIdErr] = $.type(ID).optional().get(params.untilId); if (untilIdErr) return rej('invalid untilId param'); // Check if both of sinceId and untilId is specified @@ -31,7 +27,7 @@ module.exports = (params, user) => new Promise(async (res, rej) => { } // Get 'channelId' parameter - const [channelId, channelIdErr] = $(params.channelId).id().$; + const [channelId, channelIdErr] = $.type(ID).get(params.channelId); if (channelIdErr) return rej('invalid channelId param'); // Fetch channel diff --git a/src/server/api/endpoints/channels/show.ts b/src/server/api/endpoints/channels/show.ts index 3ce9ce4745..1bba63d490 100644 --- a/src/server/api/endpoints/channels/show.ts +++ b/src/server/api/endpoints/channels/show.ts @@ -1,19 +1,15 @@ /** * Module dependencies */ -import $ from 'cafy'; +import $ from 'cafy'; import ID from '../../../../cafy-id'; import Channel, { IChannel, pack } from '../../../../models/channel'; /** * Show a channel - * - * @param {any} params - * @param {any} user - * @return {Promise<any>} */ module.exports = (params, user) => new Promise(async (res, rej) => { // Get 'channelId' parameter - const [channelId, channelIdErr] = $(params.channelId).id().$; + const [channelId, channelIdErr] = $.type(ID).get(params.channelId); if (channelIdErr) return rej('invalid channelId param'); // Fetch channel diff --git a/src/server/api/endpoints/channels/unwatch.ts b/src/server/api/endpoints/channels/unwatch.ts index 8220b90b68..f7dddff461 100644 --- a/src/server/api/endpoints/channels/unwatch.ts +++ b/src/server/api/endpoints/channels/unwatch.ts @@ -1,20 +1,16 @@ /** * Module dependencies */ -import $ from 'cafy'; +import $ from 'cafy'; import ID from '../../../../cafy-id'; import Channel from '../../../../models/channel'; import Watching from '../../../../models/channel-watching'; /** * Unwatch a channel - * - * @param {any} params - * @param {any} user - * @return {Promise<any>} */ module.exports = (params, user) => new Promise(async (res, rej) => { // Get 'channelId' parameter - const [channelId, channelIdErr] = $(params.channelId).id().$; + const [channelId, channelIdErr] = $.type(ID).get(params.channelId); if (channelIdErr) return rej('invalid channelId param'); //#region Fetch channel diff --git a/src/server/api/endpoints/channels/watch.ts b/src/server/api/endpoints/channels/watch.ts index 6906282a54..34243ff68b 100644 --- a/src/server/api/endpoints/channels/watch.ts +++ b/src/server/api/endpoints/channels/watch.ts @@ -1,20 +1,16 @@ /** * Module dependencies */ -import $ from 'cafy'; +import $ from 'cafy'; import ID from '../../../../cafy-id'; import Channel from '../../../../models/channel'; import Watching from '../../../../models/channel-watching'; /** * Watch a channel - * - * @param {any} params - * @param {any} user - * @return {Promise<any>} */ module.exports = (params, user) => new Promise(async (res, rej) => { // Get 'channelId' parameter - const [channelId, channelIdErr] = $(params.channelId).id().$; + const [channelId, channelIdErr] = $.type(ID).get(params.channelId); if (channelIdErr) return rej('invalid channelId param'); //#region Fetch channel diff --git a/src/server/api/endpoints/drive/files.ts b/src/server/api/endpoints/drive/files.ts index 63d69d145a..ab4b18cef4 100644 --- a/src/server/api/endpoints/drive/files.ts +++ b/src/server/api/endpoints/drive/files.ts @@ -1,28 +1,23 @@ /** * Module dependencies */ -import $ from 'cafy'; +import $ from 'cafy'; import ID from '../../../../cafy-id'; import DriveFile, { pack } from '../../../../models/drive-file'; /** * Get drive files - * - * @param {any} params - * @param {any} user - * @param {any} app - * @return {Promise<any>} */ module.exports = async (params, user, app) => { // Get 'limit' parameter - const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$; + const [limit = 10, limitErr] = $.num.optional().range(1, 100).get(params.limit); if (limitErr) throw 'invalid limit param'; // Get 'sinceId' parameter - const [sinceId, sinceIdErr] = $(params.sinceId).optional.id().$; + const [sinceId, sinceIdErr] = $.type(ID).optional().get(params.sinceId); if (sinceIdErr) throw 'invalid sinceId param'; // Get 'untilId' parameter - const [untilId, untilIdErr] = $(params.untilId).optional.id().$; + const [untilId, untilIdErr] = $.type(ID).optional().get(params.untilId); if (untilIdErr) throw 'invalid untilId param'; // Check if both of sinceId and untilId is specified @@ -31,11 +26,11 @@ module.exports = async (params, user, app) => { } // Get 'folderId' parameter - const [folderId = null, folderIdErr] = $(params.folderId).optional.nullable.id().$; + const [folderId = null, folderIdErr] = $.type(ID).optional().nullable().get(params.folderId); if (folderIdErr) throw 'invalid folderId param'; // Get 'type' parameter - const [type, typeErr] = $(params.type).optional.string().match(/^[a-zA-Z\/\-\*]+$/).$; + const [type, typeErr] = $.str.optional().match(/^[a-zA-Z\/\-\*]+$/).get(params.type); if (typeErr) throw 'invalid type param'; // Construct query diff --git a/src/server/api/endpoints/drive/files/create.ts b/src/server/api/endpoints/drive/files/create.ts index df0bd0a0d3..e9348e4e2f 100644 --- a/src/server/api/endpoints/drive/files/create.ts +++ b/src/server/api/endpoints/drive/files/create.ts @@ -1,17 +1,12 @@ /** * Module dependencies */ -import $ from 'cafy'; +import $ from 'cafy'; import ID from '../../../../../cafy-id'; import { validateFileName, pack } from '../../../../../models/drive-file'; import create from '../../../../../services/drive/add-file'; /** * Create a file - * - * @param {any} file - * @param {any} params - * @param {any} user - * @return {Promise<any>} */ module.exports = async (file, params, user): Promise<any> => { if (file == null) { @@ -34,7 +29,7 @@ module.exports = async (file, params, user): Promise<any> => { } // Get 'folderId' parameter - const [folderId = null, folderIdErr] = $(params.folderId).optional.nullable.id().$; + const [folderId = null, folderIdErr] = $.type(ID).optional().nullable().get(params.folderId); if (folderIdErr) throw 'invalid folderId param'; try { diff --git a/src/server/api/endpoints/drive/files/find.ts b/src/server/api/endpoints/drive/files/find.ts index 0ab6e5d3e3..98165990fe 100644 --- a/src/server/api/endpoints/drive/files/find.ts +++ b/src/server/api/endpoints/drive/files/find.ts @@ -1,23 +1,19 @@ /** * Module dependencies */ -import $ from 'cafy'; +import $ from 'cafy'; import ID from '../../../../../cafy-id'; import DriveFile, { pack } from '../../../../../models/drive-file'; /** * Find a file(s) - * - * @param {any} params - * @param {any} user - * @return {Promise<any>} */ module.exports = (params, user) => new Promise(async (res, rej) => { // Get 'name' parameter - const [name, nameErr] = $(params.name).string().$; + const [name, nameErr] = $.str.get(params.name); if (nameErr) return rej('invalid name param'); // Get 'folderId' parameter - const [folderId = null, folderIdErr] = $(params.folderId).optional.nullable.id().$; + const [folderId = null, folderIdErr] = $.type(ID).optional().nullable().get(params.folderId); if (folderIdErr) return rej('invalid folderId param'); // Issue query diff --git a/src/server/api/endpoints/drive/files/show.ts b/src/server/api/endpoints/drive/files/show.ts index 3398f24541..c7efda7ab0 100644 --- a/src/server/api/endpoints/drive/files/show.ts +++ b/src/server/api/endpoints/drive/files/show.ts @@ -1,19 +1,15 @@ /** * Module dependencies */ -import $ from 'cafy'; +import $ from 'cafy'; import ID from '../../../../../cafy-id'; import DriveFile, { pack } from '../../../../../models/drive-file'; /** * Show a file - * - * @param {any} params - * @param {any} user - * @return {Promise<any>} */ module.exports = async (params, user) => { // Get 'fileId' parameter - const [fileId, fileIdErr] = $(params.fileId).id().$; + const [fileId, fileIdErr] = $.type(ID).get(params.fileId); if (fileIdErr) throw 'invalid fileId param'; // Fetch file diff --git a/src/server/api/endpoints/drive/files/update.ts b/src/server/api/endpoints/drive/files/update.ts index c783ad8b3b..12fa8e025d 100644 --- a/src/server/api/endpoints/drive/files/update.ts +++ b/src/server/api/endpoints/drive/files/update.ts @@ -1,21 +1,17 @@ /** * Module dependencies */ -import $ from 'cafy'; +import $ from 'cafy'; import ID from '../../../../../cafy-id'; import DriveFolder from '../../../../../models/drive-folder'; import DriveFile, { validateFileName, pack } from '../../../../../models/drive-file'; import { publishDriveStream } from '../../../../../publishers/stream'; /** * Update a file - * - * @param {any} params - * @param {any} user - * @return {Promise<any>} */ module.exports = (params, user) => new Promise(async (res, rej) => { // Get 'fileId' parameter - const [fileId, fileIdErr] = $(params.fileId).id().$; + const [fileId, fileIdErr] = $.type(ID).get(params.fileId); if (fileIdErr) return rej('invalid fileId param'); // Fetch file @@ -30,12 +26,12 @@ module.exports = (params, user) => new Promise(async (res, rej) => { } // Get 'name' parameter - const [name, nameErr] = $(params.name).optional.string().pipe(validateFileName).$; + const [name, nameErr] = $.str.optional().pipe(validateFileName).get(params.name); if (nameErr) return rej('invalid name param'); if (name) file.filename = name; // Get 'folderId' parameter - const [folderId, folderIdErr] = $(params.folderId).optional.nullable.id().$; + const [folderId, folderIdErr] = $.type(ID).optional().nullable().get(params.folderId); if (folderIdErr) return rej('invalid folderId param'); if (folderId !== undefined) { diff --git a/src/server/api/endpoints/drive/files/upload_from_url.ts b/src/server/api/endpoints/drive/files/upload_from_url.ts index 8a426c0efc..c012f0d3c9 100644 --- a/src/server/api/endpoints/drive/files/upload_from_url.ts +++ b/src/server/api/endpoints/drive/files/upload_from_url.ts @@ -1,7 +1,7 @@ /** * Module dependencies */ -import $ from 'cafy'; +import $ from 'cafy'; import ID from '../../../../../cafy-id'; import { pack } from '../../../../../models/drive-file'; import uploadFromUrl from '../../../../../services/drive/upload-from-url'; @@ -11,11 +11,11 @@ import uploadFromUrl from '../../../../../services/drive/upload-from-url'; module.exports = async (params, user): Promise<any> => { // Get 'url' parameter // TODO: Validate this url - const [url, urlErr] = $(params.url).string().$; + const [url, urlErr] = $.str.get(params.url); if (urlErr) throw 'invalid url param'; // Get 'folderId' parameter - const [folderId = null, folderIdErr] = $(params.folderId).optional.nullable.id().$; + const [folderId = null, folderIdErr] = $.type(ID).optional().nullable().get(params.folderId); if (folderIdErr) throw 'invalid folderId param'; return pack(await uploadFromUrl(url, user, folderId)); diff --git a/src/server/api/endpoints/drive/folders.ts b/src/server/api/endpoints/drive/folders.ts index 489e47912e..bc6c50eb99 100644 --- a/src/server/api/endpoints/drive/folders.ts +++ b/src/server/api/endpoints/drive/folders.ts @@ -1,28 +1,23 @@ /** * Module dependencies */ -import $ from 'cafy'; +import $ from 'cafy'; import ID from '../../../../cafy-id'; import DriveFolder, { pack } from '../../../../models/drive-folder'; /** * Get drive folders - * - * @param {any} params - * @param {any} user - * @param {any} app - * @return {Promise<any>} */ module.exports = (params, user, app) => new Promise(async (res, rej) => { // Get 'limit' parameter - const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$; + const [limit = 10, limitErr] = $.num.optional().range(1, 100).get(params.limit); if (limitErr) return rej('invalid limit param'); // Get 'sinceId' parameter - const [sinceId, sinceIdErr] = $(params.sinceId).optional.id().$; + const [sinceId, sinceIdErr] = $.type(ID).optional().get(params.sinceId); if (sinceIdErr) return rej('invalid sinceId param'); // Get 'untilId' parameter - const [untilId, untilIdErr] = $(params.untilId).optional.id().$; + const [untilId, untilIdErr] = $.type(ID).optional().get(params.untilId); if (untilIdErr) return rej('invalid untilId param'); // Check if both of sinceId and untilId is specified @@ -31,7 +26,7 @@ module.exports = (params, user, app) => new Promise(async (res, rej) => { } // Get 'folderId' parameter - const [folderId = null, folderIdErr] = $(params.folderId).optional.nullable.id().$; + const [folderId = null, folderIdErr] = $.type(ID).optional().nullable().get(params.folderId); if (folderIdErr) return rej('invalid folderId param'); // Construct query diff --git a/src/server/api/endpoints/drive/folders/create.ts b/src/server/api/endpoints/drive/folders/create.ts index f34d0019d7..62e3b6f6e8 100644 --- a/src/server/api/endpoints/drive/folders/create.ts +++ b/src/server/api/endpoints/drive/folders/create.ts @@ -1,24 +1,20 @@ /** * Module dependencies */ -import $ from 'cafy'; +import $ from 'cafy'; import ID from '../../../../../cafy-id'; import DriveFolder, { isValidFolderName, pack } from '../../../../../models/drive-folder'; import { publishDriveStream } from '../../../../../publishers/stream'; /** * Create drive folder - * - * @param {any} params - * @param {any} user - * @return {Promise<any>} */ module.exports = (params, user) => new Promise(async (res, rej) => { // Get 'name' parameter - const [name = '無題のフォルダー', nameErr] = $(params.name).optional.string().pipe(isValidFolderName).$; + const [name = '無題のフォルダー', nameErr] = $.str.optional().pipe(isValidFolderName).get(params.name); if (nameErr) return rej('invalid name param'); // Get 'parentId' parameter - const [parentId = null, parentIdErr] = $(params.parentId).optional.nullable.id().$; + const [parentId = null, parentIdErr] = $.type(ID).optional().nullable().get(params.parentId); if (parentIdErr) return rej('invalid parentId param'); // If the parent folder is specified diff --git a/src/server/api/endpoints/drive/folders/find.ts b/src/server/api/endpoints/drive/folders/find.ts index 04dc38f87f..9703d9e99d 100644 --- a/src/server/api/endpoints/drive/folders/find.ts +++ b/src/server/api/endpoints/drive/folders/find.ts @@ -1,23 +1,19 @@ /** * Module dependencies */ -import $ from 'cafy'; +import $ from 'cafy'; import ID from '../../../../../cafy-id'; import DriveFolder, { pack } from '../../../../../models/drive-folder'; /** * Find a folder(s) - * - * @param {any} params - * @param {any} user - * @return {Promise<any>} */ module.exports = (params, user) => new Promise(async (res, rej) => { // Get 'name' parameter - const [name, nameErr] = $(params.name).string().$; + const [name, nameErr] = $.str.get(params.name); if (nameErr) return rej('invalid name param'); // Get 'parentId' parameter - const [parentId = null, parentIdErr] = $(params.parentId).optional.nullable.id().$; + const [parentId = null, parentIdErr] = $.type(ID).optional().nullable().get(params.parentId); if (parentIdErr) return rej('invalid parentId param'); // Issue query diff --git a/src/server/api/endpoints/drive/folders/show.ts b/src/server/api/endpoints/drive/folders/show.ts index b432f5a50a..44f1889001 100644 --- a/src/server/api/endpoints/drive/folders/show.ts +++ b/src/server/api/endpoints/drive/folders/show.ts @@ -1,19 +1,15 @@ /** * Module dependencies */ -import $ from 'cafy'; +import $ from 'cafy'; import ID from '../../../../../cafy-id'; import DriveFolder, { pack } from '../../../../../models/drive-folder'; /** * Show a folder - * - * @param {any} params - * @param {any} user - * @return {Promise<any>} */ module.exports = (params, user) => new Promise(async (res, rej) => { // Get 'folderId' parameter - const [folderId, folderIdErr] = $(params.folderId).id().$; + const [folderId, folderIdErr] = $.type(ID).get(params.folderId); if (folderIdErr) return rej('invalid folderId param'); // Get folder diff --git a/src/server/api/endpoints/drive/folders/update.ts b/src/server/api/endpoints/drive/folders/update.ts index dd7e8f5c86..e24d8a14cd 100644 --- a/src/server/api/endpoints/drive/folders/update.ts +++ b/src/server/api/endpoints/drive/folders/update.ts @@ -1,20 +1,16 @@ /** * Module dependencies */ -import $ from 'cafy'; +import $ from 'cafy'; import ID from '../../../../../cafy-id'; import DriveFolder, { isValidFolderName, pack } from '../../../../../models/drive-folder'; import { publishDriveStream } from '../../../../../publishers/stream'; /** * Update a folder - * - * @param {any} params - * @param {any} user - * @return {Promise<any>} */ module.exports = (params, user) => new Promise(async (res, rej) => { // Get 'folderId' parameter - const [folderId, folderIdErr] = $(params.folderId).id().$; + const [folderId, folderIdErr] = $.type(ID).get(params.folderId); if (folderIdErr) return rej('invalid folderId param'); // Fetch folder @@ -29,12 +25,12 @@ module.exports = (params, user) => new Promise(async (res, rej) => { } // Get 'name' parameter - const [name, nameErr] = $(params.name).optional.string().pipe(isValidFolderName).$; + const [name, nameErr] = $.str.optional().pipe(isValidFolderName).get(params.name); if (nameErr) return rej('invalid name param'); if (name) folder.name = name; // Get 'parentId' parameter - const [parentId, parentIdErr] = $(params.parentId).optional.nullable.id().$; + const [parentId, parentIdErr] = $.type(ID).optional().nullable().get(params.parentId); if (parentIdErr) return rej('invalid parentId param'); if (parentId !== undefined) { if (parentId === null) { diff --git a/src/server/api/endpoints/drive/stream.ts b/src/server/api/endpoints/drive/stream.ts index 02313aa37b..8cb3a99b42 100644 --- a/src/server/api/endpoints/drive/stream.ts +++ b/src/server/api/endpoints/drive/stream.ts @@ -1,27 +1,23 @@ /** * Module dependencies */ -import $ from 'cafy'; +import $ from 'cafy'; import ID from '../../../../cafy-id'; import DriveFile, { pack } from '../../../../models/drive-file'; /** * Get drive stream - * - * @param {any} params - * @param {any} user - * @return {Promise<any>} */ module.exports = (params, user) => new Promise(async (res, rej) => { // Get 'limit' parameter - const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$; + const [limit = 10, limitErr] = $.num.optional().range(1, 100).get(params.limit); if (limitErr) return rej('invalid limit param'); // Get 'sinceId' parameter - const [sinceId, sinceIdErr] = $(params.sinceId).optional.id().$; + const [sinceId, sinceIdErr] = $.type(ID).optional().get(params.sinceId); if (sinceIdErr) return rej('invalid sinceId param'); // Get 'untilId' parameter - const [untilId, untilIdErr] = $(params.untilId).optional.id().$; + const [untilId, untilIdErr] = $.type(ID).optional().get(params.untilId); if (untilIdErr) return rej('invalid untilId param'); // Check if both of sinceId and untilId is specified @@ -30,7 +26,7 @@ module.exports = (params, user) => new Promise(async (res, rej) => { } // Get 'type' parameter - const [type, typeErr] = $(params.type).optional.string().match(/^[a-zA-Z\/\-\*]+$/).$; + const [type, typeErr] = $.str.optional().match(/^[a-zA-Z\/\-\*]+$/).get(params.type); if (typeErr) return rej('invalid type param'); // Construct query diff --git a/src/server/api/endpoints/following/create.ts b/src/server/api/endpoints/following/create.ts index 27e5eb31db..766a8c03d0 100644 --- a/src/server/api/endpoints/following/create.ts +++ b/src/server/api/endpoints/following/create.ts @@ -1,7 +1,7 @@ /** * Module dependencies */ -import $ from 'cafy'; +import $ from 'cafy'; import ID from '../../../../cafy-id'; import User from '../../../../models/user'; import Following from '../../../../models/following'; import create from '../../../../services/following/create'; @@ -13,7 +13,7 @@ module.exports = (params, user) => new Promise(async (res, rej) => { const follower = user; // Get 'userId' parameter - const [userId, userIdErr] = $(params.userId).id().$; + const [userId, userIdErr] = $.type(ID).get(params.userId); if (userIdErr) return rej('invalid userId param'); // 自分自身 diff --git a/src/server/api/endpoints/following/delete.ts b/src/server/api/endpoints/following/delete.ts index ca0703ca22..396b19a6f6 100644 --- a/src/server/api/endpoints/following/delete.ts +++ b/src/server/api/endpoints/following/delete.ts @@ -1,7 +1,7 @@ /** * Module dependencies */ -import $ from 'cafy'; +import $ from 'cafy'; import ID from '../../../../cafy-id'; import User from '../../../../models/user'; import Following from '../../../../models/following'; import deleteFollowing from '../../../../services/following/delete'; @@ -13,7 +13,7 @@ module.exports = (params, user) => new Promise(async (res, rej) => { const follower = user; // Get 'userId' parameter - const [userId, userIdErr] = $(params.userId).id().$; + const [userId, userIdErr] = $.type(ID).get(params.userId); if (userIdErr) return rej('invalid userId param'); // Check if the followee is yourself diff --git a/src/server/api/endpoints/following/stalk.ts b/src/server/api/endpoints/following/stalk.ts new file mode 100644 index 0000000000..f0bc8cbdfc --- /dev/null +++ b/src/server/api/endpoints/following/stalk.ts @@ -0,0 +1,35 @@ +import $ from 'cafy'; import ID from '../../../../cafy-id'; +import Following from '../../../../models/following'; + +/** + * Stalk a user + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + const follower = user; + + // Get 'userId' parameter + const [userId, userIdErr] = $.type(ID).get(params.userId); + if (userIdErr) return rej('invalid userId param'); + + // Fetch following + const following = await Following.findOne({ + followerId: follower._id, + followeeId: userId + }); + + if (following === null) { + return rej('following not found'); + } + + // Stalk + await Following.update({ _id: following._id }, { + $set: { + stalk: true + } + }); + + // Send response + res(); + + // TODO: イベント +}); diff --git a/src/server/api/endpoints/following/unstalk.ts b/src/server/api/endpoints/following/unstalk.ts new file mode 100644 index 0000000000..0d0a018c34 --- /dev/null +++ b/src/server/api/endpoints/following/unstalk.ts @@ -0,0 +1,35 @@ +import $ from 'cafy'; import ID from '../../../../cafy-id'; +import Following from '../../../../models/following'; + +/** + * Unstalk a user + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + const follower = user; + + // Get 'userId' parameter + const [userId, userIdErr] = $.type(ID).get(params.userId); + if (userIdErr) return rej('invalid userId param'); + + // Fetch following + const following = await Following.findOne({ + followerId: follower._id, + followeeId: userId + }); + + if (following === null) { + return rej('following not found'); + } + + // Stalk + await Following.update({ _id: following._id }, { + $set: { + stalk: false + } + }); + + // Send response + res(); + + // TODO: イベント +}); diff --git a/src/server/api/endpoints/i/2fa/done.ts b/src/server/api/endpoints/i/2fa/done.ts index 3e824feffd..1a2706aa84 100644 --- a/src/server/api/endpoints/i/2fa/done.ts +++ b/src/server/api/endpoints/i/2fa/done.ts @@ -7,7 +7,7 @@ import User from '../../../../../models/user'; module.exports = async (params, user) => new Promise(async (res, rej) => { // Get 'token' parameter - const [token, tokenErr] = $(params.token).string().$; + const [token, tokenErr] = $.str.get(params.token); if (tokenErr) return rej('invalid token param'); const _token = token.replace(/\s/g, ''); diff --git a/src/server/api/endpoints/i/2fa/register.ts b/src/server/api/endpoints/i/2fa/register.ts index bed64a2545..d314e1a280 100644 --- a/src/server/api/endpoints/i/2fa/register.ts +++ b/src/server/api/endpoints/i/2fa/register.ts @@ -10,7 +10,7 @@ import config from '../../../../../config'; module.exports = async (params, user) => new Promise(async (res, rej) => { // Get 'password' parameter - const [password, passwordErr] = $(params.password).string().$; + const [password, passwordErr] = $.str.get(params.password); if (passwordErr) return rej('invalid password param'); // Compare password diff --git a/src/server/api/endpoints/i/2fa/unregister.ts b/src/server/api/endpoints/i/2fa/unregister.ts index f9d7a25f53..336a3564ab 100644 --- a/src/server/api/endpoints/i/2fa/unregister.ts +++ b/src/server/api/endpoints/i/2fa/unregister.ts @@ -7,7 +7,7 @@ import User from '../../../../../models/user'; module.exports = async (params, user) => new Promise(async (res, rej) => { // Get 'password' parameter - const [password, passwordErr] = $(params.password).string().$; + const [password, passwordErr] = $.str.get(params.password); if (passwordErr) return rej('invalid password param'); // Compare password diff --git a/src/server/api/endpoints/i/authorized_apps.ts b/src/server/api/endpoints/i/authorized_apps.ts index 82fd2d2516..d15bd67bf2 100644 --- a/src/server/api/endpoints/i/authorized_apps.ts +++ b/src/server/api/endpoints/i/authorized_apps.ts @@ -7,22 +7,18 @@ import { pack } from '../../../../models/app'; /** * Get authorized apps of my account - * - * @param {any} params - * @param {any} user - * @return {Promise<any>} */ module.exports = (params, user) => new Promise(async (res, rej) => { // Get 'limit' parameter - const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$; + const [limit = 10, limitErr] = $.num.optional().range(1, 100).get(params.limit); if (limitErr) return rej('invalid limit param'); // Get 'offset' parameter - const [offset = 0, offsetErr] = $(params.offset).optional.number().min(0).$; + const [offset = 0, offsetErr] = $.num.optional().min(0).get(params.offset); if (offsetErr) return rej('invalid offset param'); // Get 'sort' parameter - const [sort = 'desc', sortError] = $(params.sort).optional.string().or('desc asc').$; + const [sort = 'desc', sortError] = $.str.optional().or('desc asc').get(params.sort); if (sortError) return rej('invalid sort param'); // Get tokens diff --git a/src/server/api/endpoints/i/change_password.ts b/src/server/api/endpoints/i/change_password.ts index 57415083f1..a1a1a43406 100644 --- a/src/server/api/endpoints/i/change_password.ts +++ b/src/server/api/endpoints/i/change_password.ts @@ -7,18 +7,14 @@ import User from '../../../../models/user'; /** * Change password - * - * @param {any} params - * @param {any} user - * @return {Promise<any>} */ module.exports = async (params, user) => new Promise(async (res, rej) => { // Get 'currentPasword' parameter - const [currentPassword, currentPasswordErr] = $(params.currentPasword).string().$; + const [currentPassword, currentPasswordErr] = $.str.get(params.currentPasword); if (currentPasswordErr) return rej('invalid currentPasword param'); // Get 'newPassword' parameter - const [newPassword, newPasswordErr] = $(params.newPassword).string().$; + const [newPassword, newPasswordErr] = $.str.get(params.newPassword); if (newPasswordErr) return rej('invalid newPassword param'); // Compare password diff --git a/src/server/api/endpoints/i/favorites.ts b/src/server/api/endpoints/i/favorites.ts index b40f2b3887..23517baaff 100644 --- a/src/server/api/endpoints/i/favorites.ts +++ b/src/server/api/endpoints/i/favorites.ts @@ -1,44 +1,53 @@ /** * Module dependencies */ -import $ from 'cafy'; -import Favorite from '../../../../models/favorite'; -import { pack } from '../../../../models/note'; +import $ from 'cafy'; import ID from '../../../../cafy-id'; +import Favorite, { pack } from '../../../../models/favorite'; /** - * Get followers of a user - * - * @param {any} params - * @param {any} user - * @return {Promise<any>} + * Get favorited notes */ module.exports = (params, user) => new Promise(async (res, rej) => { // Get 'limit' parameter - const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$; + const [limit = 10, limitErr] = $.num.optional().range(1, 100).get(params.limit); if (limitErr) return rej('invalid limit param'); - // Get 'offset' parameter - const [offset = 0, offsetErr] = $(params.offset).optional.number().min(0).$; - if (offsetErr) return rej('invalid offset param'); + // Get 'sinceId' parameter + const [sinceId, sinceIdErr] = $.type(ID).optional().get(params.sinceId); + if (sinceIdErr) return rej('invalid sinceId param'); - // Get 'sort' parameter - const [sort = 'desc', sortError] = $(params.sort).optional.string().or('desc asc').$; - if (sortError) return rej('invalid sort param'); + // Get 'untilId' parameter + const [untilId, untilIdErr] = $.type(ID).optional().get(params.untilId); + if (untilIdErr) return rej('invalid untilId param'); + + // Check if both of sinceId and untilId is specified + if (sinceId && untilId) { + return rej('cannot set sinceId and untilId'); + } + + const query = { + userId: user._id + } as any; + + const sort = { + _id: -1 + }; + + if (sinceId) { + sort._id = 1; + query._id = { + $gt: sinceId + }; + } else if (untilId) { + query._id = { + $lt: untilId + }; + } // Get favorites const favorites = await Favorite - .find({ - userId: user._id - }, { - limit: limit, - skip: offset, - sort: { - _id: sort == 'asc' ? 1 : -1 - } - }); + .find(query, { limit, sort }); // Serialize - res(await Promise.all(favorites.map(async favorite => - await pack(favorite.noteId) - ))); + res(await Promise.all(favorites.map(favorite => pack(favorite, user)))); }); diff --git a/src/server/api/endpoints/i/notifications.ts b/src/server/api/endpoints/i/notifications.ts index 3b4899682d..50ed9b27e8 100644 --- a/src/server/api/endpoints/i/notifications.ts +++ b/src/server/api/endpoints/i/notifications.ts @@ -1,11 +1,11 @@ /** * Module dependencies */ -import $ from 'cafy'; +import $ from 'cafy'; import ID from '../../../../cafy-id'; import Notification from '../../../../models/notification'; import Mute from '../../../../models/mute'; import { pack } from '../../../../models/notification'; -import getFriends from '../../common/get-friends'; +import { getFriendIds } from '../../common/get-friends'; import read from '../../common/read-notification'; /** @@ -14,27 +14,27 @@ import read from '../../common/read-notification'; module.exports = (params, user) => new Promise(async (res, rej) => { // Get 'following' parameter const [following = false, followingError] = - $(params.following).optional.boolean().$; + $.bool.optional().get(params.following); if (followingError) return rej('invalid following param'); // Get 'markAsRead' parameter - const [markAsRead = true, markAsReadErr] = $(params.markAsRead).optional.boolean().$; + const [markAsRead = true, markAsReadErr] = $.bool.optional().get(params.markAsRead); if (markAsReadErr) return rej('invalid markAsRead param'); // Get 'type' parameter - const [type, typeErr] = $(params.type).optional.array('string').unique().$; + const [type, typeErr] = $.arr($.str).optional().unique().get(params.type); if (typeErr) return rej('invalid type param'); // Get 'limit' parameter - const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$; + const [limit = 10, limitErr] = $.num.optional().range(1, 100).get(params.limit); if (limitErr) return rej('invalid limit param'); // Get 'sinceId' parameter - const [sinceId, sinceIdErr] = $(params.sinceId).optional.id().$; + const [sinceId, sinceIdErr] = $.type(ID).optional().get(params.sinceId); if (sinceIdErr) return rej('invalid sinceId param'); // Get 'untilId' parameter - const [untilId, untilIdErr] = $(params.untilId).optional.id().$; + const [untilId, untilIdErr] = $.type(ID).optional().get(params.untilId); if (untilIdErr) return rej('invalid untilId param'); // Check if both of sinceId and untilId is specified @@ -62,7 +62,7 @@ module.exports = (params, user) => new Promise(async (res, rej) => { if (following) { // ID list of the user itself and other users who the user follows - const followingIds = await getFriends(user._id); + const followingIds = await getFriendIds(user._id); query.$and.push({ notifierId: { diff --git a/src/server/api/endpoints/i/pin.ts b/src/server/api/endpoints/i/pin.ts index 909a6fdbde..423f0ac4ae 100644 --- a/src/server/api/endpoints/i/pin.ts +++ b/src/server/api/endpoints/i/pin.ts @@ -1,21 +1,17 @@ /** * Module dependencies */ -import $ from 'cafy'; +import $ from 'cafy'; import ID from '../../../../cafy-id'; import User from '../../../../models/user'; import Note from '../../../../models/note'; import { pack } from '../../../../models/user'; /** * Pin note - * - * @param {any} params - * @param {any} user - * @return {Promise<any>} */ module.exports = async (params, user) => new Promise(async (res, rej) => { // Get 'noteId' parameter - const [noteId, noteIdErr] = $(params.noteId).id().$; + const [noteId, noteIdErr] = $.type(ID).get(params.noteId); if (noteIdErr) return rej('invalid noteId param'); // Fetch pinee diff --git a/src/server/api/endpoints/i/regenerate_token.ts b/src/server/api/endpoints/i/regenerate_token.ts index f9e92c1797..6e1e571297 100644 --- a/src/server/api/endpoints/i/regenerate_token.ts +++ b/src/server/api/endpoints/i/regenerate_token.ts @@ -9,14 +9,10 @@ import generateUserToken from '../../common/generate-native-user-token'; /** * Regenerate native token - * - * @param {any} params - * @param {any} user - * @return {Promise<any>} */ module.exports = async (params, user) => new Promise(async (res, rej) => { // Get 'password' parameter - const [password, passwordErr] = $(params.password).string().$; + const [password, passwordErr] = $.str.get(params.password); if (passwordErr) return rej('invalid password param'); // Compare password diff --git a/src/server/api/endpoints/i/signin_history.ts b/src/server/api/endpoints/i/signin_history.ts index 931b9e2252..63a74b41b1 100644 --- a/src/server/api/endpoints/i/signin_history.ts +++ b/src/server/api/endpoints/i/signin_history.ts @@ -1,27 +1,23 @@ /** * Module dependencies */ -import $ from 'cafy'; +import $ from 'cafy'; import ID from '../../../../cafy-id'; import Signin, { pack } from '../../../../models/signin'; /** * Get signin history of my account - * - * @param {any} params - * @param {any} user - * @return {Promise<any>} */ module.exports = (params, user) => new Promise(async (res, rej) => { // Get 'limit' parameter - const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$; + const [limit = 10, limitErr] = $.num.optional().range(1, 100).get(params.limit); if (limitErr) return rej('invalid limit param'); // Get 'sinceId' parameter - const [sinceId, sinceIdErr] = $(params.sinceId).optional.id().$; + const [sinceId, sinceIdErr] = $.type(ID).optional().get(params.sinceId); if (sinceIdErr) return rej('invalid sinceId param'); // Get 'untilId' parameter - const [untilId, untilIdErr] = $(params.untilId).optional.id().$; + const [untilId, untilIdErr] = $.type(ID).optional().get(params.untilId); if (untilIdErr) return rej('invalid untilId param'); // Check if both of sinceId and untilId is specified diff --git a/src/server/api/endpoints/i/update.ts b/src/server/api/endpoints/i/update.ts index f3c9d777b5..b7b25d0f65 100644 --- a/src/server/api/endpoints/i/update.ts +++ b/src/server/api/endpoints/i/update.ts @@ -1,9 +1,10 @@ /** * Module dependencies */ -import $ from 'cafy'; +import $ from 'cafy'; import ID from '../../../../cafy-id'; import User, { isValidName, isValidDescription, isValidLocation, isValidBirthday, pack } from '../../../../models/user'; import event from '../../../../publishers/stream'; +import DriveFile from '../../../../models/drive-file'; /** * Update myself @@ -12,51 +13,73 @@ module.exports = async (params, user, app) => new Promise(async (res, rej) => { const isSecure = user != null && app == null; // Get 'name' parameter - const [name, nameErr] = $(params.name).optional.nullable.string().pipe(isValidName).$; + const [name, nameErr] = $.str.optional().nullable().pipe(isValidName).get(params.name); if (nameErr) return rej('invalid name param'); if (name) user.name = name; // Get 'description' parameter - const [description, descriptionErr] = $(params.description).optional.nullable.string().pipe(isValidDescription).$; + const [description, descriptionErr] = $.str.optional().nullable().pipe(isValidDescription).get(params.description); if (descriptionErr) return rej('invalid description param'); if (description !== undefined) user.description = description; // Get 'location' parameter - const [location, locationErr] = $(params.location).optional.nullable.string().pipe(isValidLocation).$; + const [location, locationErr] = $.str.optional().nullable().pipe(isValidLocation).get(params.location); if (locationErr) return rej('invalid location param'); if (location !== undefined) user.profile.location = location; // Get 'birthday' parameter - const [birthday, birthdayErr] = $(params.birthday).optional.nullable.string().pipe(isValidBirthday).$; + const [birthday, birthdayErr] = $.str.optional().nullable().pipe(isValidBirthday).get(params.birthday); if (birthdayErr) return rej('invalid birthday param'); if (birthday !== undefined) user.profile.birthday = birthday; // Get 'avatarId' parameter - const [avatarId, avatarIdErr] = $(params.avatarId).optional.id().$; + const [avatarId, avatarIdErr] = $.type(ID).optional().get(params.avatarId); if (avatarIdErr) return rej('invalid avatarId param'); if (avatarId) user.avatarId = avatarId; // Get 'bannerId' parameter - const [bannerId, bannerIdErr] = $(params.bannerId).optional.id().$; + const [bannerId, bannerIdErr] = $.type(ID).optional().get(params.bannerId); if (bannerIdErr) return rej('invalid bannerId param'); if (bannerId) user.bannerId = bannerId; // Get 'isBot' parameter - const [isBot, isBotErr] = $(params.isBot).optional.boolean().$; + const [isBot, isBotErr] = $.bool.optional().get(params.isBot); if (isBotErr) return rej('invalid isBot param'); if (isBot != null) user.isBot = isBot; // Get 'autoWatch' parameter - const [autoWatch, autoWatchErr] = $(params.autoWatch).optional.boolean().$; + const [autoWatch, autoWatchErr] = $.bool.optional().get(params.autoWatch); if (autoWatchErr) return rej('invalid autoWatch param'); if (autoWatch != null) user.settings.autoWatch = autoWatch; + if (avatarId) { + const avatar = await DriveFile.findOne({ + _id: avatarId + }); + + if (avatar != null && avatar.metadata.properties.avgColor) { + user.avatarColor = avatar.metadata.properties.avgColor; + } + } + + if (bannerId) { + const banner = await DriveFile.findOne({ + _id: bannerId + }); + + if (banner != null && banner.metadata.properties.avgColor) { + user.bannerColor = banner.metadata.properties.avgColor; + } + } + await User.update(user._id, { $set: { name: user.name, description: user.description, avatarId: user.avatarId, + avatarColor: user.avatarColor, bannerId: user.bannerId, + bannerColor: user.bannerColor, profile: user.profile, isBot: user.isBot, settings: user.settings diff --git a/src/server/api/endpoints/i/update_client_setting.ts b/src/server/api/endpoints/i/update_client_setting.ts index b0d5db5ec2..e91d7565fd 100644 --- a/src/server/api/endpoints/i/update_client_setting.ts +++ b/src/server/api/endpoints/i/update_client_setting.ts @@ -2,23 +2,19 @@ * Module dependencies */ import $ from 'cafy'; -import User, { pack } from '../../../../models/user'; +import User from '../../../../models/user'; import event from '../../../../publishers/stream'; /** * Update myself - * - * @param {any} params - * @param {any} user - * @return {Promise<any>} */ module.exports = async (params, user) => new Promise(async (res, rej) => { // Get 'name' parameter - const [name, nameErr] = $(params.name).string().$; + const [name, nameErr] = $.str.get(params.name); if (nameErr) return rej('invalid name param'); // Get 'value' parameter - const [value, valueErr] = $(params.value).nullable.any().$; + const [value, valueErr] = $.any.nullable().get(params.value); if (valueErr) return rej('invalid value param'); const x = {}; @@ -28,16 +24,11 @@ module.exports = async (params, user) => new Promise(async (res, rej) => { $set: x }); - // Serialize - user.clientSettings[name] = value; - const iObj = await pack(user, user, { - detail: true, - includeSecrets: true - }); - - // Send response - res(iObj); + res(); - // Publish i updated event - event(user._id, 'i_updated', iObj); + // Publish event + event(user._id, 'clientSettingUpdated', { + key: name, + value + }); }); diff --git a/src/server/api/endpoints/i/update_home.ts b/src/server/api/endpoints/i/update_home.ts index ce7661ede0..8ce551957e 100644 --- a/src/server/api/endpoints/i/update_home.ts +++ b/src/server/api/endpoints/i/update_home.ts @@ -7,20 +7,22 @@ import event from '../../../../publishers/stream'; module.exports = async (params, user) => new Promise(async (res, rej) => { // Get 'home' parameter - const [home, homeErr] = $(params.home).optional.array().each( - $().strict.object() - .have('name', $().string()) - .have('id', $().string()) - .have('place', $().string()) - .have('data', $().object())).$; + const [home, homeErr] = $.arr( + $.obj.strict() + .have('name', $.str) + .have('id', $.str) + .have('place', $.str) + .have('data', $.obj)) + .optional() + .get(params.home); if (homeErr) return rej('invalid home param'); // Get 'id' parameter - const [id, idErr] = $(params.id).optional.string().$; + const [id, idErr] = $.str.optional().get(params.id); if (idErr) return rej('invalid id param'); // Get 'data' parameter - const [data, dataErr] = $(params.data).optional.object().$; + const [data, dataErr] = $.obj.optional().get(params.data); if (dataErr) return rej('invalid data param'); if (home) { diff --git a/src/server/api/endpoints/i/update_mobile_home.ts b/src/server/api/endpoints/i/update_mobile_home.ts index b710e2f330..d79a77072b 100644 --- a/src/server/api/endpoints/i/update_mobile_home.ts +++ b/src/server/api/endpoints/i/update_mobile_home.ts @@ -7,19 +7,20 @@ import event from '../../../../publishers/stream'; module.exports = async (params, user) => new Promise(async (res, rej) => { // Get 'home' parameter - const [home, homeErr] = $(params.home).optional.array().each( - $().strict.object() - .have('name', $().string()) - .have('id', $().string()) - .have('data', $().object())).$; + const [home, homeErr] = $.arr( + $.obj.strict() + .have('name', $.str) + .have('id', $.str) + .have('data', $.obj)) + .optional().get(params.home); if (homeErr) return rej('invalid home param'); // Get 'id' parameter - const [id, idErr] = $(params.id).optional.string().$; + const [id, idErr] = $.str.optional().get(params.id); if (idErr) return rej('invalid id param'); // Get 'data' parameter - const [data, dataErr] = $(params.data).optional.object().$; + const [data, dataErr] = $.obj.optional().get(params.data); if (dataErr) return rej('invalid data param'); if (home) { diff --git a/src/server/api/endpoints/messaging/history.ts b/src/server/api/endpoints/messaging/history.ts index e42d34f21a..ec97642f17 100644 --- a/src/server/api/endpoints/messaging/history.ts +++ b/src/server/api/endpoints/messaging/history.ts @@ -8,14 +8,10 @@ import { pack } from '../../../../models/messaging-message'; /** * Show messaging history - * - * @param {any} params - * @param {any} user - * @return {Promise<any>} */ module.exports = (params, user) => new Promise(async (res, rej) => { // Get 'limit' parameter - const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$; + const [limit = 10, limitErr] = $.num.optional().range(1, 100).get(params.limit); if (limitErr) return rej('invalid limit param'); const mute = await Mute.find({ diff --git a/src/server/api/endpoints/messaging/messages.ts b/src/server/api/endpoints/messaging/messages.ts index 092eab0562..0338aba68a 100644 --- a/src/server/api/endpoints/messaging/messages.ts +++ b/src/server/api/endpoints/messaging/messages.ts @@ -1,7 +1,7 @@ /** * Module dependencies */ -import $ from 'cafy'; +import $ from 'cafy'; import ID from '../../../../cafy-id'; import Message from '../../../../models/messaging-message'; import User from '../../../../models/user'; import { pack } from '../../../../models/messaging-message'; @@ -16,7 +16,7 @@ import read from '../../common/read-messaging-message'; */ module.exports = (params, user) => new Promise(async (res, rej) => { // Get 'userId' parameter - const [recipientId, recipientIdErr] = $(params.userId).id().$; + const [recipientId, recipientIdErr] = $.type(ID).get(params.userId); if (recipientIdErr) return rej('invalid userId param'); // Fetch recipient @@ -33,19 +33,19 @@ module.exports = (params, user) => new Promise(async (res, rej) => { } // Get 'markAsRead' parameter - const [markAsRead = true, markAsReadErr] = $(params.markAsRead).optional.boolean().$; + const [markAsRead = true, markAsReadErr] = $.bool.optional().get(params.markAsRead); if (markAsReadErr) return rej('invalid markAsRead param'); // Get 'limit' parameter - const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$; + const [limit = 10, limitErr] = $.num.optional().range(1, 100).get(params.limit); if (limitErr) return rej('invalid limit param'); // Get 'sinceId' parameter - const [sinceId, sinceIdErr] = $(params.sinceId).optional.id().$; + const [sinceId, sinceIdErr] = $.type(ID).optional().get(params.sinceId); if (sinceIdErr) return rej('invalid sinceId param'); // Get 'untilId' parameter - const [untilId, untilIdErr] = $(params.untilId).optional.id().$; + const [untilId, untilIdErr] = $.type(ID).optional().get(params.untilId); if (untilIdErr) return rej('invalid untilId param'); // Check if both of sinceId and untilId is specified diff --git a/src/server/api/endpoints/messaging/messages/create.ts b/src/server/api/endpoints/messaging/messages/create.ts index 085e75e6cf..db471839e7 100644 --- a/src/server/api/endpoints/messaging/messages/create.ts +++ b/src/server/api/endpoints/messaging/messages/create.ts @@ -1,7 +1,7 @@ /** * Module dependencies */ -import $ from 'cafy'; +import $ from 'cafy'; import ID from '../../../../../cafy-id'; import Message from '../../../../../models/messaging-message'; import { isValidText } from '../../../../../models/messaging-message'; import History from '../../../../../models/messaging-history'; @@ -12,20 +12,14 @@ import { pack } from '../../../../../models/messaging-message'; import publishUserStream from '../../../../../publishers/stream'; import { publishMessagingStream, publishMessagingIndexStream } from '../../../../../publishers/stream'; import pushSw from '../../../../../publishers/push-sw'; -import html from '../../../../../text/html'; -import parse from '../../../../../text/parse'; import config from '../../../../../config'; /** * Create a message - * - * @param {any} params - * @param {any} user - * @return {Promise<any>} */ module.exports = (params, user) => new Promise(async (res, rej) => { // Get 'userId' parameter - const [recipientId, recipientIdErr] = $(params.userId).id().$; + const [recipientId, recipientIdErr] = $.type(ID).get(params.userId); if (recipientIdErr) return rej('invalid userId param'); // Myself @@ -47,11 +41,11 @@ module.exports = (params, user) => new Promise(async (res, rej) => { } // Get 'text' parameter - const [text, textErr] = $(params.text).optional.string().pipe(isValidText).$; + const [text, textErr] = $.str.optional().pipe(isValidText).get(params.text); if (textErr) return rej('invalid text'); // Get 'fileId' parameter - const [fileId, fileIdErr] = $(params.fileId).optional.id().$; + const [fileId, fileIdErr] = $.type(ID).optional().get(params.fileId); if (fileIdErr) return rej('invalid fileId param'); let file = null; @@ -77,7 +71,6 @@ module.exports = (params, user) => new Promise(async (res, rej) => { fileId: file ? file._id : undefined, recipientId: recipient._id, text: text ? text : undefined, - textHtml: text ? html(parse(text)) : undefined, userId: user._id, isRead: false }); diff --git a/src/server/api/endpoints/messaging/unread.ts b/src/server/api/endpoints/messaging/unread.ts index 30d59dd8bd..1d83af501d 100644 --- a/src/server/api/endpoints/messaging/unread.ts +++ b/src/server/api/endpoints/messaging/unread.ts @@ -6,10 +6,6 @@ import Mute from '../../../../models/mute'; /** * Get count of unread messages - * - * @param {any} params - * @param {any} user - * @return {Promise<any>} */ module.exports = (params, user) => new Promise(async (res, rej) => { const mute = await Mute.find({ diff --git a/src/server/api/endpoints/meta.ts b/src/server/api/endpoints/meta.ts index f6a276a2b7..0e9ecf47df 100644 --- a/src/server/api/endpoints/meta.ts +++ b/src/server/api/endpoints/meta.ts @@ -2,10 +2,12 @@ * Module dependencies */ import * as os from 'os'; -import version from '../../../version'; import config from '../../../config'; import Meta from '../../../models/meta'; +const pkg = require('../../../../package.json'); +const client = require('../../../../built/client/meta.json'); + /** * @swagger * /meta: @@ -41,7 +43,10 @@ module.exports = (params) => new Promise(async (res, rej) => { res({ maintainer: config.maintainer, - version: version, + + version: pkg.version, + clientVersion: client.version, + secure: config.https != null, machine: os.hostname(), os: os.platform(), diff --git a/src/server/api/endpoints/mute/create.ts b/src/server/api/endpoints/mute/create.ts index 26ae612cab..534020c671 100644 --- a/src/server/api/endpoints/mute/create.ts +++ b/src/server/api/endpoints/mute/create.ts @@ -1,22 +1,18 @@ /** * Module dependencies */ -import $ from 'cafy'; +import $ from 'cafy'; import ID from '../../../../cafy-id'; import User from '../../../../models/user'; import Mute from '../../../../models/mute'; /** * Mute a user - * - * @param {any} params - * @param {any} user - * @return {Promise<any>} */ module.exports = (params, user) => new Promise(async (res, rej) => { const muter = user; // Get 'userId' parameter - const [userId, userIdErr] = $(params.userId).id().$; + const [userId, userIdErr] = $.type(ID).get(params.userId); if (userIdErr) return rej('invalid userId param'); // 自分自身 diff --git a/src/server/api/endpoints/mute/delete.ts b/src/server/api/endpoints/mute/delete.ts index 6f617416c8..949aff64ba 100644 --- a/src/server/api/endpoints/mute/delete.ts +++ b/src/server/api/endpoints/mute/delete.ts @@ -1,7 +1,7 @@ /** * Module dependencies */ -import $ from 'cafy'; +import $ from 'cafy'; import ID from '../../../../cafy-id'; import User from '../../../../models/user'; import Mute from '../../../../models/mute'; @@ -12,7 +12,7 @@ module.exports = (params, user) => new Promise(async (res, rej) => { const muter = user; // Get 'userId' parameter - const [userId, userIdErr] = $(params.userId).id().$; + const [userId, userIdErr] = $.type(ID).get(params.userId); if (userIdErr) return rej('invalid userId param'); // Check if the mutee is yourself diff --git a/src/server/api/endpoints/mute/list.ts b/src/server/api/endpoints/mute/list.ts index bd80401445..cf89f7e959 100644 --- a/src/server/api/endpoints/mute/list.ts +++ b/src/server/api/endpoints/mute/list.ts @@ -1,29 +1,25 @@ /** * Module dependencies */ -import $ from 'cafy'; +import $ from 'cafy'; import ID from '../../../../cafy-id'; import Mute from '../../../../models/mute'; import { pack } from '../../../../models/user'; -import getFriends from '../../common/get-friends'; +import { getFriendIds } from '../../common/get-friends'; /** * Get muted users of a user - * - * @param {any} params - * @param {any} me - * @return {Promise<any>} */ module.exports = (params, me) => new Promise(async (res, rej) => { // Get 'iknow' parameter - const [iknow = false, iknowErr] = $(params.iknow).optional.boolean().$; + const [iknow = false, iknowErr] = $.bool.optional().get(params.iknow); if (iknowErr) return rej('invalid iknow param'); // Get 'limit' parameter - const [limit = 30, limitErr] = $(params.limit).optional.number().range(1, 100).$; + const [limit = 30, limitErr] = $.num.optional().range(1, 100).get(params.limit); if (limitErr) return rej('invalid limit param'); // Get 'cursor' parameter - const [cursor = null, cursorErr] = $(params.cursor).optional.id().$; + const [cursor = null, cursorErr] = $.type(ID).optional().get(params.cursor); if (cursorErr) return rej('invalid cursor param'); // Construct query @@ -34,7 +30,7 @@ module.exports = (params, me) => new Promise(async (res, rej) => { if (iknow) { // Get my friends - const myFriends = await getFriends(me._id); + const myFriends = await getFriendIds(me._id); query.muteeId = { $in: myFriends diff --git a/src/server/api/endpoints/my/apps.ts b/src/server/api/endpoints/my/apps.ts index 2a3f8bcd7a..086e0b8965 100644 --- a/src/server/api/endpoints/my/apps.ts +++ b/src/server/api/endpoints/my/apps.ts @@ -6,18 +6,14 @@ import App, { pack } from '../../../../models/app'; /** * Get my apps - * - * @param {any} params - * @param {any} user - * @return {Promise<any>} */ module.exports = (params, user) => new Promise(async (res, rej) => { // Get 'limit' parameter - const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$; + const [limit = 10, limitErr] = $.num.optional().range(1, 100).get(params.limit); if (limitErr) return rej('invalid limit param'); // Get 'offset' parameter - const [offset = 0, offsetErr] = $(params.offset).optional.number().min(0).$; + const [offset = 0, offsetErr] = $.num.optional().min(0).get(params.offset); if (offsetErr) return rej('invalid offset param'); const query = { diff --git a/src/server/api/endpoints/notes.ts b/src/server/api/endpoints/notes.ts index a70ac0588f..4ce7613d70 100644 --- a/src/server/api/endpoints/notes.ts +++ b/src/server/api/endpoints/notes.ts @@ -1,7 +1,7 @@ /** * Module dependencies */ -import $ from 'cafy'; +import $ from 'cafy'; import ID from '../../../cafy-id'; import Note, { pack } from '../../../models/note'; /** @@ -9,35 +9,35 @@ import Note, { pack } from '../../../models/note'; */ module.exports = (params) => new Promise(async (res, rej) => { // Get 'reply' parameter - const [reply, replyErr] = $(params.reply).optional.boolean().$; + const [reply, replyErr] = $.bool.optional().get(params.reply); if (replyErr) return rej('invalid reply param'); // Get 'renote' parameter - const [renote, renoteErr] = $(params.renote).optional.boolean().$; + const [renote, renoteErr] = $.bool.optional().get(params.renote); if (renoteErr) return rej('invalid renote param'); // Get 'media' parameter - const [media, mediaErr] = $(params.media).optional.boolean().$; + const [media, mediaErr] = $.bool.optional().get(params.media); if (mediaErr) return rej('invalid media param'); // Get 'poll' parameter - const [poll, pollErr] = $(params.poll).optional.boolean().$; + const [poll, pollErr] = $.bool.optional().get(params.poll); if (pollErr) return rej('invalid poll param'); // Get 'bot' parameter - //const [bot, botErr] = $(params.bot).optional.boolean().$; + //const [bot, botErr] = $.bool.optional().get(params.bot); //if (botErr) return rej('invalid bot param'); // Get 'limit' parameter - const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$; + const [limit = 10, limitErr] = $.num.optional().range(1, 100).get(params.limit); if (limitErr) return rej('invalid limit param'); // Get 'sinceId' parameter - const [sinceId, sinceIdErr] = $(params.sinceId).optional.id().$; + const [sinceId, sinceIdErr] = $.type(ID).optional().get(params.sinceId); if (sinceIdErr) return rej('invalid sinceId param'); // Get 'untilId' parameter - const [untilId, untilIdErr] = $(params.untilId).optional.id().$; + const [untilId, untilIdErr] = $.type(ID).optional().get(params.untilId); if (untilIdErr) return rej('invalid untilId param'); // Check if both of sinceId and untilId is specified diff --git a/src/server/api/endpoints/notes/context.ts b/src/server/api/endpoints/notes/context.ts index 2caf742d26..1cd27250e2 100644 --- a/src/server/api/endpoints/notes/context.ts +++ b/src/server/api/endpoints/notes/context.ts @@ -1,7 +1,7 @@ /** * Module dependencies */ -import $ from 'cafy'; +import $ from 'cafy'; import ID from '../../../../cafy-id'; import Note, { pack } from '../../../../models/note'; /** @@ -13,15 +13,15 @@ import Note, { pack } from '../../../../models/note'; */ module.exports = (params, user) => new Promise(async (res, rej) => { // Get 'noteId' parameter - const [noteId, noteIdErr] = $(params.noteId).id().$; + const [noteId, noteIdErr] = $.type(ID).get(params.noteId); if (noteIdErr) return rej('invalid noteId param'); // Get 'limit' parameter - const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$; + const [limit = 10, limitErr] = $.num.optional().range(1, 100).get(params.limit); if (limitErr) return rej('invalid limit param'); // Get 'offset' parameter - const [offset = 0, offsetErr] = $(params.offset).optional.number().min(0).$; + const [offset = 0, offsetErr] = $.num.optional().min(0).get(params.offset); if (offsetErr) return rej('invalid offset param'); // Lookup note diff --git a/src/server/api/endpoints/notes/create.ts b/src/server/api/endpoints/notes/create.ts index 7e79912b1b..429b6d370a 100644 --- a/src/server/api/endpoints/notes/create.ts +++ b/src/server/api/endpoints/notes/create.ts @@ -1,10 +1,9 @@ /** * Module dependencies */ -import $ from 'cafy'; -import deepEqual = require('deep-equal'); +import $ from 'cafy'; import ID from '../../../../cafy-id'; import Note, { INote, isValidText, isValidCw, pack } from '../../../../models/note'; -import { ILocalUser } from '../../../../models/user'; +import User, { ILocalUser } from '../../../../models/user'; import Channel, { IChannel } from '../../../../models/channel'; import DriveFile from '../../../../models/drive-file'; import create from '../../../../services/note/create'; @@ -12,48 +11,54 @@ import { IApp } from '../../../../models/app'; /** * Create a note - * - * @param {any} params - * @param {any} user - * @param {any} app - * @return {Promise<any>} */ module.exports = (params, user: ILocalUser, app: IApp) => new Promise(async (res, rej) => { // Get 'visibility' parameter - const [visibility = 'public', visibilityErr] = $(params.visibility).optional.string().or(['public', 'unlisted', 'private', 'direct']).$; + const [visibility = 'public', visibilityErr] = $.str.optional().or(['public', 'home', 'followers', 'specified', 'private']).get(params.visibility); if (visibilityErr) return rej('invalid visibility'); + // Get 'visibleUserIds' parameter + const [visibleUserIds, visibleUserIdsErr] = $.arr($.type(ID)).optional().unique().min(1).get(params.visibleUserIds); + if (visibleUserIdsErr) return rej('invalid visibleUserIds'); + + let visibleUsers = []; + if (visibleUserIds !== undefined) { + visibleUsers = await Promise.all(visibleUserIds.map(id => User.findOne({ + _id: id + }))); + } + // Get 'text' parameter - const [text, textErr] = $(params.text).optional.string().pipe(isValidText).$; + const [text = null, textErr] = $.str.optional().nullable().pipe(isValidText).get(params.text); if (textErr) return rej('invalid text'); // Get 'cw' parameter - const [cw, cwErr] = $(params.cw).optional.string().pipe(isValidCw).$; + const [cw, cwErr] = $.str.optional().nullable().pipe(isValidCw).get(params.cw); if (cwErr) return rej('invalid cw'); // Get 'viaMobile' parameter - const [viaMobile = false, viaMobileErr] = $(params.viaMobile).optional.boolean().$; + const [viaMobile = false, viaMobileErr] = $.bool.optional().get(params.viaMobile); if (viaMobileErr) return rej('invalid viaMobile'); // Get 'tags' parameter - const [tags = [], tagsErr] = $(params.tags).optional.array('string').unique().eachQ(t => t.range(1, 32)).$; + const [tags = [], tagsErr] = $.arr($.str.range(1, 32)).optional().unique().get(params.tags); if (tagsErr) return rej('invalid tags'); // Get 'geo' parameter - const [geo, geoErr] = $(params.geo).optional.nullable.strict.object() - .have('coordinates', $().array().length(2) - .item(0, $().number().range(-180, 180)) - .item(1, $().number().range(-90, 90))) - .have('altitude', $().nullable.number()) - .have('accuracy', $().nullable.number()) - .have('altitudeAccuracy', $().nullable.number()) - .have('heading', $().nullable.number().range(0, 360)) - .have('speed', $().nullable.number()) - .$; + const [geo, geoErr] = $.obj.optional().nullable().strict() + .have('coordinates', $.arr().length(2) + .item(0, $.num.range(-180, 180)) + .item(1, $.num.range(-90, 90))) + .have('altitude', $.num.nullable()) + .have('accuracy', $.num.nullable()) + .have('altitudeAccuracy', $.num.nullable()) + .have('heading', $.num.nullable().range(0, 360)) + .have('speed', $.num.nullable()) + .get(params.geo); if (geoErr) return rej('invalid geo'); // Get 'mediaIds' parameter - const [mediaIds, mediaIdsErr] = $(params.mediaIds).optional.array('id').unique().range(1, 4).$; + const [mediaIds, mediaIdsErr] = $.arr($.type(ID)).optional().unique().range(1, 4).get(params.mediaIds); if (mediaIdsErr) return rej('invalid mediaIds'); let files = []; @@ -80,7 +85,7 @@ module.exports = (params, user: ILocalUser, app: IApp) => new Promise(async (res } // Get 'renoteId' parameter - const [renoteId, renoteIdErr] = $(params.renoteId).optional.id().$; + const [renoteId, renoteIdErr] = $.type(ID).optional().get(params.renoteId); if (renoteIdErr) return rej('invalid renoteId'); let renote: INote = null; @@ -97,35 +102,11 @@ module.exports = (params, user: ILocalUser, app: IApp) => new Promise(async (res return rej('cannot renote to renote'); } - // Fetch recently note - const latestNote = await Note.findOne({ - userId: user._id - }, { - sort: { - _id: -1 - } - }); - isQuote = text != null || files != null; - - // 直近と同じRenote対象かつ引用じゃなかったらエラー - if (latestNote && - latestNote.renoteId && - latestNote.renoteId.equals(renote._id) && - !isQuote) { - return rej('cannot renote same note that already reposted in your latest note'); - } - - // 直近がRenote対象かつ引用じゃなかったらエラー - if (latestNote && - latestNote._id.equals(renote._id) && - !isQuote) { - return rej('cannot renote your latest note'); - } } // Get 'replyId' parameter - const [replyId, replyIdErr] = $(params.replyId).optional.id().$; + const [replyId, replyIdErr] = $.type(ID).optional().get(params.replyId); if (replyIdErr) return rej('invalid replyId'); let reply: INote = null; @@ -146,7 +127,7 @@ module.exports = (params, user: ILocalUser, app: IApp) => new Promise(async (res } // Get 'channelId' parameter - const [channelId, channelIdErr] = $(params.channelId).optional.id().$; + const [channelId, channelIdErr] = $.type(ID).optional().get(params.channelId); if (channelIdErr) return rej('invalid channelId'); let channel: IChannel = null; @@ -187,12 +168,12 @@ module.exports = (params, user: ILocalUser, app: IApp) => new Promise(async (res } // Get 'poll' parameter - const [poll, pollErr] = $(params.poll).optional.strict.object() - .have('choices', $().array('string') + const [poll, pollErr] = $.obj.optional().strict() + .have('choices', $.arr($.str) .unique() .range(2, 10) .each(c => c.length > 0 && c.length < 50)) - .$; + .get(params.poll); if (pollErr) return rej('invalid poll'); if (poll) { @@ -208,37 +189,20 @@ module.exports = (params, user: ILocalUser, app: IApp) => new Promise(async (res return rej('text, mediaIds, renoteId or poll is required'); } - // 直近の投稿と重複してたらエラー - // TODO: 直近の投稿が一日前くらいなら重複とは見なさない - if (user.latestNote) { - if (deepEqual({ - text: user.latestNote.text, - reply: user.latestNote.replyId ? user.latestNote.replyId.toString() : null, - renote: user.latestNote.renoteId ? user.latestNote.renoteId.toString() : null, - mediaIds: (user.latestNote.mediaIds || []).map(id => id.toString()) - }, { - text: text, - reply: reply ? reply._id.toString() : null, - renote: renote ? renote._id.toString() : null, - mediaIds: (files || []).map(file => file._id.toString()) - })) { - return rej('duplicate'); - } - } - // 投稿を作成 const note = await create(user, { createdAt: new Date(), media: files, - poll: poll, - text: text, + poll, + text, reply, renote, - cw: cw, - tags: tags, - app: app, - viaMobile: viaMobile, + cw, + tags, + app, + viaMobile, visibility, + visibleUsers, geo }); diff --git a/src/server/api/endpoints/notes/favorites/create.ts b/src/server/api/endpoints/notes/favorites/create.ts index c8e7f52426..6832b52f75 100644 --- a/src/server/api/endpoints/notes/favorites/create.ts +++ b/src/server/api/endpoints/notes/favorites/create.ts @@ -1,20 +1,16 @@ /** * Module dependencies */ -import $ from 'cafy'; +import $ from 'cafy'; import ID from '../../../../../cafy-id'; import Favorite from '../../../../../models/favorite'; import Note from '../../../../../models/note'; /** * Favorite a note - * - * @param {any} params - * @param {any} user - * @return {Promise<any>} */ module.exports = (params, user) => new Promise(async (res, rej) => { // Get 'noteId' parameter - const [noteId, noteIdErr] = $(params.noteId).id().$; + const [noteId, noteIdErr] = $.type(ID).get(params.noteId); if (noteIdErr) return rej('invalid noteId param'); // Get favoritee diff --git a/src/server/api/endpoints/notes/favorites/delete.ts b/src/server/api/endpoints/notes/favorites/delete.ts index 92aceb343b..07112dae15 100644 --- a/src/server/api/endpoints/notes/favorites/delete.ts +++ b/src/server/api/endpoints/notes/favorites/delete.ts @@ -1,20 +1,16 @@ /** * Module dependencies */ -import $ from 'cafy'; +import $ from 'cafy'; import ID from '../../../../../cafy-id'; import Favorite from '../../../../../models/favorite'; import Note from '../../../../../models/note'; /** * Unfavorite a note - * - * @param {any} params - * @param {any} user - * @return {Promise<any>} */ module.exports = (params, user) => new Promise(async (res, rej) => { // Get 'noteId' parameter - const [noteId, noteIdErr] = $(params.noteId).id().$; + const [noteId, noteIdErr] = $.type(ID).get(params.noteId); if (noteIdErr) return rej('invalid noteId param'); // Get favoritee diff --git a/src/server/api/endpoints/notes/global-timeline.ts b/src/server/api/endpoints/notes/global-timeline.ts index 07e138ec54..d22a1763de 100644 --- a/src/server/api/endpoints/notes/global-timeline.ts +++ b/src/server/api/endpoints/notes/global-timeline.ts @@ -1,7 +1,7 @@ /** * Module dependencies */ -import $ from 'cafy'; +import $ from 'cafy'; import ID from '../../../../cafy-id'; import Note from '../../../../models/note'; import Mute from '../../../../models/mute'; import { pack } from '../../../../models/note'; @@ -11,23 +11,23 @@ import { pack } from '../../../../models/note'; */ module.exports = async (params, user, app) => { // Get 'limit' parameter - const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$; + const [limit = 10, limitErr] = $.num.optional().range(1, 100).get(params.limit); if (limitErr) throw 'invalid limit param'; // Get 'sinceId' parameter - const [sinceId, sinceIdErr] = $(params.sinceId).optional.id().$; + const [sinceId, sinceIdErr] = $.type(ID).optional().get(params.sinceId); if (sinceIdErr) throw 'invalid sinceId param'; // Get 'untilId' parameter - const [untilId, untilIdErr] = $(params.untilId).optional.id().$; + const [untilId, untilIdErr] = $.type(ID).optional().get(params.untilId); if (untilIdErr) throw 'invalid untilId param'; // Get 'sinceDate' parameter - const [sinceDate, sinceDateErr] = $(params.sinceDate).optional.number().$; + const [sinceDate, sinceDateErr] = $.num.optional().get(params.sinceDate); if (sinceDateErr) throw 'invalid sinceDate param'; // Get 'untilDate' parameter - const [untilDate, untilDateErr] = $(params.untilDate).optional.number().$; + const [untilDate, untilDateErr] = $.num.optional().get(params.untilDate); if (untilDateErr) throw 'invalid untilDate param'; // Check if only one of sinceId, untilId, sinceDate, untilDate specified diff --git a/src/server/api/endpoints/notes/local-timeline.ts b/src/server/api/endpoints/notes/local-timeline.ts index d63528c3cd..e7ebe5d960 100644 --- a/src/server/api/endpoints/notes/local-timeline.ts +++ b/src/server/api/endpoints/notes/local-timeline.ts @@ -1,7 +1,7 @@ /** * Module dependencies */ -import $ from 'cafy'; +import $ from 'cafy'; import ID from '../../../../cafy-id'; import Note from '../../../../models/note'; import Mute from '../../../../models/mute'; import { pack } from '../../../../models/note'; @@ -11,23 +11,23 @@ import { pack } from '../../../../models/note'; */ module.exports = async (params, user, app) => { // Get 'limit' parameter - const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$; + const [limit = 10, limitErr] = $.num.optional().range(1, 100).get(params.limit); if (limitErr) throw 'invalid limit param'; // Get 'sinceId' parameter - const [sinceId, sinceIdErr] = $(params.sinceId).optional.id().$; + const [sinceId, sinceIdErr] = $.type(ID).optional().get(params.sinceId); if (sinceIdErr) throw 'invalid sinceId param'; // Get 'untilId' parameter - const [untilId, untilIdErr] = $(params.untilId).optional.id().$; + const [untilId, untilIdErr] = $.type(ID).optional().get(params.untilId); if (untilIdErr) throw 'invalid untilId param'; // Get 'sinceDate' parameter - const [sinceDate, sinceDateErr] = $(params.sinceDate).optional.number().$; + const [sinceDate, sinceDateErr] = $.num.optional().get(params.sinceDate); if (sinceDateErr) throw 'invalid sinceDate param'; // Get 'untilDate' parameter - const [untilDate, untilDateErr] = $(params.untilDate).optional.number().$; + const [untilDate, untilDateErr] = $.num.optional().get(params.untilDate); if (untilDateErr) throw 'invalid untilDate param'; // Check if only one of sinceId, untilId, sinceDate, untilDate specified diff --git a/src/server/api/endpoints/notes/mentions.ts b/src/server/api/endpoints/notes/mentions.ts index c507acbaec..163a6b4866 100644 --- a/src/server/api/endpoints/notes/mentions.ts +++ b/src/server/api/endpoints/notes/mentions.ts @@ -1,9 +1,9 @@ /** * Module dependencies */ -import $ from 'cafy'; +import $ from 'cafy'; import ID from '../../../../cafy-id'; import Note from '../../../../models/note'; -import getFriends from '../../common/get-friends'; +import { getFriendIds } from '../../common/get-friends'; import { pack } from '../../../../models/note'; /** @@ -16,19 +16,19 @@ import { pack } from '../../../../models/note'; module.exports = (params, user) => new Promise(async (res, rej) => { // Get 'following' parameter const [following = false, followingError] = - $(params.following).optional.boolean().$; + $.bool.optional().get(params.following); if (followingError) return rej('invalid following param'); // Get 'limit' parameter - const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$; + const [limit = 10, limitErr] = $.num.optional().range(1, 100).get(params.limit); if (limitErr) return rej('invalid limit param'); // Get 'sinceId' parameter - const [sinceId, sinceIdErr] = $(params.sinceId).optional.id().$; + const [sinceId, sinceIdErr] = $.type(ID).optional().get(params.sinceId); if (sinceIdErr) return rej('invalid sinceId param'); // Get 'untilId' parameter - const [untilId, untilIdErr] = $(params.untilId).optional.id().$; + const [untilId, untilIdErr] = $.type(ID).optional().get(params.untilId); if (untilIdErr) return rej('invalid untilId param'); // Check if both of sinceId and untilId is specified @@ -46,7 +46,7 @@ module.exports = (params, user) => new Promise(async (res, rej) => { }; if (following) { - const followingIds = await getFriends(user._id); + const followingIds = await getFriendIds(user._id); query.userId = { $in: followingIds diff --git a/src/server/api/endpoints/notes/polls/recommendation.ts b/src/server/api/endpoints/notes/polls/recommendation.ts index cb530ea2cf..a272378d19 100644 --- a/src/server/api/endpoints/notes/polls/recommendation.ts +++ b/src/server/api/endpoints/notes/polls/recommendation.ts @@ -7,18 +7,14 @@ import Note, { pack } from '../../../../../models/note'; /** * Get recommended polls - * - * @param {any} params - * @param {any} user - * @return {Promise<any>} */ module.exports = (params, user) => new Promise(async (res, rej) => { // Get 'limit' parameter - const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$; + const [limit = 10, limitErr] = $.num.optional().range(1, 100).get(params.limit); if (limitErr) return rej('invalid limit param'); // Get 'offset' parameter - const [offset = 0, offsetErr] = $(params.offset).optional.number().min(0).$; + const [offset = 0, offsetErr] = $.num.optional().min(0).get(params.offset); if (offsetErr) return rej('invalid offset param'); // Get votes diff --git a/src/server/api/endpoints/notes/polls/vote.ts b/src/server/api/endpoints/notes/polls/vote.ts index 03d94da60d..f8f4515308 100644 --- a/src/server/api/endpoints/notes/polls/vote.ts +++ b/src/server/api/endpoints/notes/polls/vote.ts @@ -1,7 +1,7 @@ /** * Module dependencies */ -import $ from 'cafy'; +import $ from 'cafy'; import ID from '../../../../../cafy-id'; import Vote from '../../../../../models/poll-vote'; import Note from '../../../../../models/note'; import Watching from '../../../../../models/note-watching'; @@ -11,14 +11,10 @@ import notify from '../../../../../publishers/notify'; /** * Vote poll of a note - * - * @param {any} params - * @param {any} user - * @return {Promise<any>} */ module.exports = (params, user) => new Promise(async (res, rej) => { // Get 'noteId' parameter - const [noteId, noteIdErr] = $(params.noteId).id().$; + const [noteId, noteIdErr] = $.type(ID).get(params.noteId); if (noteIdErr) return rej('invalid noteId param'); // Get votee @@ -36,9 +32,9 @@ module.exports = (params, user) => new Promise(async (res, rej) => { // Get 'choice' parameter const [choice, choiceError] = - $(params.choice).number() + $.num .pipe(c => note.poll.choices.some(x => x.id == c)) - .$; + .get(params.choice); if (choiceError) return rej('invalid choice param'); // if already voted diff --git a/src/server/api/endpoints/notes/reactions.ts b/src/server/api/endpoints/notes/reactions.ts index bbff97bb0a..4ad952a7a1 100644 --- a/src/server/api/endpoints/notes/reactions.ts +++ b/src/server/api/endpoints/notes/reactions.ts @@ -1,7 +1,7 @@ /** * Module dependencies */ -import $ from 'cafy'; +import $ from 'cafy'; import ID from '../../../../cafy-id'; import Note from '../../../../models/note'; import Reaction, { pack } from '../../../../models/note-reaction'; @@ -14,19 +14,19 @@ import Reaction, { pack } from '../../../../models/note-reaction'; */ module.exports = (params, user) => new Promise(async (res, rej) => { // Get 'noteId' parameter - const [noteId, noteIdErr] = $(params.noteId).id().$; + const [noteId, noteIdErr] = $.type(ID).get(params.noteId); if (noteIdErr) return rej('invalid noteId param'); // Get 'limit' parameter - const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$; + const [limit = 10, limitErr] = $.num.optional().range(1, 100).get(params.limit); if (limitErr) return rej('invalid limit param'); // Get 'offset' parameter - const [offset = 0, offsetErr] = $(params.offset).optional.number().min(0).$; + const [offset = 0, offsetErr] = $.num.optional().min(0).get(params.offset); if (offsetErr) return rej('invalid offset param'); // Get 'sort' parameter - const [sort = 'desc', sortError] = $(params.sort).optional.string().or('desc asc').$; + const [sort = 'desc', sortError] = $.str.optional().or('desc asc').get(params.sort); if (sortError) return rej('invalid sort param'); // Lookup note diff --git a/src/server/api/endpoints/notes/reactions/create.ts b/src/server/api/endpoints/notes/reactions/create.ts index c80c5416b1..21757cb427 100644 --- a/src/server/api/endpoints/notes/reactions/create.ts +++ b/src/server/api/endpoints/notes/reactions/create.ts @@ -1,30 +1,21 @@ /** * Module dependencies */ -import $ from 'cafy'; +import $ from 'cafy'; import ID from '../../../../../cafy-id'; import Note from '../../../../../models/note'; import create from '../../../../../services/note/reaction/create'; +import { validateReaction } from '../../../../../models/note-reaction'; /** * React to a note */ module.exports = (params, user) => new Promise(async (res, rej) => { // Get 'noteId' parameter - const [noteId, noteIdErr] = $(params.noteId).id().$; + const [noteId, noteIdErr] = $.type(ID).get(params.noteId); if (noteIdErr) return rej('invalid noteId param'); // Get 'reaction' parameter - const [reaction, reactionErr] = $(params.reaction).string().or([ - 'like', - 'love', - 'laugh', - 'hmm', - 'surprise', - 'congrats', - 'angry', - 'confused', - 'pudding' - ]).$; + const [reaction, reactionErr] = $.str.pipe(validateReaction.ok).get(params.reaction); if (reactionErr) return rej('invalid reaction param'); // Fetch reactee diff --git a/src/server/api/endpoints/notes/reactions/delete.ts b/src/server/api/endpoints/notes/reactions/delete.ts index b5d738b8ff..afb8629112 100644 --- a/src/server/api/endpoints/notes/reactions/delete.ts +++ b/src/server/api/endpoints/notes/reactions/delete.ts @@ -1,21 +1,16 @@ /** * Module dependencies */ -import $ from 'cafy'; +import $ from 'cafy'; import ID from '../../../../../cafy-id'; import Reaction from '../../../../../models/note-reaction'; import Note from '../../../../../models/note'; -// import event from '../../../publishers/stream'; /** * Unreact to a note - * - * @param {any} params - * @param {any} user - * @return {Promise<any>} */ module.exports = (params, user) => new Promise(async (res, rej) => { // Get 'noteId' parameter - const [noteId, noteIdErr] = $(params.noteId).id().$; + const [noteId, noteIdErr] = $.type(ID).get(params.noteId); if (noteIdErr) return rej('invalid noteId param'); // Fetch unreactee diff --git a/src/server/api/endpoints/notes/replies.ts b/src/server/api/endpoints/notes/replies.ts index 88d9ff329a..11d221d8f7 100644 --- a/src/server/api/endpoints/notes/replies.ts +++ b/src/server/api/endpoints/notes/replies.ts @@ -1,7 +1,7 @@ /** * Module dependencies */ -import $ from 'cafy'; +import $ from 'cafy'; import ID from '../../../../cafy-id'; import Note, { pack } from '../../../../models/note'; /** @@ -13,19 +13,19 @@ import Note, { pack } from '../../../../models/note'; */ module.exports = (params, user) => new Promise(async (res, rej) => { // Get 'noteId' parameter - const [noteId, noteIdErr] = $(params.noteId).id().$; + const [noteId, noteIdErr] = $.type(ID).get(params.noteId); if (noteIdErr) return rej('invalid noteId param'); // Get 'limit' parameter - const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$; + const [limit = 10, limitErr] = $.num.optional().range(1, 100).get(params.limit); if (limitErr) return rej('invalid limit param'); // Get 'offset' parameter - const [offset = 0, offsetErr] = $(params.offset).optional.number().min(0).$; + const [offset = 0, offsetErr] = $.num.optional().min(0).get(params.offset); if (offsetErr) return rej('invalid offset param'); // Get 'sort' parameter - const [sort = 'desc', sortError] = $(params.sort).optional.string().or('desc asc').$; + const [sort = 'desc', sortError] = $.str.optional().or('desc asc').get(params.sort); if (sortError) return rej('invalid sort param'); // Lookup note diff --git a/src/server/api/endpoints/notes/reposts.ts b/src/server/api/endpoints/notes/reposts.ts index 9dfc2c3cb5..3098211b61 100644 --- a/src/server/api/endpoints/notes/reposts.ts +++ b/src/server/api/endpoints/notes/reposts.ts @@ -1,7 +1,7 @@ /** * Module dependencies */ -import $ from 'cafy'; +import $ from 'cafy'; import ID from '../../../../cafy-id'; import Note, { pack } from '../../../../models/note'; /** @@ -13,19 +13,19 @@ import Note, { pack } from '../../../../models/note'; */ module.exports = (params, user) => new Promise(async (res, rej) => { // Get 'noteId' parameter - const [noteId, noteIdErr] = $(params.noteId).id().$; + const [noteId, noteIdErr] = $.type(ID).get(params.noteId); if (noteIdErr) return rej('invalid noteId param'); // Get 'limit' parameter - const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$; + const [limit = 10, limitErr] = $.num.optional().range(1, 100).get(params.limit); if (limitErr) return rej('invalid limit param'); // Get 'sinceId' parameter - const [sinceId, sinceIdErr] = $(params.sinceId).optional.id().$; + const [sinceId, sinceIdErr] = $.type(ID).optional().get(params.sinceId); if (sinceIdErr) return rej('invalid sinceId param'); // Get 'untilId' parameter - const [untilId, untilIdErr] = $(params.untilId).optional.id().$; + const [untilId, untilIdErr] = $.type(ID).optional().get(params.untilId); if (untilIdErr) return rej('invalid untilId param'); // Check if both of sinceId and untilId is specified diff --git a/src/server/api/endpoints/notes/search.ts b/src/server/api/endpoints/notes/search.ts index bfa17b000e..9705dcfd6e 100644 --- a/src/server/api/endpoints/notes/search.ts +++ b/src/server/api/endpoints/notes/search.ts @@ -1,12 +1,12 @@ /** * Module dependencies */ -import $ from 'cafy'; +import $ from 'cafy'; import ID from '../../../../cafy-id'; const escapeRegexp = require('escape-regexp'); import Note from '../../../../models/note'; import User from '../../../../models/user'; import Mute from '../../../../models/mute'; -import getFriends from '../../common/get-friends'; +import { getFriendIds } from '../../common/get-friends'; import { pack } from '../../../../models/note'; /** @@ -18,63 +18,63 @@ import { pack } from '../../../../models/note'; */ module.exports = (params, me) => new Promise(async (res, rej) => { // Get 'text' parameter - const [text, textError] = $(params.text).optional.string().$; + const [text, textError] = $.str.optional().get(params.text); if (textError) return rej('invalid text param'); // Get 'includeUserIds' parameter - const [includeUserIds = [], includeUserIdsErr] = $(params.includeUserIds).optional.array('id').$; + const [includeUserIds = [], includeUserIdsErr] = $.arr($.type(ID)).optional().get(params.includeUserIds); if (includeUserIdsErr) return rej('invalid includeUserIds param'); // Get 'excludeUserIds' parameter - const [excludeUserIds = [], excludeUserIdsErr] = $(params.excludeUserIds).optional.array('id').$; + const [excludeUserIds = [], excludeUserIdsErr] = $.arr($.type(ID)).optional().get(params.excludeUserIds); if (excludeUserIdsErr) return rej('invalid excludeUserIds param'); // Get 'includeUserUsernames' parameter - const [includeUserUsernames = [], includeUserUsernamesErr] = $(params.includeUserUsernames).optional.array('string').$; + const [includeUserUsernames = [], includeUserUsernamesErr] = $.arr($.str).optional().get(params.includeUserUsernames); if (includeUserUsernamesErr) return rej('invalid includeUserUsernames param'); // Get 'excludeUserUsernames' parameter - const [excludeUserUsernames = [], excludeUserUsernamesErr] = $(params.excludeUserUsernames).optional.array('string').$; + const [excludeUserUsernames = [], excludeUserUsernamesErr] = $.arr($.str).optional().get(params.excludeUserUsernames); if (excludeUserUsernamesErr) return rej('invalid excludeUserUsernames param'); // Get 'following' parameter - const [following = null, followingErr] = $(params.following).optional.nullable.boolean().$; + const [following = null, followingErr] = $.bool.optional().nullable().get(params.following); if (followingErr) return rej('invalid following param'); // Get 'mute' parameter - const [mute = 'mute_all', muteErr] = $(params.mute).optional.string().$; + const [mute = 'mute_all', muteErr] = $.str.optional().get(params.mute); if (muteErr) return rej('invalid mute param'); // Get 'reply' parameter - const [reply = null, replyErr] = $(params.reply).optional.nullable.boolean().$; + const [reply = null, replyErr] = $.bool.optional().nullable().get(params.reply); if (replyErr) return rej('invalid reply param'); // Get 'renote' parameter - const [renote = null, renoteErr] = $(params.renote).optional.nullable.boolean().$; + const [renote = null, renoteErr] = $.bool.optional().nullable().get(params.renote); if (renoteErr) return rej('invalid renote param'); // Get 'media' parameter - const [media = null, mediaErr] = $(params.media).optional.nullable.boolean().$; + const [media = null, mediaErr] = $.bool.optional().nullable().get(params.media); if (mediaErr) return rej('invalid media param'); // Get 'poll' parameter - const [poll = null, pollErr] = $(params.poll).optional.nullable.boolean().$; + const [poll = null, pollErr] = $.bool.optional().nullable().get(params.poll); if (pollErr) return rej('invalid poll param'); // Get 'sinceDate' parameter - const [sinceDate, sinceDateErr] = $(params.sinceDate).optional.number().$; + const [sinceDate, sinceDateErr] = $.num.optional().get(params.sinceDate); if (sinceDateErr) throw 'invalid sinceDate param'; // Get 'untilDate' parameter - const [untilDate, untilDateErr] = $(params.untilDate).optional.number().$; + const [untilDate, untilDateErr] = $.num.optional().get(params.untilDate); if (untilDateErr) throw 'invalid untilDate param'; // Get 'offset' parameter - const [offset = 0, offsetErr] = $(params.offset).optional.number().min(0).$; + const [offset = 0, offsetErr] = $.num.optional().min(0).get(params.offset); if (offsetErr) return rej('invalid offset param'); // Get 'limit' parameter - const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 30).$; + const [limit = 10, limitErr] = $.num.optional().range(1, 30).get(params.limit); if (limitErr) return rej('invalid limit param'); let includeUsers = includeUserIds; @@ -156,7 +156,7 @@ async function search( } if (following != null && me != null) { - const ids = await getFriends(me._id, false); + const ids = await getFriendIds(me._id, false); push({ userId: following ? { $in: ids diff --git a/src/server/api/endpoints/notes/show.ts b/src/server/api/endpoints/notes/show.ts index 67cdc3038b..78dc55a703 100644 --- a/src/server/api/endpoints/notes/show.ts +++ b/src/server/api/endpoints/notes/show.ts @@ -1,7 +1,7 @@ /** * Module dependencies */ -import $ from 'cafy'; +import $ from 'cafy'; import ID from '../../../../cafy-id'; import Note, { pack } from '../../../../models/note'; /** @@ -13,7 +13,7 @@ import Note, { pack } from '../../../../models/note'; */ module.exports = (params, user) => new Promise(async (res, rej) => { // Get 'noteId' parameter - const [noteId, noteIdErr] = $(params.noteId).id().$; + const [noteId, noteIdErr] = $.type(ID).get(params.noteId); if (noteIdErr) return rej('invalid noteId param'); // Get note diff --git a/src/server/api/endpoints/notes/timeline.ts b/src/server/api/endpoints/notes/timeline.ts index b5feaac817..78786d4a16 100644 --- a/src/server/api/endpoints/notes/timeline.ts +++ b/src/server/api/endpoints/notes/timeline.ts @@ -1,12 +1,11 @@ /** * Module dependencies */ -import $ from 'cafy'; -import rap from '@prezzemolo/rap'; +import $ from 'cafy'; import ID from '../../../../cafy-id'; import Note from '../../../../models/note'; import Mute from '../../../../models/mute'; import ChannelWatching from '../../../../models/channel-watching'; -import getFriends from '../../common/get-friends'; +import { getFriends } from '../../common/get-friends'; import { pack } from '../../../../models/note'; /** @@ -14,23 +13,23 @@ import { pack } from '../../../../models/note'; */ module.exports = async (params, user, app) => { // Get 'limit' parameter - const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$; + const [limit = 10, limitErr] = $.num.optional().range(1, 100).get(params.limit); if (limitErr) throw 'invalid limit param'; // Get 'sinceId' parameter - const [sinceId, sinceIdErr] = $(params.sinceId).optional.id().$; + const [sinceId, sinceIdErr] = $.type(ID).optional().get(params.sinceId); if (sinceIdErr) throw 'invalid sinceId param'; // Get 'untilId' parameter - const [untilId, untilIdErr] = $(params.untilId).optional.id().$; + const [untilId, untilIdErr] = $.type(ID).optional().get(params.untilId); if (untilIdErr) throw 'invalid untilId param'; // Get 'sinceDate' parameter - const [sinceDate, sinceDateErr] = $(params.sinceDate).optional.number().$; + const [sinceDate, sinceDateErr] = $.num.optional().get(params.sinceDate); if (sinceDateErr) throw 'invalid sinceDate param'; // Get 'untilDate' parameter - const [untilDate, untilDateErr] = $(params.untilDate).optional.number().$; + const [untilDate, untilDateErr] = $.num.optional().get(params.untilDate); if (untilDateErr) throw 'invalid untilDate param'; // Check if only one of sinceId, untilId, sinceDate, untilDate specified @@ -38,59 +37,130 @@ module.exports = async (params, user, app) => { throw 'only one of sinceId, untilId, sinceDate, untilDate can be specified'; } - const { followingIds, watchingChannelIds, mutedUserIds } = await rap({ - // ID list of the user itself and other users who the user follows - followingIds: getFriends(user._id), + // Get 'includeMyRenotes' parameter + const [includeMyRenotes = true, includeMyRenotesErr] = $.bool.optional().get(params.includeMyRenotes); + if (includeMyRenotesErr) throw 'invalid includeMyRenotes param'; + + // Get 'includeRenotedMyNotes' parameter + const [includeRenotedMyNotes = true, includeRenotedMyNotesErr] = $.bool.optional().get(params.includeRenotedMyNotes); + if (includeRenotedMyNotesErr) throw 'invalid includeRenotedMyNotes param'; + + const [followings, watchingChannelIds, mutedUserIds] = await Promise.all([ + // フォローを取得 + // Fetch following + getFriends(user._id), // Watchしているチャンネルを取得 - watchingChannelIds: ChannelWatching.find({ + ChannelWatching.find({ userId: user._id, // 削除されたドキュメントは除く deletedAt: { $exists: false } }).then(watches => watches.map(w => w.channelId)), // ミュートしているユーザーを取得 - mutedUserIds: Mute.find({ + Mute.find({ muterId: user._id }).then(ms => ms.map(m => m.muteeId)) - }); + ]); //#region Construct query const sort = { _id: -1 }; - const query = { + const followQuery = followings.map(f => f.stalk ? { + userId: f.id + } : { + userId: f.id, + + // ストーキングしてないならリプライは含めない(ただし投稿者自身の投稿へのリプライ、自分の投稿へのリプライ、自分のリプライは含める) $or: [{ - // フォローしている人のタイムラインへの投稿 - userId: { - $in: followingIds - }, - // 「タイムラインへの」投稿に限定するためにチャンネルが指定されていないもののみに限る + // リプライでない + replyId: null + }, { // または + // リプライだが返信先が投稿者自身の投稿 + $expr: { + $eq: ['$_reply.userId', '$userId'] + } + }, { // または + // リプライだが返信先が自分(フォロワー)の投稿 + '_reply.userId': user._id + }, { // または + // 自分(フォロワー)が送信したリプライ + userId: user._id + }] + }); + + const query = { + $and: [{ $or: [{ + $and: [{ + // フォローしている人のタイムラインへの投稿 + $or: followQuery + }, { + // 「タイムラインへの」投稿に限定するためにチャンネルが指定されていないもののみに限る + $or: [{ + channelId: { + $exists: false + } + }, { + channelId: null + }] + }] + }, { + // Watchしているチャンネルへの投稿 channelId: { - $exists: false + $in: watchingChannelIds } + }], + // mute + userId: { + $nin: mutedUserIds + }, + '_reply.userId': { + $nin: mutedUserIds + }, + '_renote.userId': { + $nin: mutedUserIds + }, + }] + } as any; + + // MongoDBではトップレベルで否定ができないため、De Morganの法則を利用してクエリします。 + // つまり、「『自分の投稿かつRenote』ではない」を「『自分の投稿ではない』または『Renoteではない』」と表現します。 + // for details: https://en.wikipedia.org/wiki/De_Morgan%27s_laws + + if (includeMyRenotes === false) { + query.$and.push({ + $or: [{ + userId: { $ne: user._id } + }, { + renoteId: null + }, { + text: { $ne: null } + }, { + mediaIds: { $ne: [] } }, { - channelId: null + poll: { $ne: null } }] - }, { - // Watchしているチャンネルへの投稿 - channelId: { - $in: watchingChannelIds - } - }], - // mute - userId: { - $nin: mutedUserIds - }, - '_reply.userId': { - $nin: mutedUserIds - }, - '_renote.userId': { - $nin: mutedUserIds - }, - } as any; + }); + } + + if (includeRenotedMyNotes === false) { + query.$and.push({ + $or: [{ + '_renote.userId': { $ne: user._id } + }, { + renoteId: null + }, { + text: { $ne: null } + }, { + mediaIds: { $ne: [] } + }, { + poll: { $ne: null } + }] + }); + } if (sinceId) { sort._id = 1; diff --git a/src/server/api/endpoints/notes/trend.ts b/src/server/api/endpoints/notes/trend.ts index 48ecd5b843..4735bec51e 100644 --- a/src/server/api/endpoints/notes/trend.ts +++ b/src/server/api/endpoints/notes/trend.ts @@ -14,36 +14,33 @@ import Note, { pack } from '../../../../models/note'; */ module.exports = (params, user) => new Promise(async (res, rej) => { // Get 'limit' parameter - const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$; + const [limit = 10, limitErr] = $.num.optional().range(1, 100).get(params.limit); if (limitErr) return rej('invalid limit param'); // Get 'offset' parameter - const [offset = 0, offsetErr] = $(params.offset).optional.number().min(0).$; + const [offset = 0, offsetErr] = $.num.optional().min(0).get(params.offset); if (offsetErr) return rej('invalid offset param'); // Get 'reply' parameter - const [reply, replyErr] = $(params.reply).optional.boolean().$; + const [reply, replyErr] = $.bool.optional().get(params.reply); if (replyErr) return rej('invalid reply param'); // Get 'renote' parameter - const [renote, renoteErr] = $(params.renote).optional.boolean().$; + const [renote, renoteErr] = $.bool.optional().get(params.renote); if (renoteErr) return rej('invalid renote param'); // Get 'media' parameter - const [media, mediaErr] = $(params.media).optional.boolean().$; + const [media, mediaErr] = $.bool.optional().get(params.media); if (mediaErr) return rej('invalid media param'); // Get 'poll' parameter - const [poll, pollErr] = $(params.poll).optional.boolean().$; + const [poll, pollErr] = $.bool.optional().get(params.poll); if (pollErr) return rej('invalid poll param'); const query = { - createdAt: { - $gte: new Date(Date.now() - ms('1days')) - }, - renoteCount: { - $gt: 0 - } + _id: { $gte: new Date(Date.now() - ms('1days')) }, + renoteCount: { $gt: 0 }, + '_user.host': null } as any; if (reply != undefined) { diff --git a/src/server/api/endpoints/notes/user-list-timeline.ts b/src/server/api/endpoints/notes/user-list-timeline.ts new file mode 100644 index 0000000000..9f8397d679 --- /dev/null +++ b/src/server/api/endpoints/notes/user-list-timeline.ts @@ -0,0 +1,179 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; import ID from '../../../../cafy-id'; +import Note from '../../../../models/note'; +import Mute from '../../../../models/mute'; +import { pack } from '../../../../models/note'; +import UserList from '../../../../models/user-list'; + +/** + * Get timeline of a user list + */ +module.exports = async (params, user, app) => { + // Get 'limit' parameter + const [limit = 10, limitErr] = $.num.optional().range(1, 100).get(params.limit); + if (limitErr) throw 'invalid limit param'; + + // Get 'sinceId' parameter + const [sinceId, sinceIdErr] = $.type(ID).optional().get(params.sinceId); + if (sinceIdErr) throw 'invalid sinceId param'; + + // Get 'untilId' parameter + const [untilId, untilIdErr] = $.type(ID).optional().get(params.untilId); + if (untilIdErr) throw 'invalid untilId param'; + + // Get 'sinceDate' parameter + const [sinceDate, sinceDateErr] = $.num.optional().get(params.sinceDate); + if (sinceDateErr) throw 'invalid sinceDate param'; + + // Get 'untilDate' parameter + const [untilDate, untilDateErr] = $.num.optional().get(params.untilDate); + if (untilDateErr) throw 'invalid untilDate param'; + + // Check if only one of sinceId, untilId, sinceDate, untilDate specified + if ([sinceId, untilId, sinceDate, untilDate].filter(x => x != null).length > 1) { + throw 'only one of sinceId, untilId, sinceDate, untilDate can be specified'; + } + + // Get 'includeMyRenotes' parameter + const [includeMyRenotes = true, includeMyRenotesErr] = $.bool.optional().get(params.includeMyRenotes); + if (includeMyRenotesErr) throw 'invalid includeMyRenotes param'; + + // Get 'includeRenotedMyNotes' parameter + const [includeRenotedMyNotes = true, includeRenotedMyNotesErr] = $.bool.optional().get(params.includeRenotedMyNotes); + if (includeRenotedMyNotesErr) throw 'invalid includeRenotedMyNotes param'; + + // Get 'listId' parameter + const [listId, listIdErr] = $.type(ID).get(params.listId); + if (listIdErr) throw 'invalid listId param'; + + const [list, mutedUserIds] = await Promise.all([ + // リストを取得 + // Fetch the list + UserList.findOne({ + _id: listId, + userId: user._id + }), + + // ミュートしているユーザーを取得 + Mute.find({ + muterId: user._id + }).then(ms => ms.map(m => m.muteeId)) + ]); + + if (list.userIds.length == 0) { + return []; + } + + //#region Construct query + const sort = { + _id: -1 + }; + + const listQuery = list.userIds.map(u => ({ + userId: u, + + // リプライは含めない(ただし投稿者自身の投稿へのリプライ、自分の投稿へのリプライ、自分のリプライは含める) + $or: [{ + // リプライでない + replyId: null + }, { // または + // リプライだが返信先が投稿者自身の投稿 + $expr: { + $eq: ['$_reply.userId', '$userId'] + } + }, { // または + // リプライだが返信先が自分(フォロワー)の投稿 + '_reply.userId': user._id + }, { // または + // 自分(フォロワー)が送信したリプライ + userId: user._id + }] + })); + + const query = { + $and: [{ + // リストに入っている人のタイムラインへの投稿 + $or: listQuery, + + // mute + userId: { + $nin: mutedUserIds + }, + '_reply.userId': { + $nin: mutedUserIds + }, + '_renote.userId': { + $nin: mutedUserIds + }, + }] + } as any; + + // MongoDBではトップレベルで否定ができないため、De Morganの法則を利用してクエリします。 + // つまり、「『自分の投稿かつRenote』ではない」を「『自分の投稿ではない』または『Renoteではない』」と表現します。 + // for details: https://en.wikipedia.org/wiki/De_Morgan%27s_laws + + if (includeMyRenotes === false) { + query.$and.push({ + $or: [{ + userId: { $ne: user._id } + }, { + renoteId: null + }, { + text: { $ne: null } + }, { + mediaIds: { $ne: [] } + }, { + poll: { $ne: null } + }] + }); + } + + if (includeRenotedMyNotes === false) { + query.$and.push({ + $or: [{ + '_renote.userId': { $ne: user._id } + }, { + renoteId: null + }, { + text: { $ne: null } + }, { + mediaIds: { $ne: [] } + }, { + poll: { $ne: null } + }] + }); + } + + if (sinceId) { + sort._id = 1; + query._id = { + $gt: sinceId + }; + } else if (untilId) { + query._id = { + $lt: untilId + }; + } else if (sinceDate) { + sort._id = 1; + query.createdAt = { + $gt: new Date(sinceDate) + }; + } else if (untilDate) { + query.createdAt = { + $lt: new Date(untilDate) + }; + } + //#endregion + + // Issue query + const timeline = await Note + .find(query, { + limit: limit, + sort: sort + }); + + // Serialize + return await Promise.all(timeline.map(note => pack(note, user))); +}; diff --git a/src/server/api/endpoints/notifications/get_unread_count.ts b/src/server/api/endpoints/notifications/get_unread_count.ts index 283ecd63b1..600a80d194 100644 --- a/src/server/api/endpoints/notifications/get_unread_count.ts +++ b/src/server/api/endpoints/notifications/get_unread_count.ts @@ -6,10 +6,6 @@ import Mute from '../../../../models/mute'; /** * Get count of unread notifications - * - * @param {any} params - * @param {any} user - * @return {Promise<any>} */ module.exports = (params, user) => new Promise(async (res, rej) => { const mute = await Mute.find({ diff --git a/src/server/api/endpoints/notifications/mark_as_read_all.ts b/src/server/api/endpoints/notifications/mark_as_read_all.ts index 01c9145837..dce3cb4663 100644 --- a/src/server/api/endpoints/notifications/mark_as_read_all.ts +++ b/src/server/api/endpoints/notifications/mark_as_read_all.ts @@ -6,10 +6,6 @@ import event from '../../../../publishers/stream'; /** * Mark as read all notifications - * - * @param {any} params - * @param {any} user - * @return {Promise<any>} */ module.exports = (params, user) => new Promise(async (res, rej) => { // Update documents diff --git a/src/server/api/endpoints/othello/games.ts b/src/server/api/endpoints/othello/games.ts index d05c1c2585..2320a34b04 100644 --- a/src/server/api/endpoints/othello/games.ts +++ b/src/server/api/endpoints/othello/games.ts @@ -1,21 +1,21 @@ -import $ from 'cafy'; +import $ from 'cafy'; import ID from '../../../../cafy-id'; import OthelloGame, { pack } from '../../../../models/othello-game'; module.exports = (params, user) => new Promise(async (res, rej) => { // Get 'my' parameter - const [my = false, myErr] = $(params.my).optional.boolean().$; + const [my = false, myErr] = $.bool.optional().get(params.my); if (myErr) return rej('invalid my param'); // Get 'limit' parameter - const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$; + const [limit = 10, limitErr] = $.num.optional().range(1, 100).get(params.limit); if (limitErr) return rej('invalid limit param'); // Get 'sinceId' parameter - const [sinceId, sinceIdErr] = $(params.sinceId).optional.id().$; + const [sinceId, sinceIdErr] = $.type(ID).optional().get(params.sinceId); if (sinceIdErr) return rej('invalid sinceId param'); // Get 'untilId' parameter - const [untilId, untilIdErr] = $(params.untilId).optional.id().$; + const [untilId, untilIdErr] = $.type(ID).optional().get(params.untilId); if (untilIdErr) return rej('invalid untilId param'); // Check if both of sinceId and untilId is specified diff --git a/src/server/api/endpoints/othello/games/show.ts b/src/server/api/endpoints/othello/games/show.ts index dd886936d4..6b2f5ce137 100644 --- a/src/server/api/endpoints/othello/games/show.ts +++ b/src/server/api/endpoints/othello/games/show.ts @@ -1,10 +1,10 @@ -import $ from 'cafy'; +import $ from 'cafy'; import ID from '../../../../../cafy-id'; import OthelloGame, { pack } from '../../../../../models/othello-game'; import Othello from '../../../../../othello/core'; module.exports = (params, user) => new Promise(async (res, rej) => { // Get 'gameId' parameter - const [gameId, gameIdErr] = $(params.gameId).id().$; + const [gameId, gameIdErr] = $.type(ID).get(params.gameId); if (gameIdErr) return rej('invalid gameId param'); const game = await OthelloGame.findOne({ _id: gameId }); diff --git a/src/server/api/endpoints/othello/match.ts b/src/server/api/endpoints/othello/match.ts index d9274f8f9c..e70e579755 100644 --- a/src/server/api/endpoints/othello/match.ts +++ b/src/server/api/endpoints/othello/match.ts @@ -1,4 +1,4 @@ -import $ from 'cafy'; +import $ from 'cafy'; import ID from '../../../../cafy-id'; import Matching, { pack as packMatching } from '../../../../models/othello-matching'; import OthelloGame, { pack as packGame } from '../../../../models/othello-game'; import User from '../../../../models/user'; @@ -7,7 +7,7 @@ import { eighteight } from '../../../../othello/maps'; module.exports = (params, user) => new Promise(async (res, rej) => { // Get 'userId' parameter - const [childId, childIdErr] = $(params.userId).id().$; + const [childId, childIdErr] = $.type(ID).get(params.userId); if (childIdErr) return rej('invalid userId param'); // Myself diff --git a/src/server/api/endpoints/stats.ts b/src/server/api/endpoints/stats.ts index 52e5195484..d1e17651f2 100644 --- a/src/server/api/endpoints/stats.ts +++ b/src/server/api/endpoints/stats.ts @@ -1,48 +1,26 @@ -/** - * Module dependencies - */ import Note from '../../../models/note'; import User from '../../../models/user'; /** - * @swagger - * /stats: - * note: - * summary: Show the misskey's statistics - * responses: - * 200: - * description: Success - * schema: - * type: object - * properties: - * notesCount: - * description: count of all notes of misskey - * type: number - * usersCount: - * description: count of all users of misskey - * type: number - * - * default: - * description: Failed - * schema: - * $ref: "#/definitions/Error" - */ - -/** - * Show the misskey's statistics - * - * @param {any} params - * @return {Promise<any>} + * Get the misskey's statistics */ module.exports = params => new Promise(async (res, rej) => { - const notesCount = await Note - .count(); + const notesCount = await Note.count(); - const usersCount = await User - .count(); + const usersCount = await User.count(); + + const originalNotesCount = await Note.count({ + '_user.host': null + }); + + const originalUsersCount = await User.count({ + host: null + }); res({ - notesCount: notesCount, - usersCount: usersCount + notesCount, + usersCount, + originalNotesCount, + originalUsersCount }); }); diff --git a/src/server/api/endpoints/sw/register.ts b/src/server/api/endpoints/sw/register.ts index 3fe0bda4ee..b22a8b08ef 100644 --- a/src/server/api/endpoints/sw/register.ts +++ b/src/server/api/endpoints/sw/register.ts @@ -9,15 +9,15 @@ import Subscription from '../../../../models/sw-subscription'; */ module.exports = async (params, user, app) => new Promise(async (res, rej) => { // Get 'endpoint' parameter - const [endpoint, endpointErr] = $(params.endpoint).string().$; + const [endpoint, endpointErr] = $.str.get(params.endpoint); if (endpointErr) return rej('invalid endpoint param'); // Get 'auth' parameter - const [auth, authErr] = $(params.auth).string().$; + const [auth, authErr] = $.str.get(params.auth); if (authErr) return rej('invalid auth param'); // Get 'publickey' parameter - const [publickey, publickeyErr] = $(params.publickey).string().$; + const [publickey, publickeyErr] = $.str.get(params.publickey); if (publickeyErr) return rej('invalid publickey param'); // if already subscribed diff --git a/src/server/api/endpoints/username/available.ts b/src/server/api/endpoints/username/available.ts index bd27c37de0..b11bec4e58 100644 --- a/src/server/api/endpoints/username/available.ts +++ b/src/server/api/endpoints/username/available.ts @@ -13,7 +13,7 @@ import { validateUsername } from '../../../../models/user'; */ module.exports = async (params) => new Promise(async (res, rej) => { // Get 'username' parameter - const [username, usernameError] = $(params.username).string().pipe(validateUsername).$; + const [username, usernameError] = $.str.pipe(validateUsername).get(params.username); if (usernameError) return rej('invalid username param'); // Get exist diff --git a/src/server/api/endpoints/users.ts b/src/server/api/endpoints/users.ts index ae33e8af0c..eb581cb7e6 100644 --- a/src/server/api/endpoints/users.ts +++ b/src/server/api/endpoints/users.ts @@ -6,22 +6,18 @@ import User, { pack } from '../../../models/user'; /** * Lists all users - * - * @param {any} params - * @param {any} me - * @return {Promise<any>} */ module.exports = (params, me) => new Promise(async (res, rej) => { // Get 'limit' parameter - const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$; + const [limit = 10, limitErr] = $.num.optional().range(1, 100).get(params.limit); if (limitErr) return rej('invalid limit param'); // Get 'offset' parameter - const [offset = 0, offsetErr] = $(params.offset).optional.number().min(0).$; + const [offset = 0, offsetErr] = $.num.optional().min(0).get(params.offset); if (offsetErr) return rej('invalid offset param'); // Get 'sort' parameter - const [sort, sortError] = $(params.sort).optional.string().or('+follower|-follower').$; + const [sort, sortError] = $.str.optional().or('+follower|-follower').get(params.sort); if (sortError) return rej('invalid sort param'); // Construct query diff --git a/src/server/api/endpoints/users/followers.ts b/src/server/api/endpoints/users/followers.ts index 0222313e81..810cd7341b 100644 --- a/src/server/api/endpoints/users/followers.ts +++ b/src/server/api/endpoints/users/followers.ts @@ -1,34 +1,30 @@ /** * Module dependencies */ -import $ from 'cafy'; +import $ from 'cafy'; import ID from '../../../../cafy-id'; import User from '../../../../models/user'; import Following from '../../../../models/following'; import { pack } from '../../../../models/user'; -import getFriends from '../../common/get-friends'; +import { getFriendIds } from '../../common/get-friends'; /** * Get followers of a user - * - * @param {any} params - * @param {any} me - * @return {Promise<any>} */ module.exports = (params, me) => new Promise(async (res, rej) => { // Get 'userId' parameter - const [userId, userIdErr] = $(params.userId).id().$; + const [userId, userIdErr] = $.type(ID).get(params.userId); if (userIdErr) return rej('invalid userId param'); // Get 'iknow' parameter - const [iknow = false, iknowErr] = $(params.iknow).optional.boolean().$; + const [iknow = false, iknowErr] = $.bool.optional().get(params.iknow); if (iknowErr) return rej('invalid iknow param'); // Get 'limit' parameter - const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$; + const [limit = 10, limitErr] = $.num.optional().range(1, 100).get(params.limit); if (limitErr) return rej('invalid limit param'); // Get 'cursor' parameter - const [cursor = null, cursorErr] = $(params.cursor).optional.id().$; + const [cursor = null, cursorErr] = $.type(ID).optional().get(params.cursor); if (cursorErr) return rej('invalid cursor param'); // Lookup user @@ -52,7 +48,7 @@ module.exports = (params, me) => new Promise(async (res, rej) => { // ログインしていてかつ iknow フラグがあるとき if (me && iknow) { // Get my friends - const myFriends = await getFriends(me._id); + const myFriends = await getFriendIds(me._id); query.followerId = { $in: myFriends diff --git a/src/server/api/endpoints/users/following.ts b/src/server/api/endpoints/users/following.ts index 2372f57fbe..3373b9d632 100644 --- a/src/server/api/endpoints/users/following.ts +++ b/src/server/api/endpoints/users/following.ts @@ -1,11 +1,11 @@ /** * Module dependencies */ -import $ from 'cafy'; +import $ from 'cafy'; import ID from '../../../../cafy-id'; import User from '../../../../models/user'; import Following from '../../../../models/following'; import { pack } from '../../../../models/user'; -import getFriends from '../../common/get-friends'; +import { getFriendIds } from '../../common/get-friends'; /** * Get following users of a user @@ -16,19 +16,19 @@ import getFriends from '../../common/get-friends'; */ module.exports = (params, me) => new Promise(async (res, rej) => { // Get 'userId' parameter - const [userId, userIdErr] = $(params.userId).id().$; + const [userId, userIdErr] = $.type(ID).get(params.userId); if (userIdErr) return rej('invalid userId param'); // Get 'iknow' parameter - const [iknow = false, iknowErr] = $(params.iknow).optional.boolean().$; + const [iknow = false, iknowErr] = $.bool.optional().get(params.iknow); if (iknowErr) return rej('invalid iknow param'); // Get 'limit' parameter - const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$; + const [limit = 10, limitErr] = $.num.optional().range(1, 100).get(params.limit); if (limitErr) return rej('invalid limit param'); // Get 'cursor' parameter - const [cursor = null, cursorErr] = $(params.cursor).optional.id().$; + const [cursor = null, cursorErr] = $.type(ID).optional().get(params.cursor); if (cursorErr) return rej('invalid cursor param'); // Lookup user @@ -52,7 +52,7 @@ module.exports = (params, me) => new Promise(async (res, rej) => { // ログインしていてかつ iknow フラグがあるとき if (me && iknow) { // Get my friends - const myFriends = await getFriends(me._id); + const myFriends = await getFriendIds(me._id); query.followeeId = { $in: myFriends diff --git a/src/server/api/endpoints/users/get_frequently_replied_users.ts b/src/server/api/endpoints/users/get_frequently_replied_users.ts index 7a98f44e98..64d737a06b 100644 --- a/src/server/api/endpoints/users/get_frequently_replied_users.ts +++ b/src/server/api/endpoints/users/get_frequently_replied_users.ts @@ -1,17 +1,17 @@ /** * Module dependencies */ -import $ from 'cafy'; +import $ from 'cafy'; import ID from '../../../../cafy-id'; import Note from '../../../../models/note'; import User, { pack } from '../../../../models/user'; module.exports = (params, me) => new Promise(async (res, rej) => { // Get 'userId' parameter - const [userId, userIdErr] = $(params.userId).id().$; + const [userId, userIdErr] = $.type(ID).get(params.userId); if (userIdErr) return rej('invalid userId param'); // Get 'limit' parameter - const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$; + const [limit = 10, limitErr] = $.num.optional().range(1, 100).get(params.limit); if (limitErr) return rej('invalid limit param'); // Lookup user diff --git a/src/server/api/endpoints/users/lists/create.ts b/src/server/api/endpoints/users/lists/create.ts new file mode 100644 index 0000000000..100a78b872 --- /dev/null +++ b/src/server/api/endpoints/users/lists/create.ts @@ -0,0 +1,25 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import UserList, { pack } from '../../../../../models/user-list'; + +/** + * Create a user list + */ +module.exports = async (params, user) => new Promise(async (res, rej) => { + // Get 'title' parameter + const [title, titleErr] = $.str.range(1, 100).get(params.title); + if (titleErr) return rej('invalid title param'); + + // insert + const userList = await UserList.insert({ + createdAt: new Date(), + userId: user._id, + title: title, + userIds: [] + }); + + // Response + res(await pack(userList)); +}); diff --git a/src/server/api/endpoints/users/lists/list.ts b/src/server/api/endpoints/users/lists/list.ts new file mode 100644 index 0000000000..d19339a1f5 --- /dev/null +++ b/src/server/api/endpoints/users/lists/list.ts @@ -0,0 +1,13 @@ +import UserList, { pack } from '../../../../../models/user-list'; + +/** + * Add a user to a user list + */ +module.exports = async (params, me) => new Promise(async (res, rej) => { + // Fetch lists + const userLists = await UserList.find({ + userId: me._id, + }); + + res(await Promise.all(userLists.map(x => pack(x)))); +}); diff --git a/src/server/api/endpoints/users/lists/push.ts b/src/server/api/endpoints/users/lists/push.ts new file mode 100644 index 0000000000..da5a9a134c --- /dev/null +++ b/src/server/api/endpoints/users/lists/push.ts @@ -0,0 +1,61 @@ +import $ from 'cafy'; import ID from '../../../../../cafy-id'; +import UserList from '../../../../../models/user-list'; +import User, { pack as packUser, isRemoteUser, getGhost } from '../../../../../models/user'; +import { publishUserListStream } from '../../../../../publishers/stream'; +import ap from '../../../../../remote/activitypub/renderer'; +import renderFollow from '../../../../../remote/activitypub/renderer/follow'; +import { deliver } from '../../../../../queue'; + +/** + * Add a user to a user list + */ +module.exports = async (params, me) => new Promise(async (res, rej) => { + // Get 'listId' parameter + const [listId, listIdErr] = $.type(ID).get(params.listId); + if (listIdErr) return rej('invalid listId param'); + + // Fetch the list + const userList = await UserList.findOne({ + _id: listId, + userId: me._id, + }); + + if (userList == null) { + return rej('list not found'); + } + + // Get 'userId' parameter + const [userId, userIdErr] = $.type(ID).get(params.userId); + if (userIdErr) return rej('invalid userId param'); + + // Fetch the user + const user = await User.findOne({ + _id: userId + }); + + if (user == null) { + return rej('user not found'); + } + + if (userList.userIds.map(id => id.toHexString()).includes(user._id.toHexString())) { + return rej('the user already added'); + } + + // Push the user + await UserList.update({ _id: userList._id }, { + $push: { + userIds: user._id + } + }); + + res(); + + publishUserListStream(userList._id, 'userAdded', await packUser(user)); + + // このインスタンス内にこのリモートユーザーをフォローしているユーザーがいなくても投稿を受け取るためにダミーのユーザーがフォローしたということにする + if (isRemoteUser(user)) { + const ghost = await getGhost(); + const content = ap(renderFollow(ghost, user)); + deliver(ghost, content, user.inbox); + } +}); diff --git a/src/server/api/endpoints/users/lists/show.ts b/src/server/api/endpoints/users/lists/show.ts new file mode 100644 index 0000000000..16cb3382fd --- /dev/null +++ b/src/server/api/endpoints/users/lists/show.ts @@ -0,0 +1,23 @@ +import $ from 'cafy'; import ID from '../../../../../cafy-id'; +import UserList, { pack } from '../../../../../models/user-list'; + +/** + * Show a user list + */ +module.exports = async (params, me) => new Promise(async (res, rej) => { + // Get 'listId' parameter + const [listId, listIdErr] = $.type(ID).get(params.listId); + if (listIdErr) return rej('invalid listId param'); + + // Fetch the list + const userList = await UserList.findOne({ + _id: listId, + userId: me._id, + }); + + if (userList == null) { + return rej('list not found'); + } + + res(await pack(userList)); +}); diff --git a/src/server/api/endpoints/users/notes.ts b/src/server/api/endpoints/users/notes.ts index bd4247c79c..061c363d0f 100644 --- a/src/server/api/endpoints/users/notes.ts +++ b/src/server/api/endpoints/users/notes.ts @@ -1,7 +1,7 @@ /** * Module dependencies */ -import $ from 'cafy'; +import $ from 'cafy'; import ID from '../../../../cafy-id'; import getHostLower from '../../common/get-host-lower'; import Note, { pack } from '../../../../models/note'; import User from '../../../../models/user'; @@ -11,11 +11,11 @@ import User from '../../../../models/user'; */ module.exports = (params, me) => new Promise(async (res, rej) => { // Get 'userId' parameter - const [userId, userIdErr] = $(params.userId).optional.id().$; + const [userId, userIdErr] = $.type(ID).optional().get(params.userId); if (userIdErr) return rej('invalid userId param'); // Get 'username' parameter - const [username, usernameErr] = $(params.username).optional.string().$; + const [username, usernameErr] = $.str.optional().get(params.username); if (usernameErr) return rej('invalid username param'); if (userId === undefined && username === undefined) { @@ -23,7 +23,7 @@ module.exports = (params, me) => new Promise(async (res, rej) => { } // Get 'host' parameter - const [host, hostErr] = $(params.host).optional.string().$; + const [host, hostErr] = $.str.optional().get(params.host); if (hostErr) return rej('invalid host param'); if (userId === undefined && host === undefined) { @@ -31,31 +31,31 @@ module.exports = (params, me) => new Promise(async (res, rej) => { } // Get 'includeReplies' parameter - const [includeReplies = true, includeRepliesErr] = $(params.includeReplies).optional.boolean().$; + const [includeReplies = true, includeRepliesErr] = $.bool.optional().get(params.includeReplies); if (includeRepliesErr) return rej('invalid includeReplies param'); // Get 'withMedia' parameter - const [withMedia = false, withMediaErr] = $(params.withMedia).optional.boolean().$; + const [withMedia = false, withMediaErr] = $.bool.optional().get(params.withMedia); if (withMediaErr) return rej('invalid withMedia param'); // Get 'limit' parameter - const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$; + const [limit = 10, limitErr] = $.num.optional().range(1, 100).get(params.limit); if (limitErr) return rej('invalid limit param'); // Get 'sinceId' parameter - const [sinceId, sinceIdErr] = $(params.sinceId).optional.id().$; + const [sinceId, sinceIdErr] = $.type(ID).optional().get(params.sinceId); if (sinceIdErr) return rej('invalid sinceId param'); // Get 'untilId' parameter - const [untilId, untilIdErr] = $(params.untilId).optional.id().$; + const [untilId, untilIdErr] = $.type(ID).optional().get(params.untilId); if (untilIdErr) return rej('invalid untilId param'); // Get 'sinceDate' parameter - const [sinceDate, sinceDateErr] = $(params.sinceDate).optional.number().$; + const [sinceDate, sinceDateErr] = $.num.optional().get(params.sinceDate); if (sinceDateErr) throw 'invalid sinceDate param'; // Get 'untilDate' parameter - const [untilDate, untilDateErr] = $(params.untilDate).optional.number().$; + const [untilDate, untilDateErr] = $.num.optional().get(params.untilDate); if (untilDateErr) throw 'invalid untilDate param'; // Check if only one of sinceId, untilId, sinceDate, untilDate specified diff --git a/src/server/api/endpoints/users/recommendation.ts b/src/server/api/endpoints/users/recommendation.ts index e367e65a6c..620ae17ca2 100644 --- a/src/server/api/endpoints/users/recommendation.ts +++ b/src/server/api/endpoints/users/recommendation.ts @@ -4,7 +4,8 @@ const ms = require('ms'); import $ from 'cafy'; import User, { pack } from '../../../../models/user'; -import getFriends from '../../common/get-friends'; +import { getFriendIds } from '../../common/get-friends'; +import Mute from '../../../../models/mute'; /** * Get recommended users @@ -15,23 +16,28 @@ import getFriends from '../../common/get-friends'; */ module.exports = (params, me) => new Promise(async (res, rej) => { // Get 'limit' parameter - const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$; + const [limit = 10, limitErr] = $.num.optional().range(1, 100).get(params.limit); if (limitErr) return rej('invalid limit param'); // Get 'offset' parameter - const [offset = 0, offsetErr] = $(params.offset).optional.number().min(0).$; + const [offset = 0, offsetErr] = $.num.optional().min(0).get(params.offset); if (offsetErr) return rej('invalid offset param'); // ID list of the user itself and other users who the user follows - const followingIds = await getFriends(me._id); + const followingIds = await getFriendIds(me._id); + + // ミュートしているユーザーを取得 + const mutedUserIds = (await Mute.find({ + muterId: me._id + })).map(m => m.muteeId); const users = await User .find({ _id: { - $nin: followingIds + $nin: followingIds.concat(mutedUserIds) }, $or: [{ - 'lastUsedAt': { + lastUsedAt: { $gte: new Date(Date.now() - ms('7days')) } }, { diff --git a/src/server/api/endpoints/users/search.ts b/src/server/api/endpoints/users/search.ts index da30f47c2a..cfbdc337bf 100644 --- a/src/server/api/endpoints/users/search.ts +++ b/src/server/api/endpoints/users/search.ts @@ -16,15 +16,15 @@ const escapeRegexp = require('escape-regexp'); */ module.exports = (params, me) => new Promise(async (res, rej) => { // Get 'query' parameter - const [query, queryError] = $(params.query).string().pipe(x => x != '').$; + const [query, queryError] = $.str.pipe(x => x != '').get(params.query); if (queryError) return rej('invalid query param'); // Get 'offset' parameter - const [offset = 0, offsetErr] = $(params.offset).optional.number().min(0).$; + const [offset = 0, offsetErr] = $.num.optional().min(0).get(params.offset); if (offsetErr) return rej('invalid offset param'); // Get 'max' parameter - const [max = 10, maxErr] = $(params.max).optional.number().range(1, 30).$; + const [max = 10, maxErr] = $.num.optional().range(1, 30).get(params.max); if (maxErr) return rej('invalid max param'); // If Elasticsearch is available, search by $ diff --git a/src/server/api/endpoints/users/search_by_username.ts b/src/server/api/endpoints/users/search_by_username.ts index 5f6ececff9..5927d00faf 100644 --- a/src/server/api/endpoints/users/search_by_username.ts +++ b/src/server/api/endpoints/users/search_by_username.ts @@ -6,22 +6,18 @@ import User, { pack } from '../../../../models/user'; /** * Search a user by username - * - * @param {any} params - * @param {any} me - * @return {Promise<any>} */ module.exports = (params, me) => new Promise(async (res, rej) => { // Get 'query' parameter - const [query, queryError] = $(params.query).string().$; + const [query, queryError] = $.str.get(params.query); if (queryError) return rej('invalid query param'); // Get 'offset' parameter - const [offset = 0, offsetErr] = $(params.offset).optional.number().min(0).$; + const [offset = 0, offsetErr] = $.num.optional().min(0).get(params.offset); if (offsetErr) return rej('invalid offset param'); // Get 'limit' parameter - const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$; + const [limit = 10, limitErr] = $.num.optional().range(1, 100).get(params.limit); if (limitErr) return rej('invalid limit param'); const users = await User diff --git a/src/server/api/endpoints/users/show.ts b/src/server/api/endpoints/users/show.ts index 7e7f5dc488..b8c6ff25c4 100644 --- a/src/server/api/endpoints/users/show.ts +++ b/src/server/api/endpoints/users/show.ts @@ -1,56 +1,68 @@ /** * Module dependencies */ -import $ from 'cafy'; +import $ from 'cafy'; import ID from '../../../../cafy-id'; import User, { pack } from '../../../../models/user'; import resolveRemoteUser from '../../../../remote/resolve-user'; const cursorOption = { fields: { data: false } }; /** - * Show a user + * Show user(s) */ module.exports = (params, me) => new Promise(async (res, rej) => { let user; // Get 'userId' parameter - const [userId, userIdErr] = $(params.userId).optional.id().$; + const [userId, userIdErr] = $.type(ID).optional().get(params.userId); if (userIdErr) return rej('invalid userId param'); + // Get 'userIds' parameter + const [userIds, userIdsErr] = $.arr($.type(ID)).optional().get(params.userIds); + if (userIdsErr) return rej('invalid userIds param'); + // Get 'username' parameter - const [username, usernameErr] = $(params.username).optional.string().$; + const [username, usernameErr] = $.str.optional().get(params.username); if (usernameErr) return rej('invalid username param'); // Get 'host' parameter - const [host, hostErr] = $(params.host).nullable.optional.string().$; + const [host, hostErr] = $.str.optional().nullable().get(params.host); if (hostErr) return rej('invalid host param'); - if (userId === undefined && typeof username !== 'string') { - return rej('userId or pair of username and host is required'); - } + if (userIds) { + const users = await User.find({ + _id: { + $in: userIds + } + }); - // Lookup user - if (typeof host === 'string') { - try { - user = await resolveRemoteUser(username, host, cursorOption); - } catch (e) { - console.warn(`failed to resolve remote user: ${e}`); - return rej('failed to resolve remote user'); - } + res(await Promise.all(users.map(u => pack(u, me, { + detail: true + })))); } else { - const q = userId !== undefined - ? { _id: userId } - : { usernameLower: username.toLowerCase(), host: null }; + // Lookup user + if (typeof host === 'string') { + try { + user = await resolveRemoteUser(username, host, cursorOption); + } catch (e) { + console.warn(`failed to resolve remote user: ${e}`); + return rej('failed to resolve remote user'); + } + } else { + const q = userId !== undefined + ? { _id: userId } + : { usernameLower: username.toLowerCase(), host: null }; - user = await User.findOne(q, cursorOption); + user = await User.findOne(q, cursorOption); - if (user === null) { - return rej('user not found'); + if (user === null) { + return rej('user not found'); + } } - } - // Send response - res(await pack(user, me, { - detail: true - })); + // Send response + res(await pack(user, me, { + detail: true + })); + } }); diff --git a/src/server/api/stream/home.ts b/src/server/api/stream/home.ts index a9d6ff241e..54fde2d776 100644 --- a/src/server/api/stream/home.ts +++ b/src/server/api/stream/home.ts @@ -32,17 +32,17 @@ export default async function( //#region 流れてきたメッセージがミュートしているユーザーが関わるものだったら無視する if (x.type == 'note') { - if (mutedUserIds.indexOf(x.body.userId) != -1) { + if (mutedUserIds.includes(x.body.userId)) { return; } - if (x.body.reply != null && mutedUserIds.indexOf(x.body.reply.userId) != -1) { + if (x.body.reply != null && mutedUserIds.includes(x.body.reply.userId)) { return; } - if (x.body.renote != null && mutedUserIds.indexOf(x.body.renote.userId) != -1) { + if (x.body.renote != null && mutedUserIds.includes(x.body.renote.userId)) { return; } } else if (x.type == 'notification') { - if (mutedUserIds.indexOf(x.body.userId) != -1) { + if (mutedUserIds.includes(x.body.userId)) { return; } } @@ -53,6 +53,7 @@ export default async function( connection.send(data); } break; + case 'note-stream': const noteId = channel.split(':')[2]; log(`RECEIVED: ${noteId} ${data} by @${user.username}`); @@ -69,12 +70,13 @@ export default async function( } }); - connection.on('message', data => { + connection.on('message', async data => { const msg = JSON.parse(data.utf8Data); switch (msg.type) { case 'api': - call(msg.endpoint, user, app, msg.data).then(res => { + // 新鮮なデータを利用するためにユーザーをフェッチ + call(msg.endpoint, await User.findOne({ _id: user._id }), app, msg.data).then(res => { connection.send(JSON.stringify({ type: `api-res:${msg.id}`, body: { res } diff --git a/src/server/api/stream/user-list.ts b/src/server/api/stream/user-list.ts new file mode 100644 index 0000000000..ba03b97860 --- /dev/null +++ b/src/server/api/stream/user-list.ts @@ -0,0 +1,14 @@ +import * as websocket from 'websocket'; +import * as redis from 'redis'; +import { ParsedUrlQuery } from 'querystring'; + +export default function(request: websocket.request, connection: websocket.connection, subscriber: redis.RedisClient, user: any): void { + const q = request.resourceURL.query as ParsedUrlQuery; + const listId = q.listId as string; + + // Subscribe stream + subscriber.subscribe(`misskey:user-list-stream:${listId}`); + subscriber.on('message', (_, data) => { + connection.send(data); + }); +} diff --git a/src/server/api/streaming.ts b/src/server/api/streaming.ts index ce13253649..e4884ed7c4 100644 --- a/src/server/api/streaming.ts +++ b/src/server/api/streaming.ts @@ -6,6 +6,7 @@ import config from '../../config'; import homeStream from './stream/home'; import localTimelineStream from './stream/local-timeline'; import globalTimelineStream from './stream/global-timeline'; +import userListStream from './stream/user-list'; import driveStream from './stream/drive'; import messagingStream from './stream/messaging'; import messagingIndexStream from './stream/messaging-index'; @@ -70,6 +71,7 @@ module.exports = (server: http.Server) => { request.resourceURL.pathname === '/' ? homeStream : request.resourceURL.pathname === '/local-timeline' ? localTimelineStream : request.resourceURL.pathname === '/global-timeline' ? globalTimelineStream : + request.resourceURL.pathname === '/user-list' ? userListStream : request.resourceURL.pathname === '/drive' ? driveStream : request.resourceURL.pathname === '/messaging' ? messagingStream : request.resourceURL.pathname === '/messaging-index' ? messagingIndexStream : diff --git a/src/server/file/assets/cache-expired.png b/src/server/file/assets/cache-expired.png Binary files differnew file mode 100644 index 0000000000..ea681af0a0 --- /dev/null +++ b/src/server/file/assets/cache-expired.png diff --git a/src/server/file/assets/tombstone.png b/src/server/file/assets/tombstone.png Binary files differnew file mode 100644 index 0000000000..86224e3182 --- /dev/null +++ b/src/server/file/assets/tombstone.png diff --git a/src/server/file/index.ts b/src/server/file/index.ts index 29056c63e7..973528da33 100644 --- a/src/server/file/index.ts +++ b/src/server/file/index.ts @@ -6,7 +6,6 @@ import * as fs from 'fs'; import * as Koa from 'koa'; import * as cors from '@koa/cors'; import * as Router from 'koa-router'; -import pour from './pour'; import sendDriveFile from './send-drive-file'; // Init app @@ -24,12 +23,14 @@ const router = new Router(); router.get('/default-avatar.jpg', ctx => { const file = fs.createReadStream(`${__dirname}/assets/avatar.jpg`); - pour(file, 'image/jpeg', ctx); + ctx.set('Content-Type', 'image/jpeg'); + ctx.body = file; }); router.get('/app-default.jpg', ctx => { const file = fs.createReadStream(`${__dirname}/assets/dummy.png`); - pour(file, 'image/png', ctx); + ctx.set('Content-Type', 'image/jpeg'); + ctx.body = file; }); router.get('/:id', sendDriveFile); diff --git a/src/server/file/pour.ts b/src/server/file/pour.ts deleted file mode 100644 index 0fd0ad0e60..0000000000 --- a/src/server/file/pour.ts +++ /dev/null @@ -1,88 +0,0 @@ -import * as fs from 'fs'; -import * as stream from 'stream'; -import * as Koa from 'koa'; -import * as Gm from 'gm'; - -const gm = Gm.subClass({ - imageMagick: true -}); - -interface ISend { - contentType: string; - stream: stream.Readable; -} - -function thumbnail(data: stream.Readable, type: string, resize: number): ISend { - const readable: stream.Readable = (() => { - // 動画であれば - if (/^video\/.*$/.test(type)) { - // TODO - // 使わないことになったストリームはしっかり取り壊す - data.destroy(); - return fs.createReadStream(`${__dirname}/assets/thumbnail-not-available.png`); - // 画像であれば - // Note: SVGはapplication/xml - } else if (/^image\/.*$/.test(type) || type == 'application/xml') { - // 0フレーム目を送る - try { - return gm(data).selectFrame(0).stream(); - // だめだったら - } catch (e) { - // 使わないことになったストリームはしっかり取り壊す - data.destroy(); - return fs.createReadStream(`${__dirname}/assets/thumbnail-not-available.png`); - } - // 動画か画像以外 - } else { - data.destroy(); - return fs.createReadStream(`${__dirname}/assets/not-an-image.png`); - } - })(); - - let g = gm(readable); - - if (resize) { - g = g.resize(resize, resize); - } - - const stream = g - .compress('jpeg') - .quality(80) - .interlace('line') - .stream(); - - return { - contentType: 'image/jpeg', - stream - }; -} - -const commonReadableHandlerGenerator = (ctx: Koa.Context) => (e: Error): void => { - console.error(e); - ctx.status = 500; -}; - -export default function(readable: stream.Readable, type: string, ctx: Koa.Context): void { - readable.on('error', commonReadableHandlerGenerator(ctx)); - - const data = ((): ISend => { - if (ctx.query.thumbnail !== undefined) { - return thumbnail(readable, type, ctx.query.size); - } - return { - contentType: type, - stream: readable - }; - })(); - - if (readable !== data.stream) { - data.stream.on('error', commonReadableHandlerGenerator(ctx)); - } - - if (ctx.query.download !== undefined) { - ctx.set('Content-Disposition', 'attachment'); - } - - ctx.set('Content-Type', data.contentType); - ctx.body = data.stream; -} diff --git a/src/server/file/send-drive-file.ts b/src/server/file/send-drive-file.ts index e6ee19ff1d..d613a3aa5f 100644 --- a/src/server/file/send-drive-file.ts +++ b/src/server/file/send-drive-file.ts @@ -1,8 +1,17 @@ +import * as fs from 'fs'; + import * as Koa from 'koa'; import * as send from 'koa-send'; import * as mongodb from 'mongodb'; -import DriveFile, { getGridFSBucket } from '../../models/drive-file'; -import pour from './pour'; +import DriveFile, { getDriveFileBucket } from '../../models/drive-file'; +import DriveFileThumbnail, { getDriveFileThumbnailBucket } from '../../models/drive-file-thumbnail'; + +const assets = `${__dirname}/../../server/file/assets/`; + +const commonReadableHandlerGenerator = (ctx: Koa.Context) => (e: Error): void => { + console.error(e); + ctx.status = 500; +}; export default async function(ctx: Koa.Context) { // Validate id @@ -18,13 +27,52 @@ export default async function(ctx: Koa.Context) { if (file == null) { ctx.status = 404; - await send(ctx, `${__dirname}/assets/dummy.png`); + await send(ctx, '/dummy.png', { root: assets }); return; } - const bucket = await getGridFSBucket(); + if (file.metadata.deletedAt) { + ctx.status = 410; + if (file.metadata.isExpired) { + await send(ctx, '/cache-expired.png', { root: assets }); + } else { + await send(ctx, '/tombstone.png', { root: assets }); + } + return; + } + + const sendRaw = async () => { + const bucket = await getDriveFileBucket(); + const readable = bucket.openDownloadStream(fileId); + readable.on('error', commonReadableHandlerGenerator(ctx)); + ctx.set('Content-Type', file.contentType); + ctx.body = readable; + }; - const readable = bucket.openDownloadStream(fileId); + if ('thumbnail' in ctx.query) { + // 画像以外 + if (!file.contentType.startsWith('image/')) { + const readable = fs.createReadStream(`${__dirname}/assets/thumbnail-not-available.png`); + ctx.set('Content-Type', 'image/png'); + ctx.body = readable; + } else if (file.contentType == 'image/gif') { + // GIF + await sendRaw(); + } else { + const thumb = await DriveFileThumbnail.findOne({ 'metadata.originalId': fileId }); + if (thumb != null) { + ctx.set('Content-Type', 'image/jpeg'); + const bucket = await getDriveFileThumbnailBucket(); + ctx.body = bucket.openDownloadStream(thumb._id); + } else { + await sendRaw(); + } + } + } else { + if ('download' in ctx.query) { + ctx.set('Content-Disposition', 'attachment'); + } - pour(readable, file.contentType, ctx); + await sendRaw(); + } } diff --git a/src/server/index.ts b/src/server/index.ts index 5db3da2b93..ded8f7706e 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -4,12 +4,15 @@ import * as fs from 'fs'; import * as http from 'http'; -import * as http2 from 'http2'; +import * as https from 'https'; +//import * as http2 from 'http2'; import * as zlib from 'zlib'; import * as Koa from 'koa'; import * as Router from 'koa-router'; import * as mount from 'koa-mount'; import * as compress from 'koa-compress'; +import * as logger from 'koa-logger'; +//const slow = require('koa-slow'); import activityPub from './activitypub'; import webFinger from './webfinger'; @@ -19,6 +22,17 @@ import config from '../config'; const app = new Koa(); app.proxy = true; +if (process.env.NODE_ENV != 'production') { + // Logger + app.use(logger()); + + // Delay + //app.use(slow({ + // delay: 1000 + //})); +} + +// Compress response app.use(compress({ flush: zlib.constants.Z_SYNC_FLUSH })); @@ -54,7 +68,8 @@ function createServer() { certs[k] = fs.readFileSync(config.https[k]); }); certs['allowHTTP1'] = true; - return http2.createSecureServer(certs, app.callback()); + //return http2.createSecureServer(certs, app.callback()); + return https.createServer(certs, app.callback()); } else { return http.createServer(app.callback()); } diff --git a/src/server/web/index.ts b/src/server/web/index.ts index eba0c372b0..6ceef17c1c 100644 --- a/src/server/web/index.ts +++ b/src/server/web/index.ts @@ -7,14 +7,32 @@ import * as Koa from 'koa'; import * as Router from 'koa-router'; import * as send from 'koa-send'; import * as favicon from 'koa-favicon'; +import * as views from 'koa-views'; import docs from './docs'; +import User from '../../models/user'; +import parseAcct from '../../acct/parse'; +import { fa } from '../../build/fa'; +import config from '../../config'; +import Note, { pack as packNote } from '../../models/note'; +import getNoteSummary from '../../renderers/get-note-summary'; +const consts = require('../../const.json'); const client = `${__dirname}/../../client/`; // Init app const app = new Koa(); +// Init renderer +app.use(views(__dirname + '/views', { + extension: 'pug', + options: { + config, + themeColor: consts.themeColor, + facss: fa.dom.css() + } +})); + // Serve favicon app.use(favicon(`${client}/assets/favicon.ico`)); @@ -42,17 +60,21 @@ router.get('/assets/*', async ctx => { // Apple touch icon router.get('/apple-touch-icon.png', async ctx => { - await send(ctx, `${client}/assets/apple-touch-icon.png`); + await send(ctx, '/assets/apple-touch-icon.png', { + root: client + }); }); // ServiceWroker -router.get(/^\/sw\.(.+?)\.js$/, async ctx => { - await send(ctx, `${client}/assets/sw.${ctx.params[0]}.js`); -}); +//router.get(/^\/sw\.(.+?)\.js$/, async ctx => { +// await send(ctx, `${client}/assets/sw.${ctx.params[0]}.js`); +//}); // Manifest router.get('/manifest.json', async ctx => { - await send(ctx, `${client}/assets/manifest.json`); + await send(ctx, '/assets/manifest.json', { + root: client + }); }); //#endregion @@ -61,7 +83,40 @@ router.get('/manifest.json', async ctx => { router.use('/docs', docs.routes()); // URL preview endpoint -router.get('url', require('./url-preview')); +router.get('/url', require('./url-preview')); + +//#region for crawlers +// User +router.get('/@:user', async (ctx, next) => { + const { username, host } = parseAcct(ctx.params.user); + const user = await User.findOne({ + usernameLower: username.toLowerCase(), + host + }); + + if (user != null) { + await ctx.render('user', { user }); + } else { + // リモートユーザーなので + await next(); + } +}); + +// Note +router.get('/notes/:note', async ctx => { + const note = await Note.findOne({ _id: ctx.params.note }); + + if (note != null) { + const _note = await packNote(note); + await ctx.render('note', { + note: _note, + summary: getNoteSummary(_note) + }); + } else { + ctx.status = 404; + } +}); +//#endregion // Render base html for all requests router.get('*', async ctx => { diff --git a/src/server/web/url-preview.ts b/src/server/web/url-preview.ts index d5464d0cd4..cd53837a25 100644 --- a/src/server/web/url-preview.ts +++ b/src/server/web/url-preview.ts @@ -14,6 +14,8 @@ module.exports = async (ctx: Koa.Context) => { function wrap(url: string): string { return url != null - ? `https://images.weserv.nl/?url=${url.replace(/^https?:\/\//, '')}` + ? url.startsWith('https://') || url.startsWith('data:') + ? url + : `https://images.weserv.nl/?url=${encodeURIComponent(url.replace(/^http:\/\//, ''))}` : null; } diff --git a/src/server/web/views/note.pug b/src/server/web/views/note.pug new file mode 100644 index 0000000000..22f1834059 --- /dev/null +++ b/src/server/web/views/note.pug @@ -0,0 +1,25 @@ +extends ../../../../src/client/app/base + +block vars + - const user = note.user; + - const title = user.name ? `${user.name} (@${user.username})` : `@${user.username}`; + - const url = `${config.url}/notes/${note.id}`; + +block title + = `${title} | Misskey` + +block desc + meta(name='description' content= summary) + +block meta + meta(name='twitter:card' content='summary') + meta(property='og:type' content='article') + meta(property='og:title' content= title) + meta(property='og:description' content= summary) + meta(property='og:url' content= url) + meta(property='og:image' content= user.avatarUrl) + + if note.prev + link(rel='prev' href=`${config.url}/notes/${note.prev}`) + if note.next + link(rel='next' href=`${config.url}/notes/${note.next}`) diff --git a/src/server/web/views/user.pug b/src/server/web/views/user.pug new file mode 100644 index 0000000000..b5ea2f6eb4 --- /dev/null +++ b/src/server/web/views/user.pug @@ -0,0 +1,20 @@ +extends ../../../../src/client/app/base + +block vars + - const title = user.name ? `${user.name} (@${user.username})` : `@${user.username}`; + - const url = config.url + '/@' + (user.host ? `${user.username}@${user.host}` : user.username); + - const img = user.avatarId ? `${config.drive_url}/${user.avatarId}` : null; + +block title + = `${title} | Misskey` + +block desc + meta(name='description' content= user.description) + +block meta + meta(name='twitter:card' content='summary') + meta(property='og:type' content='blog') + meta(property='og:title' content= title) + meta(property='og:description' content= user.description) + meta(property='og:url' content= url) + meta(property='og:image' content= img) diff --git a/src/server/webfinger.ts b/src/server/webfinger.ts index e72592351b..ce0cb82fe2 100644 --- a/src/server/webfinger.ts +++ b/src/server/webfinger.ts @@ -1,8 +1,9 @@ +import * as mongo from 'mongodb'; import * as Router from 'koa-router'; import config from '../config'; import parseAcct from '../acct/parse'; -import User from '../models/user'; +import User, { IUser } from '../models/user'; // Init router const router = new Router(); @@ -14,27 +15,38 @@ router.get('/.well-known/webfinger', async ctx => { } const resourceLower = ctx.query.resource.toLowerCase(); - const webPrefix = config.url.toLowerCase() + '/@'; let acctLower; + let id; - if (resourceLower.startsWith(webPrefix)) { - acctLower = resourceLower.slice(webPrefix.length); + if (resourceLower.startsWith(config.url.toLowerCase() + '/@')) { + acctLower = resourceLower.split('/').pop(); + } else if (resourceLower.startsWith(config.url.toLowerCase() + '/users/')) { + id = new mongo.ObjectID(resourceLower.split('/').pop()); } else if (resourceLower.startsWith('acct:')) { acctLower = resourceLower.slice('acct:'.length); } else { acctLower = resourceLower; } - const parsedAcctLower = parseAcct(acctLower); - if (![null, config.host.toLowerCase()].includes(parsedAcctLower.host)) { - ctx.status = 422; - return; - } + let user: IUser; + + if (acctLower) { + const parsedAcctLower = parseAcct(acctLower); + if (![null, config.host.toLowerCase()].includes(parsedAcctLower.host)) { + ctx.status = 422; + return; + } - const user = await User.findOne({ - usernameLower: parsedAcctLower.username, - host: null - }); + user = await User.findOne({ + usernameLower: parsedAcctLower.username, + host: null + }); + } else { + user = await User.findOne({ + _id: id, + host: null + }); + } if (user === null) { ctx.status = 404; diff --git a/src/services/drive/add-file.ts b/src/services/drive/add-file.ts index 30aae24ba6..efabe345d1 100644 --- a/src/services/drive/add-file.ts +++ b/src/services/drive/add-file.ts @@ -10,12 +10,14 @@ import * as debug from 'debug'; import fileType = require('file-type'); import prominence = require('prominence'); -import DriveFile, { IMetadata, getGridFSBucket, IDriveFile } from '../../models/drive-file'; +import DriveFile, { IMetadata, getDriveFileBucket, IDriveFile, DriveFileChunk } from '../../models/drive-file'; import DriveFolder from '../../models/drive-folder'; import { pack } from '../../models/drive-file'; import event, { publishDriveStream } from '../../publishers/stream'; import getAcct from '../../acct/render'; -import { IUser } from '../../models/user'; +import { IUser, isLocalUser, isRemoteUser } from '../../models/user'; +import DriveFileThumbnail, { getDriveFileThumbnailBucket, DriveFileThumbnailChunk } from '../../models/drive-file-thumbnail'; +import genThumbnail from '../../drive/gen-thumbnail'; const gm = _gm.subClass({ imageMagick: true @@ -30,8 +32,8 @@ const tmpFile = (): Promise<[string, any]> => new Promise((resolve, reject) => { }); }); -const addToGridFS = (name: string, readable: stream.Readable, type: string, metadata: any): Promise<any> => - getGridFSBucket() +const writeChunks = (name: string, readable: stream.Readable, type: string, metadata: any) => + getDriveFileBucket() .then(bucket => new Promise((resolve, reject) => { const writeStream = bucket.openUploadStream(name, { contentType: type, metadata }); writeStream.once('finish', resolve); @@ -39,6 +41,20 @@ const addToGridFS = (name: string, readable: stream.Readable, type: string, meta readable.pipe(writeStream); })); +const writeThumbnailChunks = (name: string, readable: stream.Readable, originalId) => + getDriveFileThumbnailBucket() + .then(bucket => new Promise((resolve, reject) => { + const writeStream = bucket.openUploadStream(name, { + contentType: 'image/jpeg', + metadata: { + originalId + } + }); + writeStream.once('finish', resolve); + writeStream.on('error', reject); + readable.pipe(writeStream); + })); + const addFile = async ( user: IUser, path: string, @@ -46,6 +62,7 @@ const addFile = async ( comment: string = null, folderId: mongodb.ObjectID = null, force: boolean = false, + url: string = null, uri: string = null ): Promise<IDriveFile> => { log(`registering ${name} (user: ${getAcct(user)}, path: ${path})`); @@ -101,7 +118,8 @@ const addFile = async ( // Check if there is a file with the same hash const much = await DriveFile.findOne({ md5: hash, - 'metadata.userId': user._id + 'metadata.userId': user._id, + 'metadata.deletedAt': { $exists: false } }); if (much !== null) { @@ -185,7 +203,10 @@ const addFile = async ( // Calculate drive usage const usage = await DriveFile .aggregate([{ - $match: { 'metadata.userId': user._id } + $match: { + 'metadata.userId': user._id, + 'metadata.deletedAt': { $exists: false } + } }, { $project: { length: true @@ -207,7 +228,49 @@ const addFile = async ( // If usage limit exceeded if (usage + size > user.driveCapacity) { - throw 'no-free-space'; + if (isLocalUser(user)) { + throw 'no-free-space'; + } else { + //#region (アバターまたはバナーを含まず)最も古いファイルを削除する + const oldFile = await DriveFile.findOne({ + _id: { + $nin: [user.avatarId, user.bannerId] + } + }, { + sort: { + _id: 1 + } + }); + + if (oldFile) { + // チャンクをすべて削除 + DriveFileChunk.remove({ + files_id: oldFile._id + }); + + DriveFile.update({ _id: oldFile._id }, { + $set: { + 'metadata.deletedAt': new Date(), + 'metadata.isExpired': true + } + }); + + //#region サムネイルもあれば削除 + const thumbnail = await DriveFileThumbnail.findOne({ + 'metadata.originalId': oldFile._id + }); + + if (thumbnail) { + DriveFileThumbnailChunk.remove({ + files_id: thumbnail._id + }); + + DriveFileThumbnail.remove({ _id: thumbnail._id }); + } + //#endregion + } + //#endregion + } } })() ]); @@ -227,16 +290,34 @@ const addFile = async ( const metadata = { userId: user._id, + _user: { + host: user.host + }, folderId: folder !== null ? folder._id : null, comment: comment, properties: properties } as IMetadata; + if (url !== null) { + metadata.url = url; + } + if (uri !== null) { metadata.uri = uri; } - return addToGridFS(detectedName, readable, mime, metadata); + const file = await (writeChunks(detectedName, readable, mime, metadata) as Promise<IDriveFile>); + + try { + const thumb = await genThumbnail(file); + if (thumb) { + await writeThumbnailChunks(detectedName, thumb, file._id); + } + } catch (e) { + // noop + } + + return file; }; /** diff --git a/src/services/drive/upload-from-url.ts b/src/services/drive/upload-from-url.ts index 08e0397706..ad2620c036 100644 --- a/src/services/drive/upload-from-url.ts +++ b/src/services/drive/upload-from-url.ts @@ -43,7 +43,7 @@ export default async (url, user, folderId = null, uri = null): Promise<IDriveFil let error; try { - driveFile = await create(user, path, name, null, folderId, false, uri); + driveFile = await create(user, path, name, null, folderId, false, url, uri); log(`created: ${driveFile._id}`); } catch (e) { error = e; diff --git a/src/services/following/create.ts b/src/services/following/create.ts index 375b028912..3424c55dae 100644 --- a/src/services/following/create.ts +++ b/src/services/following/create.ts @@ -13,7 +13,18 @@ export default async function(follower: IUser, followee: IUser, activity?) { const following = await Following.insert({ createdAt: new Date(), followerId: follower._id, - followeeId: followee._id + followeeId: followee._id, + stalk: true, + + // 非正規化 + _follower: { + host: follower.host, + inbox: isRemoteUser(follower) ? follower.inbox : undefined + }, + _followee: { + host: followee.host, + inbox: isRemoteUser(followee) ? followee.inbox : undefined + } }); //#region Increment following count diff --git a/src/services/note/create.ts b/src/services/note/create.ts index e35e5ecfbd..f049c34b65 100644 --- a/src/services/note/create.ts +++ b/src/services/note/create.ts @@ -1,6 +1,6 @@ import Note, { pack, INote } from '../../models/note'; -import User, { isLocalUser, IUser, isRemoteUser } from '../../models/user'; -import stream, { publishLocalTimelineStream, publishGlobalTimelineStream } from '../../publishers/stream'; +import User, { isLocalUser, IUser, isRemoteUser, IRemoteUser, ILocalUser } from '../../models/user'; +import stream, { publishLocalTimelineStream, publishGlobalTimelineStream, publishUserListStream } from '../../publishers/stream'; import Following from '../../models/following'; import { deliver } from '../../queue'; import renderNote from '../../remote/activitypub/renderer/note'; @@ -15,8 +15,64 @@ import Mute from '../../models/mute'; import pushSw from '../../publishers/push-sw'; import event from '../../publishers/stream'; import parse from '../../text/parse'; -import html from '../../text/html'; import { IApp } from '../../models/app'; +import UserList from '../../models/user-list'; +import resolveUser from '../../remote/resolve-user'; + +type Reason = 'reply' | 'quote' | 'mention'; + +/** + * ServiceWorkerへの通知を担当 + */ +class NotificationManager { + private user: IUser; + private note: any; + private list: Array<{ + user: ILocalUser['_id'], + reason: Reason; + }> = []; + + constructor(user, note) { + this.user = user; + this.note = note; + } + + public push(user: ILocalUser['_id'], reason: Reason) { + // 自分自身へは通知しない + if (this.user._id.equals(user)) return; + + const exist = this.list.find(x => x.user.equals(user)); + + if (exist) { + // 「メンションされているかつ返信されている」場合は、メンションとしての通知ではなく返信としての通知にする + if (reason != 'mention') { + exist.reason = reason; + } + } else { + this.list.push({ + user, reason + }); + } + } + + public deliver() { + this.list.forEach(async x => { + const mentionee = x.user; + + // ミュート情報を取得 + const mentioneeMutes = await Mute.find({ + muterId: mentionee + }); + + const mentioneesMutedUserIds = mentioneeMutes.map(m => m.muteeId.toString()); + + // 通知される側のユーザーが通知する側のユーザーをミュートしていない限りは通知する + if (!mentioneesMutedUserIds.includes(this.user._id.toString())) { + pushSw(mentionee, x.reason, this.note); + } + }); + } +} export default async (user: IUser, data: { createdAt?: Date; @@ -30,6 +86,7 @@ export default async (user: IUser, data: { tags?: string[]; cw?: string; visibility?: string; + visibleUsers?: IUser[]; uri?: string; app?: IApp; }, silent = false) => new Promise<INote>(async (res, rej) => { @@ -39,7 +96,7 @@ export default async (user: IUser, data: { const tags = data.tags || []; - let tokens = null; + let tokens: any[] = null; if (data.text) { // Analyze @@ -57,21 +114,29 @@ export default async (user: IUser, data: { }); } + if (data.visibleUsers) { + data.visibleUsers = data.visibleUsers.filter(x => x != null); + } + const insert: any = { createdAt: data.createdAt, mediaIds: data.media ? data.media.map(file => file._id) : [], replyId: data.reply ? data.reply._id : null, renoteId: data.renote ? data.renote._id : null, text: data.text, - textHtml: tokens === null ? null : html(tokens), poll: data.poll, - cw: data.cw, + cw: data.cw == null ? null : data.cw, tags, userId: user._id, viaMobile: data.viaMobile, geo: data.geo || null, appId: data.app ? data.app._id : null, visibility: data.visibility, + visibleUserIds: data.visibility == 'specified' + ? data.visibleUsers + ? data.visibleUsers.map(u => u._id) + : [] + : [], // 以下非正規化データ _reply: data.reply ? { userId: data.reply.userId } : null, @@ -85,143 +150,167 @@ export default async (user: IUser, data: { if (data.uri != null) insert.uri = data.uri; // 投稿を作成 - const note = await Note.insert(insert); + let note: INote; + try { + note = await Note.insert(insert); + } catch (e) { + // duplicate key error + if (e.code === 11000) { + return res(null); + } + + console.error(e); + return rej('something happened'); + } res(note); + // Increment notes count User.update({ _id: user._id }, { - // Increment notes count $inc: { notesCount: 1 - }, - // Update latest note - $set: { - latestNote: note } }); // Serialize const noteObj = await pack(note); - // タイムラインへの投稿 - if (note.channelId == null) { + const nm = new NotificationManager(user, noteObj); + + const render = async () => { + const content = data.renote && data.text == null + ? renderAnnounce(data.renote.uri ? data.renote.uri : await renderNote(data.renote)) + : renderCreate(await renderNote(note)); + return packAp(content); + }; + + if (!silent) { if (isLocalUser(user)) { - // Publish event to myself's stream - stream(note.userId, 'note', noteObj); + if (note.visibility == 'private' || note.visibility == 'followers' || note.visibility == 'specified') { + // Publish event to myself's stream + stream(note.userId, 'note', await pack(note, user, { + detail: true + })); + } else { + // Publish event to myself's stream + stream(note.userId, 'note', noteObj); - // Publish note to local timeline stream - publishLocalTimelineStream(noteObj); + // Publish note to local timeline stream + if (note.visibility != 'home') { + publishLocalTimelineStream(noteObj); + } + } } // Publish note to global timeline stream publishGlobalTimelineStream(noteObj); - // Fetch all followers - const followers = await Following.aggregate([{ - $lookup: { - from: 'users', - localField: 'followerId', - foreignField: '_id', - as: 'user' - } - }, { - $match: { + if (note.visibility == 'specified') { + data.visibleUsers.forEach(async u => { + stream(u._id, 'note', await pack(note, u, { + detail: true + })); + }); + } + + if (note.visibility == 'public' || note.visibility == 'home' || note.visibility == 'followers') { + // フォロワーに配信 + Following.find({ followeeId: note.userId - } - }], { - _id: false - }); + }).then(followers => { + followers.map(async following => { + const follower = following._follower; - if (!silent) { - const render = async () => { - const content = data.renote && data.text == null - ? renderAnnounce(data.renote.uri ? data.renote.uri : await renderNote(data.renote)) - : renderCreate(await renderNote(note)); - return packAp(content); - }; + if (isLocalUser(follower)) { + // ストーキングしていない場合 + if (!following.stalk) { + // この投稿が返信ならスキップ + if (note.replyId && !note._reply.userId.equals(following.followerId) && !note._reply.userId.equals(note.userId)) return; + } - // 投稿がリプライかつ投稿者がローカルユーザーかつリプライ先の投稿の投稿者がリモートユーザーなら配送 - if (data.reply && isLocalUser(user) && isRemoteUser(data.reply._user)) { - deliver(user, await render(), data.reply._user.inbox); - } + // Publish event to followers stream + stream(following.followerId, 'note', noteObj); + } else { + //#region AP配送 + // フォロワーがリモートユーザーかつ投稿者がローカルユーザーなら投稿を配信 + if (isLocalUser(user)) { + deliver(user, await render(), follower.inbox); + } + //#endergion + } + }); + }); + } - // 投稿がRenoteかつ投稿者がローカルユーザーかつRenote元の投稿の投稿者がリモートユーザーなら配送 - if (data.renote && isLocalUser(user) && isRemoteUser(data.renote._user)) { - deliver(user, await render(), data.renote._user.inbox); - } + // リストに配信 + UserList.find({ + userIds: note.userId + }).then(lists => { + lists.forEach(list => { + publishUserListStream(list._id, 'note', noteObj); + }); + }); + } - Promise.all(followers.map(async follower => { - follower = follower.user[0]; + //#region リプライとAnnounceのAP配送 - if (isLocalUser(follower)) { - // Publish event to followers stream - stream(follower._id, 'note', noteObj); - } else { - // フォロワーがリモートユーザーかつ投稿者がローカルユーザーなら投稿を配信 - if (isLocalUser(user)) { - deliver(user, await render(), follower.inbox); - } - } - })); - } + // 投稿がリプライかつ投稿者がローカルユーザーかつリプライ先の投稿の投稿者がリモートユーザーなら配送 + if (data.reply && isLocalUser(user) && isRemoteUser(data.reply._user)) { + deliver(user, await render(), data.reply._user.inbox); } - // チャンネルへの投稿 - /* TODO - if (note.channelId) { - promises.push( - // Increment channel index(notes count) - Channel.update({ _id: note.channelId }, { - $inc: { - index: 1 - } - }), + // 投稿がRenoteかつ投稿者がローカルユーザーかつRenote元の投稿の投稿者がリモートユーザーなら配送 + if (data.renote && isLocalUser(user) && isRemoteUser(data.renote._user)) { + deliver(user, await render(), data.renote._user.inbox); + } + //#endergion - // Publish event to channel - promisedNoteObj.then(noteObj => { - publishChannelStream(note.channelId, 'note', noteObj); - }), + //#region メンション + if (data.text) { + // TODO: Drop dupulicates + const mentions = tokens + .filter(t => t.type == 'mention'); - Promise.all([ - promisedNoteObj, + let mentionedUsers = await Promise.all(mentions.map(async m => { + try { + return await resolveUser(m.username, m.host); + } catch (e) { + return null; + } + })); - // Get channel watchers - ChannelWatching.find({ - channelId: note.channelId, - // 削除されたドキュメントは除く - deletedAt: { $exists: false } - }) - ]).then(([noteObj, watches]) => { - // チャンネルの視聴者(のタイムライン)に配信 - watches.forEach(w => { - stream(w.userId, 'note', noteObj); - }); - }) - ); - }*/ + // TODO: Drop dupulicates + mentionedUsers = mentionedUsers.filter(x => x != null); - const mentions = []; + mentionedUsers.filter(u => isLocalUser(u)).forEach(async u => { + // 既に言及されたユーザーに対する返信や引用renoteの場合も無視 + if (data.reply && data.reply.userId.equals(u._id)) return; + if (data.renote && data.renote.userId.equals(u._id)) return; - async function addMention(mentionee, reason) { - // Reject if already added - if (mentions.some(x => x.equals(mentionee))) return; + // Create notification + notify(u._id, user._id, 'mention', { + noteId: note._id + }); - // Add mention - mentions.push(mentionee); + nm.push(u._id, 'mention'); + }); - // Publish event - if (!user._id.equals(mentionee)) { - const mentioneeMutes = await Mute.find({ - muter_id: mentionee, - deleted_at: { $exists: false } + if (isLocalUser(user)) { + mentionedUsers.filter(u => isRemoteUser(u)).forEach(async u => { + deliver(user, await render(), (u as IRemoteUser).inbox); + }); + } + + // Append mentions data + if (mentionedUsers.length > 0) { + Note.update({ _id: note._id }, { + $set: { + mentions: mentionedUsers.map(u => u._id) + } }); - const mentioneesMutedUserIds = mentioneeMutes.map(m => m.muteeId.toString()); - if (mentioneesMutedUserIds.indexOf(user._id.toString()) == -1) { - event(mentionee, reason, noteObj); - pushSw(mentionee, reason, noteObj); - } } } + //#endregion // If has in reply to note if (data.reply) { @@ -260,8 +349,7 @@ export default async (user: IUser, data: { watch(user._id, data.reply); } - // Add mention - addMention(data.reply.userId, 'reply'); + nm.push(data.reply.userId, 'reply'); } // If it is renote @@ -296,7 +384,7 @@ export default async (user: IUser, data: { // If it is quote renote if (data.text) { // Add mention - addMention(data.renote.userId, 'quote'); + nm.push(data.renote.userId, 'quote'); } else { // Publish event if (!user._id.equals(data.renote.userId)) { @@ -304,14 +392,17 @@ export default async (user: IUser, data: { } } + //#region TODO: これ重い // 今までで同じ投稿をRenoteしているか - const existRenote = await Note.findOne({ - userId: user._id, - renoteId: data.renote._id, - _id: { - $ne: note._id - } - }); + //const existRenote = await Note.findOne({ + // userId: user._id, + // renoteId: data.renote._id, + // _id: { + // $ne: note._id + // } + //}); + const existRenote = null; + //#endregion if (!existRenote) { // Update renoteee status @@ -322,48 +413,4 @@ export default async (user: IUser, data: { }); } } - - // If has text content - if (data.text) { - // Extract an '@' mentions - const atMentions = tokens - .filter(t => t.type == 'mention') - .map(m => m.username) - // Drop dupulicates - .filter((v, i, s) => s.indexOf(v) == i); - - // Resolve all mentions - await Promise.all(atMentions.map(async mention => { - // Fetch mentioned user - // SELECT _id - const mentionee = await User - .findOne({ - usernameLower: mention.toLowerCase() - }, { _id: true }); - - // When mentioned user not found - if (mentionee == null) return; - - // 既に言及されたユーザーに対する返信や引用renoteの場合も無視 - if (data.reply && data.reply.userId.equals(mentionee._id)) return; - if (data.renote && data.renote.userId.equals(mentionee._id)) return; - - // Add mention - addMention(mentionee._id, 'mention'); - - // Create notification - notify(mentionee._id, user._id, 'mention', { - noteId: note._id - }); - })); - } - - // Append mentions data - if (mentions.length > 0) { - Note.update({ _id: note._id }, { - $set: { - mentions - } - }); - } }); diff --git a/src/services/note/reaction/create.ts b/src/services/note/reaction/create.ts index dd3d4be8b7..123c091c85 100644 --- a/src/services/note/reaction/create.ts +++ b/src/services/note/reaction/create.ts @@ -9,7 +9,6 @@ import watch from '../watch'; import renderLike from '../../../remote/activitypub/renderer/like'; import { deliver } from '../../../queue'; import pack from '../../../remote/activitypub/renderer'; -import { MongoError } from 'mongodb'; export default async (user: IUser, note: INote, reaction: string) => new Promise(async (res, rej) => { // Myself @@ -27,8 +26,8 @@ export default async (user: IUser, note: INote, reaction: string) => new Promise }); } catch (e) { // duplicate key error - if (e instanceof MongoError && e.code === 11000) { - return rej('already reacted'); + if (e.code === 11000) { + return res(null); } console.error(e); @@ -47,11 +46,13 @@ export default async (user: IUser, note: INote, reaction: string) => new Promise publishNoteStream(note._id, 'reacted'); - // Notify - notify(note.userId, user._id, 'reaction', { - noteId: note._id, - reaction: reaction - }); + // リアクションされたユーザーがローカルユーザーなら通知を作成 + if (isLocalUser(note._user)) { + notify(note.userId, user._id, 'reaction', { + noteId: note._id, + reaction: reaction + }); + } pushSw(note.userId, 'reaction', { user: await packUser(user, note.userId), @@ -86,7 +87,7 @@ export default async (user: IUser, note: INote, reaction: string) => new Promise //#region 配信 // リアクターがローカルユーザーかつリアクション対象がリモートユーザーの投稿なら配送 if (isLocalUser(user) && isRemoteUser(note._user)) { - const content = pack(renderLike(user, note)); + const content = pack(renderLike(user, note, reaction)); deliver(user, content, note._user.inbox); } //#endregion diff --git a/src/text/html.ts b/src/text/html.ts index 797f3b3f33..f33ef4997b 100644 --- a/src/text/html.ts +++ b/src/text/html.ts @@ -54,6 +54,12 @@ const handlers = { document.body.appendChild(blockquote); }, + title({ document }, { content }) { + const h1 = document.createElement('h1'); + h1.textContent = content; + document.body.appendChild(h1); + }, + text({ document }, { content }) { for (const text of content.split('\n')) { const node = document.createTextNode(text); @@ -69,6 +75,13 @@ const handlers = { a.href = url; a.textContent = url; document.body.appendChild(a); + }, + + search({ document }, { content, query }) { + const a = document.createElement('a'); + a.href = `https://www.google.com/?#q=${query}`; + a.textContent = content; + document.body.appendChild(a); } }; diff --git a/src/text/parse/elements/search.ts b/src/text/parse/elements/search.ts new file mode 100644 index 0000000000..12ee8ecbb8 --- /dev/null +++ b/src/text/parse/elements/search.ts @@ -0,0 +1,13 @@ +/** + * Search + */ + +module.exports = text => { + const match = text.match(/^(.+?) 検索(\n|$)/); + if (!match) return null; + return { + type: 'search', + content: match[0], + query: match[1] + }; +}; diff --git a/src/text/parse/elements/title.ts b/src/text/parse/elements/title.ts new file mode 100644 index 0000000000..9f4708f5d6 --- /dev/null +++ b/src/text/parse/elements/title.ts @@ -0,0 +1,14 @@ +/** + * Title + */ + +module.exports = text => { + const match = text.match(/^【(.+?)】\n/); + if (!match) return null; + const title = match[0]; + return { + type: 'title', + content: title, + title: title.substr(1, title.length - 3) + }; +}; diff --git a/src/text/parse/index.ts b/src/text/parse/index.ts index b958da81b0..cfddd9f615 100644 --- a/src/text/parse/index.ts +++ b/src/text/parse/index.ts @@ -4,6 +4,7 @@ const elements = [ require('./elements/bold'), + require('./elements/title'), require('./elements/url'), require('./elements/link'), require('./elements/mention'), @@ -11,7 +12,8 @@ const elements = [ require('./elements/code'), require('./elements/inline-code'), require('./elements/quote'), - require('./elements/emoji') + require('./elements/emoji'), + require('./elements/search') ]; export default (source: string): any[] => { diff --git a/src/version.ts b/src/version.ts deleted file mode 100644 index d379b57f8f..0000000000 --- a/src/version.ts +++ /dev/null @@ -1,7 +0,0 @@ -/** - * Version - */ - -const meta = require('../version.json'); - -export default meta.version as string; |