diff options
| author | syuilo <Syuilotan@yahoo.co.jp> | 2018-10-08 15:37:24 +0900 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2018-10-08 15:37:24 +0900 |
| commit | 9c170c426be01773afb15a9868ff3c278e09409c (patch) | |
| tree | 0229bb52dd9197308d193f4e41bbc11d3dcb95a1 /src | |
| parent | New translations ja-JP.yml (Norwegian) (diff) | |
| parent | fix(package): update @types/mongodb to version 3.1.10 (#2849) (diff) | |
| download | misskey-9c170c426be01773afb15a9868ff3c278e09409c.tar.gz misskey-9c170c426be01773afb15a9868ff3c278e09409c.tar.bz2 misskey-9c170c426be01773afb15a9868ff3c278e09409c.zip | |
Merge branch 'develop' into l10n_develop
Diffstat (limited to 'src')
481 files changed, 11154 insertions, 8381 deletions
diff --git a/src/client/app/app.styl b/src/client/app/app.styl index 431b9daa65..2f0095944c 100644 --- a/src/client/app/app.styl +++ b/src/client/app/app.styl @@ -6,6 +6,10 @@ html &, * cursor progress !important +html + // iOSのため + overflow auto + body overflow-wrap break-word @@ -23,7 +27,7 @@ body z-index 65536 .bar - background $theme-color + background var(--primary) position fixed z-index 65537 @@ -40,7 +44,7 @@ body right 0px width 100px height 100% - box-shadow 0 0 10px $theme-color, 0 0 5px $theme-color + box-shadow 0 0 10px var(--primary), 0 0 5px var(--primary) opacity 1 transform rotate(3deg) translate(0px, -4px) @@ -60,8 +64,8 @@ body box-sizing border-box border solid 2px transparent - border-top-color $theme-color - border-left-color $theme-color + border-top-color var(--primary) + border-left-color var(--primary) border-radius 50% animation progress-spinner 400ms linear infinite diff --git a/src/client/app/app.vue b/src/client/app/app.vue index 7a46e7dea0..e639c9f9ac 100644 --- a/src/client/app/app.vue +++ b/src/client/app/app.vue @@ -1,3 +1,32 @@ <template> -<router-view id="app"></router-view> +<router-view id="app" v-hotkey.global="keymap"></router-view> </template> + +<script lang="ts"> +import Vue from 'vue'; +import { url, lang } from './config'; + +export default Vue.extend({ + computed: { + keymap(): any { + return { + 'h|slash': this.help, + 'd': this.dark + }; + } + }, + + methods: { + help() { + window.open(`${url}/docs/${lang}/keyboard-shortcut`, '_blank'); + }, + + dark() { + this.$store.commit('device/set', { + key: 'darkmode', + value: !this.$store.state.device.darkmode + }); + } + } +}); +</script> diff --git a/src/client/app/auth/views/index.vue b/src/client/app/auth/views/index.vue index 609e758994..ba7df911e5 100644 --- a/src/client/app/auth/views/index.vue +++ b/src/client/app/auth/views/index.vue @@ -80,7 +80,7 @@ export default Vue.extend({ accepted() { this.state = 'accepted'; if (this.session.app.callbackUrl) { - location.href = this.session.app.callbackUrl + '?token=' + this.session.token; + location.href = `${this.session.app.callbackUrl}?token=${this.session.token}`; } } } diff --git a/src/client/app/base.pug b/src/client/app/base.pug index 11b150bc67..ee9d4b6f6d 100644 --- a/src/client/app/base.pug +++ b/src/client/app/base.pug @@ -34,9 +34,6 @@ html //- FontAwesome style style #{facss} - //- highlight.js style - style #{hljscss} - body noscript: p | JavaScriptを有効にしてください diff --git a/src/client/app/boot.js b/src/client/app/boot.js index 54397c98c6..6e06a88aa3 100644 --- a/src/client/app/boot.js +++ b/src/client/app/boot.js @@ -18,6 +18,17 @@ return; } + const langs = LANGS; + + //#region Apply theme + const theme = localStorage.getItem('theme'); + if (theme) { + Object.entries(JSON.parse(theme)).forEach(([k, v]) => { + document.documentElement.style.setProperty(`--${k}`, v.toString()); + }); + } + //#endregion + //#region Load settings let settings = null; const vuex = localStorage.getItem('vuex'); @@ -40,10 +51,10 @@ //#region Detect the user language let lang = null; - if (LANGS.includes(navigator.language)) { + if (langs.includes(navigator.language)) { lang = navigator.language; } else { - lang = LANGS.find(x => x.split('-')[0] == navigator.language); + lang = langs.find(x => x.split('-')[0] == navigator.language); if (lang == null) { // Fallback @@ -52,7 +63,7 @@ } if (settings && settings.device.lang && - LANGS.includes(settings.device.lang)) { + langs.includes(settings.device.lang)) { lang = settings.device.lang; } //#endregion @@ -82,19 +93,12 @@ app = isMobile ? 'mobile' : 'desktop'; } - // Dark/Light - if (settings) { - if (settings.device.darkmode) { - document.documentElement.setAttribute('data-darkmode', 'true'); - } - } - // Script version const ver = localStorage.getItem('v') || VERSION; // Get salt query const salt = localStorage.getItem('salt') - ? '?salt=' + localStorage.getItem('salt') + ? `?salt=${localStorage.getItem('salt')}` : ''; // Load an app script @@ -140,7 +144,7 @@ // Random localStorage.setItem('salt', Math.random().toString()); - // Clear cache (serive worker) + // Clear cache (service worker) try { navigator.serviceWorker.controller.postMessage('clear'); diff --git a/src/client/app/common/hotkey.ts b/src/client/app/common/hotkey.ts new file mode 100644 index 0000000000..dc1a34338a --- /dev/null +++ b/src/client/app/common/hotkey.ts @@ -0,0 +1,110 @@ +import keyCode from './keycode'; +import { concat } from '../../../prelude/array'; + +type pattern = { + which: string[]; + ctrl?: boolean; + shift?: boolean; + alt?: boolean; +}; + +type action = { + patterns: pattern[]; + + callback: Function; +}; + +const getKeyMap = keymap => Object.entries(keymap).map(([patterns, callback]): action => { + const result = { + patterns: [], + callback: callback + } as action; + + result.patterns = patterns.split('|').map(part => { + const pattern = { + which: [], + ctrl: false, + alt: false, + shift: false + } as pattern; + + part.trim().split('+').forEach(key => { + key = key.trim().toLowerCase(); + switch (key) { + case 'ctrl': pattern.ctrl = true; break; + case 'alt': pattern.alt = true; break; + case 'shift': pattern.shift = true; break; + default: pattern.which = keyCode(key).map(k => k.toLowerCase()); + } + }); + + return pattern; + }); + + return result; +}); + +const ignoreElemens = ['input', 'textarea']; + +export default { + install(Vue) { + Vue.directive('hotkey', { + bind(el, binding) { + el._hotkey_global = binding.modifiers.global === true; + + const actions = getKeyMap(binding.value); + + // flatten + const reservedKeys = concat(concat(actions.map(a => a.patterns.map(p => p.which)))); + + el.dataset.reservedKeys = reservedKeys.map(key => `'${key}'`).join(' '); + + el._keyHandler = (e: KeyboardEvent) => { + const key = e.code.toLowerCase(); + + const targetReservedKeys = document.activeElement ? ((document.activeElement as any).dataset || {}).reservedKeys || '' : ''; + if (document.activeElement && ignoreElemens.some(el => document.activeElement.matches(el))) return; + + for (const action of actions) { + if (el._hotkey_global && targetReservedKeys.includes(`'${key}'`)) break; + + const matched = action.patterns.some(pattern => { + const matched = pattern.which.includes(key) && + pattern.ctrl == e.ctrlKey && + pattern.shift == e.shiftKey && + pattern.alt == e.altKey && + e.metaKey == false; + + if (matched) { + e.preventDefault(); + e.stopPropagation(); + action.callback(e); + return true; + } else { + return false; + } + }); + + if (matched) { + break; + } + } + }; + + if (el._hotkey_global) { + document.addEventListener('keydown', el._keyHandler); + } else { + el.addEventListener('keydown', el._keyHandler); + } + }, + + unbind(el) { + if (el._hotkey_global) { + document.removeEventListener('keydown', el._keyHandler); + } else { + el.removeEventListener('keydown', el._keyHandler); + } + } + }); + } +}; diff --git a/src/client/app/common/keycode.ts b/src/client/app/common/keycode.ts new file mode 100644 index 0000000000..5786c1dc0a --- /dev/null +++ b/src/client/app/common/keycode.ts @@ -0,0 +1,33 @@ +export default (input: string): string[] => { + if (Object.keys(aliases).some(a => a.toLowerCase() == input.toLowerCase())) { + const codes = aliases[input]; + return Array.isArray(codes) ? codes : [codes]; + } else { + return [input]; + } +}; + +export const aliases = { + 'esc': 'Escape', + 'enter': ['Enter', 'NumpadEnter'], + 'up': 'ArrowUp', + 'down': 'ArrowDown', + 'left': 'ArrowLeft', + 'right': 'ArrowRight', + 'plus': ['NumpadAdd', 'Semicolon'], +}; + +/*! +* Programatically add the following +*/ + +// lower case chars +for (let i = 97; i < 123; i++) { + const char = String.fromCharCode(i); + aliases[char] = `Key${char.toUpperCase()}`; +} + +// numbers +for (let i = 0; i < 10; i++) { + aliases[i] = [`Numpad${i}`, `Digit${i}`]; +} diff --git a/src/client/app/common/scripts/check-for-update.ts b/src/client/app/common/scripts/check-for-update.ts index 4445eefc39..91b165b45d 100644 --- a/src/client/app/common/scripts/check-for-update.ts +++ b/src/client/app/common/scripts/check-for-update.ts @@ -9,7 +9,7 @@ export default async function(mios: MiOS, force = false, silent = false) { localStorage.setItem('should-refresh', 'true'); localStorage.setItem('v', newer); - // Clear cache (serive worker) + // Clear cache (service worker) try { if (navigator.serviceWorker.controller) { navigator.serviceWorker.controller.postMessage('clear'); diff --git a/src/client/app/common/scripts/compose-notification.ts b/src/client/app/common/scripts/compose-notification.ts index f42af94370..65087cc98e 100644 --- a/src/client/app/common/scripts/compose-notification.ts +++ b/src/client/app/common/scripts/compose-notification.ts @@ -13,21 +13,21 @@ type Notification = { export default function(type, data): Notification { switch (type) { - case 'drive_file_created': + case 'driveFileCreated': return { title: '%i18n:common.notification.file-uploaded%', body: data.name, icon: data.url }; - case 'unread_messaging_message': + case 'unreadMessagingMessage': return { title: '%i18n:common.notification.message-from%'.split("{}")[0] + `${getUserName(data.user)}` + '%i18n:common.notification.message-from%'.split("{}")[1] , body: data.text, // TODO: getMessagingMessageSummary(data), icon: data.user.avatarUrl }; - case 'reversi_invited': + case 'reversiInvited': return { title: '%i18n:common.notification.reversi-invited%', body: '%i18n:common.notification.reversi-invited-by%'.split("{}")[0] + `${getUserName(data.parent)}` + '%i18n:common.notification.reversi-invited-by%'.split("{}")[1], diff --git a/src/client/app/common/scripts/fuck-ad-block.ts b/src/client/app/common/scripts/fuck-ad-block.ts index ed0904aeb3..0c802f1648 100644 --- a/src/client/app/common/scripts/fuck-ad-block.ts +++ b/src/client/app/common/scripts/fuck-ad-block.ts @@ -1,8 +1,8 @@ -require('fuckadblock'); - declare const fuckAdBlock: any; export default (os) => { + require('fuckadblock'); + function adBlockDetected() { os.apis.dialog({ title: '%fa:exclamation-triangle%%i18n:common.adblock.detected%', diff --git a/src/client/app/common/scripts/gcd.ts b/src/client/app/common/scripts/gcd.ts deleted file mode 100644 index 9a19f9da66..0000000000 --- a/src/client/app/common/scripts/gcd.ts +++ /dev/null @@ -1,2 +0,0 @@ -const gcd = (a, b) => !b ? a : gcd(b, a % b); -export default gcd; diff --git a/src/client/app/common/scripts/get-md5.ts b/src/client/app/common/scripts/get-md5.ts new file mode 100644 index 0000000000..24ac04c1ad --- /dev/null +++ b/src/client/app/common/scripts/get-md5.ts @@ -0,0 +1,8 @@ +const crypto = require('crypto'); + +export default (data: ArrayBuffer) => { + const buf = new Buffer(data); + const hash = crypto.createHash("md5"); + hash.update(buf); + return hash.digest("hex"); +};
\ No newline at end of file diff --git a/src/client/app/common/scripts/note-subscriber.ts b/src/client/app/common/scripts/note-subscriber.ts new file mode 100644 index 0000000000..c41897e70f --- /dev/null +++ b/src/client/app/common/scripts/note-subscriber.ts @@ -0,0 +1,116 @@ +import Vue from 'vue'; + +export default prop => ({ + data() { + return { + connection: null + }; + }, + + computed: { + $_ns_note_(): any { + return this[prop]; + }, + + $_ns_isRenote(): boolean { + return (this.$_ns_note_.renote && + this.$_ns_note_.text == null && + this.$_ns_note_.fileIds.length == 0 && + this.$_ns_note_.poll == null); + }, + + $_ns_target(): any { + return this._ns_isRenote ? this.$_ns_note_.renote : this.$_ns_note_; + }, + }, + + created() { + if (this.$store.getters.isSignedIn) { + this.connection = (this as any).os.stream; + } + }, + + mounted() { + this.capture(true); + + if (this.$store.getters.isSignedIn) { + this.connection.on('_connected_', this.onStreamConnected); + } + }, + + beforeDestroy() { + this.decapture(true); + + if (this.$store.getters.isSignedIn) { + this.connection.off('_connected_', this.onStreamConnected); + } + }, + + methods: { + capture(withHandler = false) { + if (this.$store.getters.isSignedIn) { + const data = { + id: this.$_ns_target.id + } as any; + + if ( + (this.$_ns_target.visibleUserIds || []).includes(this.$store.state.i.id) || + (this.$_ns_target.mentions || []).includes(this.$store.state.i.id) + ) { + data.read = true; + } + + this.connection.send('sn', data); + if (withHandler) this.connection.on('noteUpdated', this.onStreamNoteUpdated); + } + }, + + decapture(withHandler = false) { + if (this.$store.getters.isSignedIn) { + this.connection.send('un', { + id: this.$_ns_target.id + }); + if (withHandler) this.connection.off('noteUpdated', this.onStreamNoteUpdated); + } + }, + + onStreamConnected() { + this.capture(); + }, + + onStreamNoteUpdated(data) { + const { type, id, body } = data; + + if (id !== this.$_ns_target.id) return; + + switch (type) { + case 'reacted': { + const reaction = body.reaction; + if (this.$_ns_target.reactionCounts == null) Vue.set(this.$_ns_target, 'reactionCounts', {}); + this.$_ns_target.reactionCounts[reaction] = (this.$_ns_target.reactionCounts[reaction] || 0) + 1; + break; + } + + case 'pollVoted': { + if (body.userId == this.$store.state.i.id) return; + const choice = body.choice; + this.$_ns_target.poll.choices.find(c => c.id === choice).votes++; + break; + } + + case 'deleted': { + Vue.set(this.$_ns_target, 'deletedAt', body.deletedAt); + this.$_ns_target.text = null; + this.$_ns_target.tags = []; + this.$_ns_target.fileIds = []; + this.$_ns_target.poll = null; + this.$_ns_target.geo = null; + this.$_ns_target.cw = null; + break; + } + } + + this.$emit(`update:${prop}`, this.$_ns_note_); + }, + } +}); diff --git a/src/client/app/common/scripts/parse-search-query.ts b/src/client/app/common/scripts/parse-search-query.ts deleted file mode 100644 index 5f6ae3320a..0000000000 --- a/src/client/app/common/scripts/parse-search-query.ts +++ /dev/null @@ -1,53 +0,0 @@ -export default function(qs: string) { - const q = { - text: '' - }; - - qs.split(' ').forEach(x => { - if (/^([a-z_]+?):(.+?)$/.test(x)) { - const [key, value] = x.split(':'); - switch (key) { - case 'user': - q['includeUserUsernames'] = value.split(','); - break; - case 'exclude_user': - q['excludeUserUsernames'] = value.split(','); - break; - case 'follow': - q['following'] = value == 'null' ? null : value == 'true'; - break; - case 'reply': - q['reply'] = value == 'null' ? null : value == 'true'; - break; - case 'renote': - q['renote'] = value == 'null' ? null : value == 'true'; - break; - case 'media': - q['media'] = value == 'null' ? null : value == 'true'; - break; - case 'poll': - q['poll'] = value == 'null' ? null : value == 'true'; - break; - case 'until': - case 'since': - // YYYY-MM-DD - if (/^[0-9]+\-[0-9]+\-[0-9]+$/) { - const [yyyy, mm, dd] = value.split('-'); - q[`${key}_date`] = (new Date(parseInt(yyyy, 10), parseInt(mm, 10) - 1, parseInt(dd, 10))).getTime(); - } - break; - default: - q[key] = value; - break; - } - } else { - q.text += x + ' '; - } - }); - - if (q.text) { - q.text = q.text.trim(); - } - - return q; -} diff --git a/src/client/app/common/scripts/stream.ts b/src/client/app/common/scripts/stream.ts new file mode 100644 index 0000000000..3b1a94adf9 --- /dev/null +++ b/src/client/app/common/scripts/stream.ts @@ -0,0 +1,318 @@ +import autobind from 'autobind-decorator'; +import { EventEmitter } from 'eventemitter3'; +import ReconnectingWebsocket from 'reconnecting-websocket'; +import { wsUrl } from '../../config'; +import MiOS from '../../mios'; + +/** + * Misskey stream connection + */ +export default class Stream extends EventEmitter { + private stream: ReconnectingWebsocket; + private state: string; + private buffer: any[]; + private sharedConnections: SharedConnection[] = []; + private nonSharedConnections: NonSharedConnection[] = []; + + constructor(os: MiOS) { + super(); + + this.state = 'initializing'; + this.buffer = []; + + const user = os.store.state.i; + + this.stream = new ReconnectingWebsocket(wsUrl + (user ? `?i=${user.token}` : '')); + this.stream.addEventListener('open', this.onOpen); + this.stream.addEventListener('close', this.onClose); + this.stream.addEventListener('message', this.onMessage); + + if (user) { + const main = this.useSharedConnection('main'); + + // 自分の情報が更新されたとき + main.on('meUpdated', i => { + os.store.dispatch('mergeMe', i); + }); + + main.on('readAllNotifications', () => { + os.store.dispatch('mergeMe', { + hasUnreadNotification: false + }); + }); + + main.on('unreadNotification', () => { + os.store.dispatch('mergeMe', { + hasUnreadNotification: true + }); + }); + + main.on('readAllMessagingMessages', () => { + os.store.dispatch('mergeMe', { + hasUnreadMessagingMessage: false + }); + }); + + main.on('unreadMessagingMessage', () => { + os.store.dispatch('mergeMe', { + hasUnreadMessagingMessage: true + }); + }); + + main.on('unreadMention', () => { + os.store.dispatch('mergeMe', { + hasUnreadMentions: true + }); + }); + + main.on('readAllUnreadMentions', () => { + os.store.dispatch('mergeMe', { + hasUnreadMentions: false + }); + }); + + main.on('unreadSpecifiedNote', () => { + os.store.dispatch('mergeMe', { + hasUnreadSpecifiedNotes: true + }); + }); + + main.on('readAllUnreadSpecifiedNotes', () => { + os.store.dispatch('mergeMe', { + hasUnreadSpecifiedNotes: false + }); + }); + + main.on('clientSettingUpdated', x => { + os.store.commit('settings/set', { + key: x.key, + value: x.value + }); + }); + + main.on('homeUpdated', x => { + os.store.commit('settings/setHome', x); + }); + + main.on('mobileHomeUpdated', x => { + os.store.commit('settings/setMobileHome', x); + }); + + main.on('widgetUpdated', x => { + os.store.commit('settings/setWidget', { + id: x.id, + data: x.data + }); + }); + + // トークンが再生成されたとき + // このままではMisskeyが利用できないので強制的にサインアウトさせる + main.on('myTokenRegenerated', () => { + alert('%i18n:common.my-token-regenerated%'); + os.signout(); + }); + } + } + + public useSharedConnection = (channel: string): SharedConnection => { + const existConnection = this.sharedConnections.find(c => c.channel === channel); + + if (existConnection) { + existConnection.use(); + return existConnection; + } else { + const connection = new SharedConnection(this, channel); + connection.use(); + this.sharedConnections.push(connection); + return connection; + } + } + + @autobind + public removeSharedConnection(connection: SharedConnection) { + this.sharedConnections = this.sharedConnections.filter(c => c.id !== connection.id); + } + + public connectToChannel = (channel: string, params?: any): NonSharedConnection => { + const connection = new NonSharedConnection(this, channel, params); + this.nonSharedConnections.push(connection); + return connection; + } + + @autobind + public disconnectToChannel(connection: NonSharedConnection) { + this.nonSharedConnections = this.nonSharedConnections.filter(c => c.id !== connection.id); + } + + /** + * Callback of when open connection + */ + @autobind + private onOpen() { + const isReconnect = this.state == 'reconnecting'; + + this.state = 'connected'; + this.emit('_connected_'); + + // バッファーを処理 + const _buffer = [].concat(this.buffer); // Shallow copy + this.buffer = []; // Clear buffer + _buffer.forEach(data => { + this.send(data); // Resend each buffered messages + }); + + // チャンネル再接続 + if (isReconnect) { + this.sharedConnections.forEach(c => { + c.connect(); + }); + this.nonSharedConnections.forEach(c => { + c.connect(); + }); + } + } + + /** + * Callback of when close connection + */ + @autobind + private onClose() { + this.state = 'reconnecting'; + this.emit('_disconnected_'); + } + + /** + * Callback of when received a message from connection + */ + @autobind + private onMessage(message) { + const { type, body } = JSON.parse(message.data); + + if (type == 'channel') { + const id = body.id; + const connection = this.sharedConnections.find(c => c.id === id) || this.nonSharedConnections.find(c => c.id === id); + connection.emit(body.type, body.body); + } else { + this.emit(type, body); + } + } + + /** + * Send a message to connection + */ + @autobind + public send(typeOrPayload, payload?) { + const data = payload === undefined ? typeOrPayload : { + type: typeOrPayload, + body: payload + }; + + // まだ接続が確立されていなかったらバッファリングして次に接続した時に送信する + if (this.state != 'connected') { + this.buffer.push(data); + return; + } + + this.stream.send(JSON.stringify(data)); + } + + /** + * Close this connection + */ + @autobind + public close() { + this.stream.removeEventListener('open', this.onOpen); + this.stream.removeEventListener('message', this.onMessage); + } +} + +abstract class Connection extends EventEmitter { + public channel: string; + public id: string; + protected params: any; + protected stream: Stream; + + constructor(stream: Stream, channel: string, params?: any) { + super(); + + this.stream = stream; + this.channel = channel; + this.params = params; + this.id = Math.random().toString(); + this.connect(); + } + + @autobind + public connect() { + this.stream.send('connect', { + channel: this.channel, + id: this.id, + params: this.params + }); + } + + @autobind + public send(typeOrPayload, payload?) { + const data = payload === undefined ? typeOrPayload : { + type: typeOrPayload, + body: payload + }; + + this.stream.send('channel', { + id: this.id, + body: data + }); + } + + public abstract dispose: () => void; +} + +class SharedConnection extends Connection { + private users = 0; + private disposeTimerId: any; + + constructor(stream: Stream, channel: string) { + super(stream, channel); + } + + @autobind + public use() { + this.users++; + + // タイマー解除 + if (this.disposeTimerId) { + clearTimeout(this.disposeTimerId); + this.disposeTimerId = null; + } + } + + @autobind + public dispose() { + this.users--; + + // そのコネクションの利用者が誰もいなくなったら + if (this.users === 0) { + // また直ぐに再利用される可能性があるので、一定時間待ち、 + // 新たな利用者が現れなければコネクションを切断する + this.disposeTimerId = setTimeout(() => { + this.disposeTimerId = null; + this.removeAllListeners(); + this.stream.send('disconnect', { id: this.id }); + this.stream.removeSharedConnection(this); + }, 3000); + } + } +} + +class NonSharedConnection extends Connection { + constructor(stream: Stream, channel: string, params?: any) { + super(stream, channel, params); + } + + @autobind + public dispose() { + this.removeAllListeners(); + this.stream.send('disconnect', { id: this.id }); + this.stream.disconnectToChannel(this); + } +} diff --git a/src/client/app/common/scripts/streaming/drive.ts b/src/client/app/common/scripts/streaming/drive.ts deleted file mode 100644 index 50fff05737..0000000000 --- a/src/client/app/common/scripts/streaming/drive.ts +++ /dev/null @@ -1,34 +0,0 @@ -import Stream from './stream'; -import StreamManager from './stream-manager'; -import MiOS from '../../../mios'; - -/** - * Drive stream connection - */ -export class DriveStream extends Stream { - constructor(os: MiOS, me) { - super(os, 'drive', { - i: me.token - }); - } -} - -export class DriveStreamManager extends StreamManager<DriveStream> { - private me; - private os: MiOS; - - constructor(os: MiOS, me) { - super(); - - this.me = me; - this.os = os; - } - - public getConnection() { - if (this.connection == null) { - this.connection = new DriveStream(this.os, this.me); - } - - return this.connection; - } -} diff --git a/src/client/app/common/scripts/streaming/games/reversi/reversi-game.ts b/src/client/app/common/scripts/streaming/games/reversi/reversi-game.ts deleted file mode 100644 index e6b02fcfdb..0000000000 --- a/src/client/app/common/scripts/streaming/games/reversi/reversi-game.ts +++ /dev/null @@ -1,11 +0,0 @@ -import Stream from '../../stream'; -import MiOS from '../../../../../mios'; - -export class ReversiGameStream extends Stream { - constructor(os: MiOS, me, game) { - super(os, 'games/reversi-game', { - i: me ? me.token : null, - game: game.id - }); - } -} diff --git a/src/client/app/common/scripts/streaming/games/reversi/reversi.ts b/src/client/app/common/scripts/streaming/games/reversi/reversi.ts deleted file mode 100644 index 1f4fd8c63e..0000000000 --- a/src/client/app/common/scripts/streaming/games/reversi/reversi.ts +++ /dev/null @@ -1,31 +0,0 @@ -import StreamManager from '../../stream-manager'; -import Stream from '../../stream'; -import MiOS from '../../../../../mios'; - -export class ReversiStream extends Stream { - constructor(os: MiOS, me) { - super(os, 'games/reversi', { - i: me.token - }); - } -} - -export class ReversiStreamManager extends StreamManager<ReversiStream> { - private me; - private os: MiOS; - - constructor(os: MiOS, me) { - super(); - - this.me = me; - this.os = os; - } - - public getConnection() { - if (this.connection == null) { - this.connection = new ReversiStream(this.os, this.me); - } - - return this.connection; - } -} diff --git a/src/client/app/common/scripts/streaming/global-timeline.ts b/src/client/app/common/scripts/streaming/global-timeline.ts deleted file mode 100644 index a639f1595c..0000000000 --- a/src/client/app/common/scripts/streaming/global-timeline.ts +++ /dev/null @@ -1,34 +0,0 @@ -import Stream from './stream'; -import StreamManager from './stream-manager'; -import MiOS from '../../../mios'; - -/** - * Global timeline stream connection - */ -export class GlobalTimelineStream extends Stream { - constructor(os: MiOS, me) { - super(os, 'global-timeline', { - i: me.token - }); - } -} - -export class GlobalTimelineStreamManager extends StreamManager<GlobalTimelineStream> { - private me; - private os: MiOS; - - constructor(os: MiOS, me) { - super(); - - this.me = me; - this.os = os; - } - - public getConnection() { - if (this.connection == null) { - this.connection = new GlobalTimelineStream(this.os, this.me); - } - - return this.connection; - } -} diff --git a/src/client/app/common/scripts/streaming/home.ts b/src/client/app/common/scripts/streaming/home.ts deleted file mode 100644 index dd18c70d70..0000000000 --- a/src/client/app/common/scripts/streaming/home.ts +++ /dev/null @@ -1,102 +0,0 @@ -import Stream from './stream'; -import StreamManager from './stream-manager'; -import MiOS from '../../../mios'; - -/** - * Home stream connection - */ -export class HomeStream extends Stream { - constructor(os: MiOS, me) { - super(os, '', { - i: me.token - }); - - // 最終利用日時を更新するため定期的にaliveメッセージを送信 - setInterval(() => { - this.send({ type: 'alive' }); - me.lastUsedAt = new Date(); - }, 1000 * 60); - - // 自分の情報が更新されたとき - this.on('meUpdated', i => { - if (os.debug) { - console.log('I updated:', i); - } - - os.store.dispatch('mergeMe', i); - }); - - this.on('read_all_notifications', () => { - os.store.dispatch('mergeMe', { - hasUnreadNotification: false - }); - }); - - this.on('unread_notification', () => { - os.store.dispatch('mergeMe', { - hasUnreadNotification: true - }); - }); - - this.on('read_all_messaging_messages', () => { - os.store.dispatch('mergeMe', { - hasUnreadMessagingMessage: false - }); - }); - - this.on('unread_messaging_message', () => { - os.store.dispatch('mergeMe', { - hasUnreadMessagingMessage: true - }); - }); - - this.on('clientSettingUpdated', x => { - os.store.commit('settings/set', { - key: x.key, - value: x.value - }); - }); - - this.on('home_updated', x => { - os.store.commit('settings/setHome', x); - }); - - this.on('mobile_home_updated', x => { - os.store.commit('settings/setMobileHome', x); - }); - - this.on('widgetUpdated', x => { - os.store.commit('settings/setWidget', { - id: x.id, - data: x.data - }); - }); - - // トークンが再生成されたとき - // このままではMisskeyが利用できないので強制的にサインアウトさせる - this.on('my_token_regenerated', () => { - alert('%i18n:common.my-token-regenerated%'); - os.signout(); - }); - } -} - -export class HomeStreamManager extends StreamManager<HomeStream> { - private me; - private os: MiOS; - - constructor(os: MiOS, me) { - super(); - - this.me = me; - this.os = os; - } - - public getConnection() { - if (this.connection == null) { - this.connection = new HomeStream(this.os, this.me); - } - - return this.connection; - } -} diff --git a/src/client/app/common/scripts/streaming/hybrid-timeline.ts b/src/client/app/common/scripts/streaming/hybrid-timeline.ts deleted file mode 100644 index cd290797c4..0000000000 --- a/src/client/app/common/scripts/streaming/hybrid-timeline.ts +++ /dev/null @@ -1,34 +0,0 @@ -import Stream from './stream'; -import StreamManager from './stream-manager'; -import MiOS from '../../../mios'; - -/** - * Hybrid timeline stream connection - */ -export class HybridTimelineStream extends Stream { - constructor(os: MiOS, me) { - super(os, 'hybrid-timeline', { - i: me.token - }); - } -} - -export class HybridTimelineStreamManager extends StreamManager<HybridTimelineStream> { - private me; - private os: MiOS; - - constructor(os: MiOS, me) { - super(); - - this.me = me; - this.os = os; - } - - public getConnection() { - if (this.connection == null) { - this.connection = new HybridTimelineStream(this.os, this.me); - } - - return this.connection; - } -} diff --git a/src/client/app/common/scripts/streaming/local-timeline.ts b/src/client/app/common/scripts/streaming/local-timeline.ts deleted file mode 100644 index 2834262bdc..0000000000 --- a/src/client/app/common/scripts/streaming/local-timeline.ts +++ /dev/null @@ -1,34 +0,0 @@ -import Stream from './stream'; -import StreamManager from './stream-manager'; -import MiOS from '../../../mios'; - -/** - * Local timeline stream connection - */ -export class LocalTimelineStream extends Stream { - constructor(os: MiOS, me) { - super(os, 'local-timeline', { - i: me.token - }); - } -} - -export class LocalTimelineStreamManager extends StreamManager<LocalTimelineStream> { - private me; - private os: MiOS; - - constructor(os: MiOS, me) { - super(); - - this.me = me; - this.os = os; - } - - public getConnection() { - if (this.connection == null) { - this.connection = new LocalTimelineStream(this.os, this.me); - } - - return this.connection; - } -} diff --git a/src/client/app/common/scripts/streaming/messaging-index.ts b/src/client/app/common/scripts/streaming/messaging-index.ts deleted file mode 100644 index addcccb952..0000000000 --- a/src/client/app/common/scripts/streaming/messaging-index.ts +++ /dev/null @@ -1,34 +0,0 @@ -import Stream from './stream'; -import StreamManager from './stream-manager'; -import MiOS from '../../../mios'; - -/** - * Messaging index stream connection - */ -export class MessagingIndexStream extends Stream { - constructor(os: MiOS, me) { - super(os, 'messaging-index', { - i: me.token - }); - } -} - -export class MessagingIndexStreamManager extends StreamManager<MessagingIndexStream> { - private me; - private os: MiOS; - - constructor(os: MiOS, me) { - super(); - - this.me = me; - this.os = os; - } - - public getConnection() { - if (this.connection == null) { - this.connection = new MessagingIndexStream(this.os, this.me); - } - - return this.connection; - } -} diff --git a/src/client/app/common/scripts/streaming/messaging.ts b/src/client/app/common/scripts/streaming/messaging.ts deleted file mode 100644 index a59377d867..0000000000 --- a/src/client/app/common/scripts/streaming/messaging.ts +++ /dev/null @@ -1,20 +0,0 @@ -import Stream from './stream'; -import MiOS from '../../../mios'; - -/** - * Messaging stream connection - */ -export class MessagingStream extends Stream { - constructor(os: MiOS, me, otherparty) { - super(os, 'messaging', { - i: me.token, - otherparty - }); - - (this as any).on('_connected_', () => { - this.send({ - i: me.token - }); - }); - } -} diff --git a/src/client/app/common/scripts/streaming/notes-stats.ts b/src/client/app/common/scripts/streaming/notes-stats.ts deleted file mode 100644 index 9e3e78a709..0000000000 --- a/src/client/app/common/scripts/streaming/notes-stats.ts +++ /dev/null @@ -1,30 +0,0 @@ -import Stream from './stream'; -import StreamManager from './stream-manager'; -import MiOS from '../../../mios'; - -/** - * Notes stats stream connection - */ -export class NotesStatsStream extends Stream { - constructor(os: MiOS) { - super(os, 'notes-stats'); - } -} - -export class NotesStatsStreamManager extends StreamManager<NotesStatsStream> { - private os: MiOS; - - constructor(os: MiOS) { - super(); - - this.os = os; - } - - public getConnection() { - if (this.connection == null) { - this.connection = new NotesStatsStream(this.os); - } - - return this.connection; - } -} diff --git a/src/client/app/common/scripts/streaming/server-stats.ts b/src/client/app/common/scripts/streaming/server-stats.ts deleted file mode 100644 index 9983dfcaf0..0000000000 --- a/src/client/app/common/scripts/streaming/server-stats.ts +++ /dev/null @@ -1,30 +0,0 @@ -import Stream from './stream'; -import StreamManager from './stream-manager'; -import MiOS from '../../../mios'; - -/** - * Server stats stream connection - */ -export class ServerStatsStream extends Stream { - constructor(os: MiOS) { - super(os, 'server-stats'); - } -} - -export class ServerStatsStreamManager extends StreamManager<ServerStatsStream> { - private os: MiOS; - - constructor(os: MiOS) { - super(); - - this.os = os; - } - - public getConnection() { - if (this.connection == null) { - this.connection = new ServerStatsStream(this.os); - } - - return this.connection; - } -} diff --git a/src/client/app/common/scripts/streaming/stream-manager.ts b/src/client/app/common/scripts/streaming/stream-manager.ts deleted file mode 100644 index 568b8b0372..0000000000 --- a/src/client/app/common/scripts/streaming/stream-manager.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { EventEmitter } from 'eventemitter3'; -import * as uuid from 'uuid'; -import Connection from './stream'; - -/** - * ストリーム接続を管理するクラス - * 複数の場所から同じストリームを利用する際、接続をまとめたりする - */ -export default abstract class StreamManager<T extends Connection> extends EventEmitter { - private _connection: T = null; - - private disposeTimerId: any; - - /** - * コネクションを必要としているユーザー - */ - private users = []; - - protected set connection(connection: T) { - this._connection = connection; - - if (this._connection == null) { - this.emit('disconnected'); - } else { - this.emit('connected', this._connection); - - this._connection.on('_connected_', () => { - this.emit('_connected_'); - }); - - this._connection.on('_disconnected_', () => { - this.emit('_disconnected_'); - }); - - this._connection.user = 'Managed'; - } - } - - protected get connection() { - return this._connection; - } - - /** - * コネクションを持っているか否か - */ - public get hasConnection() { - return this._connection != null; - } - - public get state(): string { - if (!this.hasConnection) return 'no-connection'; - return this._connection.state; - } - - /** - * コネクションを要求します - */ - public abstract getConnection(): T; - - /** - * 現在接続しているコネクションを取得します - */ - public borrow() { - return this._connection; - } - - /** - * コネクションを要求するためのユーザーIDを発行します - */ - public use() { - // タイマー解除 - if (this.disposeTimerId) { - clearTimeout(this.disposeTimerId); - this.disposeTimerId = null; - } - - // ユーザーID生成 - const userId = uuid(); - - this.users.push(userId); - - this._connection.user = `Managed (${ this.users.length })`; - - return userId; - } - - /** - * コネクションを利用し終わってもう必要ないことを通知します - * @param userId use で発行したユーザーID - */ - public dispose(userId) { - this.users = this.users.filter(id => id != userId); - - this._connection.user = `Managed (${ this.users.length })`; - - // 誰もコネクションの利用者がいなくなったら - if (this.users.length == 0) { - // また直ぐに再利用される可能性があるので、一定時間待ち、 - // 新たな利用者が現れなければコネクションを切断する - this.disposeTimerId = setTimeout(() => { - this.disposeTimerId = null; - - this.connection.close(); - this.connection = null; - }, 3000); - } - } -} diff --git a/src/client/app/common/scripts/streaming/stream.ts b/src/client/app/common/scripts/streaming/stream.ts deleted file mode 100644 index fefa8e5ced..0000000000 --- a/src/client/app/common/scripts/streaming/stream.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { EventEmitter } from 'eventemitter3'; -import * as uuid from 'uuid'; -import * as ReconnectingWebsocket from 'reconnecting-websocket'; -import { wsUrl } from '../../../config'; -import MiOS from '../../../mios'; - -/** - * Misskey stream connection - */ -export default class Connection extends EventEmitter { - public state: string; - private buffer: any[]; - public socket: ReconnectingWebsocket; - public name: string; - public connectedAt: Date; - public user: string = null; - public in: number = 0; - public out: number = 0; - public inout: Array<{ - type: 'in' | 'out', - at: Date, - data: string - }> = []; - public id: string; - public isSuspended = false; - private os: MiOS; - - constructor(os: MiOS, endpoint, params?) { - super(); - - //#region BIND - this.onOpen = this.onOpen.bind(this); - this.onClose = this.onClose.bind(this); - this.onMessage = this.onMessage.bind(this); - this.send = this.send.bind(this); - this.close = this.close.bind(this); - //#endregion - - this.id = uuid(); - this.os = os; - this.name = endpoint; - this.state = 'initializing'; - this.buffer = []; - - const query = params - ? Object.keys(params) - .map(k => encodeURIComponent(k) + '=' + encodeURIComponent(params[k])) - .join('&') - : null; - - this.socket = new ReconnectingWebsocket(`${wsUrl}/${endpoint}${query ? '?' + query : ''}`); - this.socket.addEventListener('open', this.onOpen); - this.socket.addEventListener('close', this.onClose); - this.socket.addEventListener('message', this.onMessage); - - // Register this connection for debugging - this.os.registerStreamConnection(this); - } - - /** - * Callback of when open connection - */ - private onOpen() { - this.state = 'connected'; - this.emit('_connected_'); - - this.connectedAt = new Date(); - - // バッファーを処理 - const _buffer = [].concat(this.buffer); // Shallow copy - this.buffer = []; // Clear buffer - _buffer.forEach(data => { - this.send(data); // Resend each buffered messages - - if (this.os.debug) { - this.out++; - this.inout.push({ type: 'out', at: new Date(), data }); - } - }); - } - - /** - * Callback of when close connection - */ - private onClose() { - this.state = 'reconnecting'; - this.emit('_disconnected_'); - } - - /** - * Callback of when received a message from connection - */ - private onMessage(message) { - if (this.isSuspended) return; - - if (this.os.debug) { - this.in++; - this.inout.push({ type: 'in', at: new Date(), data: message.data }); - } - - try { - const msg = JSON.parse(message.data); - if (msg.type) this.emit(msg.type, msg.body); - } catch (e) { - // noop - } - } - - /** - * Send a message to connection - */ - public send(data) { - if (this.isSuspended) return; - - // まだ接続が確立されていなかったらバッファリングして次に接続した時に送信する - if (this.state != 'connected') { - this.buffer.push(data); - return; - } - - if (this.os.debug) { - this.out++; - this.inout.push({ type: 'out', at: new Date(), data }); - } - - this.socket.send(JSON.stringify(data)); - } - - /** - * Close this connection - */ - public close() { - this.os.unregisterStreamConnection(this); - this.socket.removeEventListener('open', this.onOpen); - this.socket.removeEventListener('message', this.onMessage); - } -} diff --git a/src/client/app/common/scripts/streaming/user-list.ts b/src/client/app/common/scripts/streaming/user-list.ts deleted file mode 100644 index 30a52b98dd..0000000000 --- a/src/client/app/common/scripts/streaming/user-list.ts +++ /dev/null @@ -1,17 +0,0 @@ -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/acct.vue b/src/client/app/common/views/components/acct.vue index 1ad222afdd..542fbb4296 100644 --- a/src/client/app/common/views/components/acct.vue +++ b/src/client/app/common/views/components/acct.vue @@ -1,19 +1,25 @@ <template> <span class="mk-acct"> <span class="name">@{{ user.username }}</span> - <span class="host" v-if="user.host">@{{ user.host }}</span> + <span class="host" :class="{ fade: $store.state.settings.contrastedAcct }" v-if="user.host || detail || $store.state.settings.showFullAcct">@{{ user.host || host }}</span> </span> </template> <script lang="ts"> import Vue from 'vue'; +import { host } from '../../../config'; export default Vue.extend({ - props: ['user'] + props: ['user', 'detail'], + data() { + return { + host + }; + } }); </script> <style lang="stylus" scoped> .mk-acct - > .host + > .host.fade opacity 0.5 </style> diff --git a/src/client/app/common/views/components/autocomplete.vue b/src/client/app/common/views/components/autocomplete.vue index b274eaa0a0..bc0120c9ab 100644 --- a/src/client/app/common/views/components/autocomplete.vue +++ b/src/client/app/common/views/components/autocomplete.vue @@ -125,7 +125,7 @@ export default Vue.extend({ } if (this.type == 'user') { - const cacheKey = 'autocomplete:user:' + this.q; + const cacheKey = `autocomplete:user:${this.q}`; const cache = sessionStorage.getItem(cacheKey); if (cache) { const users = JSON.parse(cache); @@ -148,7 +148,7 @@ export default Vue.extend({ this.hashtags = JSON.parse(localStorage.getItem('hashtags') || '[]'); this.fetching = false; } else { - const cacheKey = 'autocomplete:hashtag:' + this.q; + const cacheKey = `autocomplete:hashtag:${this.q}`; const cache = sessionStorage.getItem(cacheKey); if (cache) { const hashtags = JSON.parse(cache); @@ -259,15 +259,13 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> -@import '~const.styl' - -root(isDark) +.mk-autocomplete position fixed z-index 65535 max-width 100% margin-top calc(1em + 8px) overflow hidden - background isDark ? #313543 : #fff + background var(--faceHeader) border solid 1px rgba(#000, 0.1) border-radius 4px transition top 0.1s ease, left 0.1s ease @@ -299,16 +297,16 @@ root(isDark) text-overflow ellipsis &:hover - background isDark ? rgba(#fff, 0.1) : rgba(#000, 0.1) + background var(--autocompleteItemHoverBg) &[data-selected='true'] - background $theme-color + background var(--primary) &, * color #fff !important &:active - background darken($theme-color, 10%) + background var(--primaryDarken10) &, * color #fff !important @@ -325,15 +323,15 @@ root(isDark) .name margin 0 8px 0 0 - color isDark ? rgba(#fff, 0.8) : rgba(#000, 0.8) + color var(--autocompleteItemText) .username - color isDark ? rgba(#fff, 0.3) : rgba(#000, 0.3) + color var(--autocompleteItemTextSub) > .hashtags > li .name - color isDark ? rgba(#fff, 0.8) : rgba(#000, 0.8) + color var(--autocompleteItemText) > .emojis > li @@ -343,15 +341,9 @@ root(isDark) width 24px .name - color isDark ? rgba(#fff, 0.8) : rgba(#000, 0.8) + color var(--autocompleteItemText) .alias margin 0 0 0 8px - color isDark ? rgba(#fff, 0.3) : rgba(#000, 0.3) - -.mk-autocomplete[data-darkmode] - root(true) - -.mk-autocomplete:not([data-darkmode]) - root(false) + color var(--autocompleteItemTextSub) </style> diff --git a/src/client/app/common/views/components/avatar.vue b/src/client/app/common/views/components/avatar.vue index c5ac74e537..ac018abcfc 100644 --- a/src/client/app/common/views/components/avatar.vue +++ b/src/client/app/common/views/components/avatar.vue @@ -1,15 +1,15 @@ <template> - <span class="mk-avatar" :class="{ cat }" :title="user | acct" v-if="disableLink && !disablePreview" v-user-preview="user.id" @click="onClick"> - <span class="inner" :style="style"></span> + <span class="mk-avatar" :style="style" :class="{ cat }" :title="user | acct" v-if="disableLink && !disablePreview" v-user-preview="user.id" @click="onClick"> + <span class="inner" :style="icon"></span> </span> - <span class="mk-avatar" :class="{ cat }" :title="user | acct" v-else-if="disableLink && disablePreview" @click="onClick"> - <span class="inner" :style="style"></span> + <span class="mk-avatar" :style="style" :class="{ cat }" :title="user | acct" v-else-if="disableLink && disablePreview" @click="onClick"> + <span class="inner" :style="icon"></span> </span> - <router-link class="mk-avatar" :class="{ cat }" :to="user | userPage" :title="user | acct" :target="target" v-else-if="!disableLink && !disablePreview" v-user-preview="user.id"> - <span class="inner" :style="style"></span> + <router-link class="mk-avatar" :style="style" :class="{ cat }" :to="user | userPage" :title="user | acct" :target="target" v-else-if="!disableLink && !disablePreview" v-user-preview="user.id"> + <span class="inner" :style="icon"></span> </router-link> - <router-link class="mk-avatar" :class="{ cat }" :to="user | userPage" :title="user | acct" :target="target" v-else-if="!disableLink && disablePreview"> - <span class="inner" :style="style"></span> + <router-link class="mk-avatar" :style="style" :class="{ cat }" :to="user | userPage" :title="user | acct" :target="target" v-else-if="!disableLink && disablePreview"> + <span class="inner" :style="icon"></span> </router-link> </template> @@ -43,6 +43,11 @@ export default Vue.extend({ }, style(): any { return { + borderRadius: this.$store.state.settings.circleIcons ? '100%' : null + }; + }, + icon(): any { + return { backgroundColor: this.lightmode ? `rgb(${this.user.avatarColor.slice(0, 3).join(',')})` : this.user.avatarColor && this.user.avatarColor.length == 3 @@ -53,6 +58,11 @@ export default Vue.extend({ }; } }, + mounted() { + if (this.user.avatarColor) { + this.$el.style.color = `rgb(${this.user.avatarColor.slice(0, 3).join(',')})`; + } + }, methods: { onClick(e) { this.$emit('click', e); @@ -62,8 +72,7 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> - -root(isDark) +.mk-avatar display inline-block vertical-align bottom @@ -74,7 +83,7 @@ root(isDark) &.cat::before, &.cat::after background #df548f - border solid 4px isDark ? #e0eefd : #202224 + border solid 4px currentColor box-sizing border-box content '' display inline-block @@ -100,9 +109,4 @@ root(isDark) transition border-radius 1s ease z-index 1 -.mk-avatar[data-darkmode] - root(true) - -.mk-avatar:not([data-darkmode]) - root(false) </style> diff --git a/src/client/app/common/views/components/connect-failed.troubleshooter.vue b/src/client/app/common/views/components/connect-failed.troubleshooter.vue index 6c23cc7969..f64cae6b4b 100644 --- a/src/client/app/common/views/components/connect-failed.troubleshooter.vue +++ b/src/client/app/common/views/components/connect-failed.troubleshooter.vue @@ -57,7 +57,7 @@ export default Vue.extend({ } // Check internet connection - fetch('https://google.com?rand=' + Math.random(), { + fetch(`https://google.com?rand=${Math.random()}`, { mode: 'no-cors' }).then(() => { this.internet = true; diff --git a/src/client/app/common/views/components/connect-failed.vue b/src/client/app/common/views/components/connect-failed.vue index 0f686926b0..36cae05665 100644 --- a/src/client/app/common/views/components/connect-failed.vue +++ b/src/client/app/common/views/components/connect-failed.vue @@ -39,7 +39,7 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> -@import '~const.styl' + .mk-connect-failed width 100% @@ -70,17 +70,17 @@ export default Vue.extend({ display block margin 1em auto 0 auto padding 8px 10px - color $theme-color-foreground - background $theme-color + color var(--primaryForeground) + background var(--primary) &:focus - outline solid 3px rgba($theme-color, 0.3) + outline solid 3px var(--primaryAlpha03) &:hover - background lighten($theme-color, 10%) + background var(--primaryLighten10) &:active - background darken($theme-color, 10%) + background var(--primaryDarken10) > .thanks display block diff --git a/src/client/app/common/views/components/cw-button.vue b/src/client/app/common/views/components/cw-button.vue new file mode 100644 index 0000000000..79917f82ab --- /dev/null +++ b/src/client/app/common/views/components/cw-button.vue @@ -0,0 +1,38 @@ +<template> +<button class="nrvgflfuaxwgkxoynpnumyookecqrrvh" @click="toggle">{{ value ? '%i18n:@hide%' : '%i18n:@show%' }}</button> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +export default Vue.extend({ + props: { + value: { + type: Boolean, + required: true + } + }, + + methods: { + toggle() { + this.$emit('input', !this.value); + } + } +}); +</script> + +<style lang="stylus" scoped> +.nrvgflfuaxwgkxoynpnumyookecqrrvh + display inline-block + padding 4px 8px + font-size 0.7em + color var(--cwButtonFg) + background var(--cwButtonBg) + border-radius 2px + cursor pointer + user-select none + + &:hover + background var(--cwButtonHoverBg) + +</style> diff --git a/src/client/app/common/views/components/forkit.vue b/src/client/app/common/views/components/forkit.vue index de627181ef..b303b48b79 100644 --- a/src/client/app/common/views/components/forkit.vue +++ b/src/client/app/common/views/components/forkit.vue @@ -9,7 +9,7 @@ </template> <style lang="stylus" scoped> -@import '~const.styl' + .a display block @@ -18,8 +18,8 @@ display block //fill #151513 //color #fff - fill $theme-color - color $theme-color-foreground + fill var(--primary) + color var(--primaryForeground) .octo-arm transform-origin 130px 106px diff --git a/src/client/app/common/views/components/games/reversi/reversi.game.vue b/src/client/app/common/views/components/games/reversi/reversi.game.vue index b432a2308d..751abe2ecd 100644 --- a/src/client/app/common/views/components/games/reversi/reversi.game.vue +++ b/src/client/app/common/views/components/games/reversi/reversi.game.vue @@ -50,15 +50,15 @@ </div> <div class="player" v-if="game.isEnded"> - <el-button-group> - <el-button type="primary" @click="logPos = 0" :disabled="logPos == 0">%fa:angle-double-left%</el-button> - <el-button type="primary" @click="logPos--" :disabled="logPos == 0">%fa:angle-left%</el-button> - </el-button-group> + <div> + <button @click="logPos = 0" :disabled="logPos == 0">%fa:angle-double-left%</button> + <button @click="logPos--" :disabled="logPos == 0">%fa:angle-left%</button> + </div> <span>{{ logPos }} / {{ logs.length }}</span> - <el-button-group> - <el-button type="primary" @click="logPos++" :disabled="logPos == logs.length">%fa:angle-right%</el-button> - <el-button type="primary" @click="logPos = logs.length" :disabled="logPos == logs.length">%fa:angle-double-right%</el-button> - </el-button-group> + <div> + <button @click="logPos++" :disabled="logPos == logs.length">%fa:angle-right%</button> + <button @click="logPos = logs.length" :disabled="logPos == logs.length">%fa:angle-double-right%</button> + </div> </div> <div class="info"> @@ -159,11 +159,9 @@ export default Vue.extend({ canPutEverywhere: this.game.settings.canPutEverywhere, loopedBoard: this.game.settings.loopedBoard }); - this.logs.forEach((log, i) => { - if (i < v) { - this.o.put(log.color, log.pos); - } - }); + for (const log of this.logs.slice(0, v)) { + this.o.put(log.color, log.pos); + } this.$forceUpdate(); } }, @@ -306,9 +304,7 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> -@import '~const.styl' - -root(isDark) +.xqnhankfuuilcwvhgsopeqncafzsquya text-align center > .go-index @@ -321,7 +317,7 @@ root(isDark) > header padding 8px - border-bottom dashed 1px isDark ? #4c5761 : #c4cdd4 + border-bottom dashed 1px var(--reversiGameHeaderLine) a color inherit @@ -388,30 +384,30 @@ root(isDark) user-select none &.empty - border solid 2px isDark ? #51595f : #eee + border solid 2px var(--reversiGameEmptyCell) &.empty.can - background isDark ? #51595f : #eee + background var(--reversiGameEmptyCell) &.empty.myTurn - border-color isDark ? #6a767f : #ddd + border-color var(--reversiGameEmptyCellMyTurn) &.can - background isDark ? #51595f : #eee + background var(--reversiGameEmptyCellCanPut) cursor pointer &:hover - border-color darken($theme-color, 10%) - background $theme-color + border-color var(--primaryDarken10) + background var(--primary) &:active - background darken($theme-color, 10%) + background var(--primaryDarken10) &.prev - box-shadow 0 0 0 4px rgba($theme-color, 0.7) + box-shadow 0 0 0 4px var(--primaryAlpha07) &.isEnded - border-color isDark ? #6a767f : #ddd + border-color var(--reversiGameEmptyCellMyTurn) &.none border-color transparent !important @@ -460,10 +456,4 @@ root(isDark) margin 0 8px min-width 70px -.xqnhankfuuilcwvhgsopeqncafzsquya[data-darkmode] - root(true) - -.xqnhankfuuilcwvhgsopeqncafzsquya:not([data-darkmode]) - root(false) - </style> diff --git a/src/client/app/common/views/components/games/reversi/reversi.gameroom.vue b/src/client/app/common/views/components/games/reversi/reversi.gameroom.vue index 1539c88de0..0a18e0b19a 100644 --- a/src/client/app/common/views/components/games/reversi/reversi.gameroom.vue +++ b/src/client/app/common/views/components/games/reversi/reversi.gameroom.vue @@ -9,7 +9,6 @@ import Vue from 'vue'; import XGame from './reversi.game.vue'; import XRoom from './reversi.room.vue'; -import { ReversiGameStream } from '../../../../scripts/streaming/games/reversi/reversi-game'; export default Vue.extend({ components: { @@ -34,12 +33,13 @@ export default Vue.extend({ }, created() { this.g = this.game; - this.connection = new ReversiGameStream((this as any).os, this.$store.state.i, this.game); + this.connection = (this as any).os.stream.connectToChannel('gamesReversiGame', { + gameId: this.game.id + }); this.connection.on('started', this.onStarted); }, beforeDestroy() { - this.connection.off('started', this.onStarted); - this.connection.close(); + this.connection.dispose(); }, methods: { onStarted(game) { diff --git a/src/client/app/common/views/components/games/reversi/reversi.index.vue b/src/client/app/common/views/components/games/reversi/reversi.index.vue index fa88aeaaf4..a040162802 100644 --- a/src/client/app/common/views/components/games/reversi/reversi.index.vue +++ b/src/client/app/common/views/components/games/reversi/reversi.index.vue @@ -3,7 +3,6 @@ <h1>%i18n:@title%</h1> <p>%i18n:@sub-title%</p> <div class="play"> - <!--<el-button round>フリーマッチ(準備中)</el-button>--> <form-button primary round @click="match">%i18n:@invite%</form-button> <details> <summary>%i18n:@rule%</summary> @@ -60,15 +59,13 @@ export default Vue.extend({ myGames: [], matching: null, invitations: [], - connection: null, - connectionId: null + connection: null }; }, mounted() { if (this.$store.getters.isSignedIn) { - this.connection = (this as any).os.streams.reversiStream.getConnection(); - this.connectionId = (this as any).os.streams.reversiStream.use(); + this.connection = (this as any).os.stream.useSharedConnection('gamesReversi'); this.connection.on('invited', this.onInvited); @@ -91,8 +88,7 @@ export default Vue.extend({ beforeDestroy() { if (this.connection) { - this.connection.off('invited', this.onInvited); - (this as any).os.streams.reversiStream.dispose(this.connectionId); + this.connection.dispose(); } }, @@ -139,9 +135,7 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> -@import '~const.styl' - -root(isDark) +.phgnkghfpyvkrvwiajkiuoxyrdaqpzcx > h1 margin 0 padding 24px @@ -149,7 +143,7 @@ root(isDark) text-align center font-weight normal color #fff - background linear-gradient(to bottom, isDark ? #45730e : #8bca3e, isDark ? #464300 : #d6cf31) + background linear-gradient(to bottom, var(--reversiBannerGradientStart), var(--reversiBannerGradientEnd)) & + p margin 0 @@ -157,7 +151,7 @@ root(isDark) margin-bottom 12px text-align center font-size 14px - border-bottom solid 1px isDark ? #535f65 : #d3d9dc + border-bottom solid 1px var(--faceDivider) > .play margin 0 auto @@ -172,14 +166,14 @@ root(isDark) padding 16px font-size 14px text-align left - background isDark ? #282c37 : #f5f5f5 + background var(--reversiDescBg) border-radius 8px > section margin 0 auto padding 0 16px 16px 16px max-width 500px - border-top solid 1px isDark ? #535f65 : #d3d9dc + border-top solid 1px var(--faceDivider) > h2 margin 0 @@ -190,9 +184,9 @@ root(isDark) .invitation margin 8px 0 padding 8px - color isDark ? #fff : #677f84 - background isDark ? #282c37 : #fff - box-shadow 0 2px 16px rgba(#000, isDark ? 0.7 : 0.15) + color var(--text) + background var(--face) + box-shadow 0 2px 16px var(--reversiListItemShadow) border-radius 6px cursor pointer @@ -201,13 +195,13 @@ root(isDark) user-select none &:focus - border-color $theme-color + border-color var(--primary) &:hover - background isDark ? #313543 : #f5f5f5 + box-shadow 0 0 0 100px inset rgba(0, 0, 0, 0.05) &:active - background isDark ? #1e222b : #eee + box-shadow 0 0 0 100px inset rgba(0, 0, 0, 0.1) > .avatar width 32px @@ -222,9 +216,9 @@ root(isDark) display block margin 8px 0 padding 8px - color isDark ? #fff : #677f84 - background isDark ? #282c37 : #fff - box-shadow 0 2px 16px rgba(#000, isDark ? 0.7 : 0.15) + color var(--text) + background var(--face) + box-shadow 0 2px 16px var(--reversiListItemShadow) border-radius 6px cursor pointer @@ -233,10 +227,10 @@ root(isDark) user-select none &:hover - background isDark ? #313543 : #f5f5f5 + box-shadow 0 0 0 100px inset rgba(0, 0, 0, 0.05) &:active - background isDark ? #1e222b : #eee + box-shadow 0 0 0 100px inset rgba(0, 0, 0, 0.1) > .avatar width 32px @@ -247,10 +241,4 @@ root(isDark) margin 0 8px line-height 32px -.phgnkghfpyvkrvwiajkiuoxyrdaqpzcx[data-darkmode] - root(true) - -.phgnkghfpyvkrvwiajkiuoxyrdaqpzcx:not([data-darkmode]) - root(false) - </style> diff --git a/src/client/app/common/views/components/games/reversi/reversi.room.vue b/src/client/app/common/views/components/games/reversi/reversi.room.vue index aed8718dd0..9f0d9c23fb 100644 --- a/src/client/app/common/views/components/games/reversi/reversi.room.vue +++ b/src/client/app/common/views/components/games/reversi/reversi.room.vue @@ -47,9 +47,9 @@ </header> <div> - <mk-switch v-model="game.settings.isLlotheo" @change="updateSettings" text="%i18n:@is-llotheo%"/> - <mk-switch v-model="game.settings.loopedBoard" @change="updateSettings" text="%i18n:@looped-map%"/> - <mk-switch v-model="game.settings.canPutEverywhere" @change="updateSettings" text="%i18n:@can-put-everywhere%"/> + <ui-switch v-model="game.settings.isLlotheo" @change="updateSettings">%i18n:@is-llotheo%</ui-switch> + <ui-switch v-model="game.settings.loopedBoard" @change="updateSettings">%i18n:@looped-map%</ui-switch> + <ui-switch v-model="game.settings.canPutEverywhere" @change="updateSettings">%i18n:@can-put-everywhere%</ui-switch> </div> </div> @@ -59,13 +59,8 @@ </header> <div> - <el-alert v-for="message in messages" - :title="message.text" - :type="message.type" - :key="message.id"/> - <template v-for="item in form"> - <mk-switch v-if="item.type == 'switch'" v-model="item.value" :key="item.id" :text="item.label" @change="onChangeForm(item)">{{ item.desc || '' }}</mk-switch> + <ui-switch v-if="item.type == 'switch'" v-model="item.value" :key="item.id" :text="item.label" @change="onChangeForm(item)">{{ item.desc || '' }}</ui-switch> <div class="card" v-if="item.type == 'radio'" :key="item.id"> <header> @@ -93,7 +88,7 @@ </header> <div> - <el-input v-model="item.value" @change="onChangeForm(item)"/> + <input v-model="item.value" @change="onChangeForm(item)"/> </div> </div> </template> @@ -257,11 +252,9 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> -@import '~const.styl' - -root(isDark) +.urbixznjwwuukfsckrwzwsqzsxornqij text-align center - background isDark ? #191b22 : #f9f9f9 + background var(--bg) > header padding 8px @@ -278,10 +271,10 @@ root(isDark) > select width 100% padding 12px 14px - background isDark ? #282C37 : #fff - border 1px solid isDark ? #6a707d : #dcdfe6 + background var(--face) + border 1px solid var(--reversiMapSelectBorder) border-radius 4px - color isDark ? #fff : #606266 + color var(--text) cursor pointer transition border-color 0.2s cubic-bezier(0.645, 0.045, 0.355, 1) -webkit-appearance none @@ -289,17 +282,18 @@ root(isDark) appearance none &:hover - border-color isDark ? #a7aebd : #c0c4cc + border-color var(--reversiMapSelectHoverBorder) &:focus &:active - border-color $theme-color + border-color var(--primary) > div > .random padding 32px 0 font-size 64px - color isDark ? #4e5961 : #d8d8d8 + color var(--text) + opacity 0.7 > .board display grid @@ -307,11 +301,11 @@ root(isDark) width 300px height 300px margin 0 auto - color isDark ? #fff : #444 + color var(--text) > div background transparent - border solid 2px isDark ? #6a767f : #ddd + border solid 2px var(--faceDivider) border-radius 6px overflow hidden cursor pointer @@ -336,32 +330,26 @@ root(isDark) .card max-width 400px border-radius 4px - background isDark ? #282C37 : #fff - color isDark ? #fff : #303133 - box-shadow 0 2px 12px 0 rgba(#000, isDark ? 0.7 : 0.1) + background var(--face) + color var(--text) + box-shadow 0 2px 12px 0 var(--reversiRoomFormShadow) > header padding 18px 20px - border-bottom 1px solid isDark ? #1c2023 : #ebeef5 + border-bottom 1px solid var(--faceDivider) > div padding 20px - color isDark ? #fff : #606266 + color var(--text) > footer position sticky bottom 0 padding 16px - background rgba(isDark ? #191b22 : #fff, 0.9) - border-top solid 1px isDark ? #606266 : #c4cdd4 + background var(--reversiRoomFooterBg) + border-top solid 1px var(--faceDivider) > .status margin 0 0 16px 0 -.urbixznjwwuukfsckrwzwsqzsxornqij[data-darkmode] - root(true) - -.urbixznjwwuukfsckrwzwsqzsxornqij:not([data-darkmode]) - root(false) - </style> diff --git a/src/client/app/common/views/components/games/reversi/reversi.vue b/src/client/app/common/views/components/games/reversi/reversi.vue index 223ec4597a..f2156bc41b 100644 --- a/src/client/app/common/views/components/games/reversi/reversi.vue +++ b/src/client/app/common/views/components/games/reversi/reversi.vue @@ -47,7 +47,6 @@ export default Vue.extend({ game: null, matching: null, connection: null, - connectionId: null, pingClock: null }; }, @@ -66,8 +65,7 @@ export default Vue.extend({ this.fetch(); if (this.$store.getters.isSignedIn) { - this.connection = (this as any).os.streams.reversiStream.getConnection(); - this.connectionId = (this as any).os.streams.reversiStream.use(); + this.connection = (this as any).os.stream.useSharedConnection('gamesReversi'); this.connection.on('matched', this.onMatched); @@ -84,9 +82,7 @@ export default Vue.extend({ beforeDestroy() { if (this.connection) { - this.connection.off('matched', this.onMatched); - (this as any).os.streams.reversiStream.dispose(this.connectionId); - + this.connection.dispose(); clearInterval(this.pingClock); } }, @@ -156,11 +152,9 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> -@import '~const.styl' - -root(isDark) - color isDark ? #fff : #677f84 - background isDark ? #191b22 : #fff +.vchtoekanapleubgzioubdtmlkribzfd + color var(--text) + background var(--bg) > .matching > h1 @@ -177,10 +171,4 @@ root(isDark) text-align center border-top dashed 1px #c4cdd4 -.vchtoekanapleubgzioubdtmlkribzfd[data-darkmode] - root(true) - -.vchtoekanapleubgzioubdtmlkribzfd:not([data-darkmode]) - root(false) - </style> diff --git a/src/client/app/common/views/components/google.vue b/src/client/app/common/views/components/google.vue index 8272961ef2..ac71a5e56d 100644 --- a/src/client/app/common/views/components/google.vue +++ b/src/client/app/common/views/components/google.vue @@ -26,7 +26,7 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> -root(isDark) +.mk-google display flex margin 8px 0 @@ -37,31 +37,25 @@ root(isDark) height 40px font-family sans-serif font-size 16px - color isDark ? #dee4e8 : #55595c - background isDark ? #191b22 : #fff - border solid 1px isDark ? #495156 : #dadada + color var(--googleSearchFg) + background var(--googleSearchBg) + border solid 1px var(--googleSearchBorder) border-radius 4px 0 0 4px &:hover - border-color isDark ? #777c86 : #b0b0b0 + border-color var(--googleSearchHoverBorder) > button flex-shrink 0 padding 0 16px - border solid 1px isDark ? #495156 : #dadada + border solid 1px var(--googleSearchBorder) border-left none border-radius 0 4px 4px 0 &:hover - background-color isDark ? #2e3440 : #eee + background-color var(--googleSearchHoverButton) &: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 422a3da050..0dea38a7a1 100644 --- a/src/client/app/common/views/components/index.ts +++ b/src/client/app/common/views/components/index.ts @@ -1,5 +1,10 @@ import Vue from 'vue'; +import theme from './theme.vue'; +import instance from './instance.vue'; +import cwButton from './cw-button.vue'; +import tagCloud from './tag-cloud.vue'; +import trends from './trends.vue'; import analogClock from './analog-clock.vue'; import menu from './menu.vue'; import noteHeader from './note-header.vue'; @@ -26,7 +31,6 @@ import messagingRoom from './messaging-room.vue'; import urlPreview from './url-preview.vue'; import twitterSetting from './twitter-setting.vue'; import fileTypeIcon from './file-type-icon.vue'; -import Switch from './switch.vue'; import Reversi from './games/reversi/reversi.vue'; import welcomeTimeline from './welcome-timeline.vue'; import uiInput from './ui/input.vue'; @@ -40,6 +44,11 @@ import uiSelect from './ui/select.vue'; import formButton from './ui/form/button.vue'; import formRadio from './ui/form/radio.vue'; +Vue.component('mk-theme', theme); +Vue.component('mk-instance', instance); +Vue.component('mk-cw-button', cwButton); +Vue.component('mk-tag-cloud', tagCloud); +Vue.component('mk-trends', trends); Vue.component('mk-analog-clock', analogClock); Vue.component('mk-menu', menu); Vue.component('mk-note-header', noteHeader); @@ -66,7 +75,6 @@ Vue.component('mk-messaging-room', messagingRoom); Vue.component('mk-url-preview', urlPreview); Vue.component('mk-twitter-setting', twitterSetting); Vue.component('mk-file-type-icon', fileTypeIcon); -Vue.component('mk-switch', Switch); Vue.component('mk-reversi', Reversi); Vue.component('mk-welcome-timeline', welcomeTimeline); Vue.component('ui-input', uiInput); diff --git a/src/client/app/common/views/components/instance.vue b/src/client/app/common/views/components/instance.vue new file mode 100644 index 0000000000..c3935cce0e --- /dev/null +++ b/src/client/app/common/views/components/instance.vue @@ -0,0 +1,51 @@ +<template> +<div class="nhasjydimbopojusarffqjyktglcuxjy" v-if="meta"> + <div class="banner" :style="{ backgroundImage: meta.bannerUrl ? `url(${meta.bannerUrl})` : null }"></div> + + <h1>{{ meta.name }}</h1> + <p v-html="meta.description || '%i18n:common.about%'"></p> + <router-link to="/">%i18n:@start%</router-link> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +export default Vue.extend({ + data() { + return { + meta: null + } + }, + created() { + (this as any).os.getMeta().then(meta => { + this.meta = meta; + }); + } +}); +</script> + +<style lang="stylus" scoped> +.nhasjydimbopojusarffqjyktglcuxjy + color var(--text) + background var(--face) + text-align center + + > .banner + height 100px + background-position center + background-size cover + + > h1 + margin 16px + font-size 16px + + > p + margin 16px + font-size 14px + + > a + display block + padding-bottom 16px + +</style> diff --git a/src/client/app/common/views/components/media-banner.vue b/src/client/app/common/views/components/media-banner.vue new file mode 100644 index 0000000000..0f5981d3c4 --- /dev/null +++ b/src/client/app/common/views/components/media-banner.vue @@ -0,0 +1,85 @@ +<template> +<div class="mk-media-banner"> + <div class="sensitive" v-if="media.isSensitive && hide" @click="hide = false"> + <span class="icon">%fa:exclamation-triangle%</span> + <b>%i18n:@sensitive%</b> + <span>%i18n:@click-to-show%</span> + </div> + <div class="audio" v-else-if="media.type.startsWith('audio')"> + <audio class="audio" + :src="media.url" + :title="media.name" + controls + ref="audio" + preload="metadata" /> + </div> + <a class="download" v-else + :href="media.url" + :title="media.name" + :download="media.name" + > + <span class="icon">%fa:download%</span> + <b>{{ media.name }}</b> + </a> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +export default Vue.extend({ + props: { + media: { + type: Object, + required: true + } + }, + data() { + return { + hide: true + }; + } +}) +</script> + +<style lang="stylus" scoped> +.mk-media-banner + width 100% + border-radius 4px + margin-top 4px + overflow hidden + + > .download, + > .sensitive + display flex + align-items center + font-size 12px + padding 8px 12px + white-space nowrap + + > * + display block + + > b + overflow hidden + text-overflow ellipsis + + > *:not(:last-child) + margin-right .2em + + > .icon + font-size 1.6em + + > .download + background var(--noteAttachedFile) + + > .sensitive + background #111 + color #fff + + > .audio + .audio + display block + width 100% + +</style> diff --git a/src/client/app/common/views/components/media-list.vue b/src/client/app/common/views/components/media-list.vue index cdfc2c8d3c..d83d6f85cd 100644 --- a/src/client/app/common/views/components/media-list.vue +++ b/src/client/app/common/views/components/media-list.vue @@ -1,18 +1,27 @@ <template> <div class="mk-media-list"> - <div :data-count="mediaList.length" ref="grid"> - <template v-for="media in mediaList"> - <mk-media-video :video="media" :key="media.id" v-if="media.type.startsWith('video')" :inline-playable="mediaList.length === 1"/> - <mk-media-image :image="media" :key="media.id" v-else :raw="raw"/> - </template> + <template v-for="media in mediaList.filter(media => !previewable(media))"> + <x-banner :media="media" :key="media.id"/> + </template> + <div v-if="mediaList.filter(media => previewable(media)).length > 0" class="gird-container"> + <div :data-count="mediaList.filter(media => previewable(media)).length" ref="grid"> + <template v-for="media in mediaList"> + <mk-media-video :video="media" :key="media.id" v-if="media.type.startsWith('video')"/> + <mk-media-image :image="media" :key="media.id" v-else-if="media.type.startsWith('image')" :raw="raw"/> + </template> + </div> </div> </div> </template> <script lang="ts"> import Vue from 'vue'; +import XBanner from './media-banner.vue'; export default Vue.extend({ + components: { + XBanner + }, props: { mediaList: { required: true @@ -22,70 +31,80 @@ export default Vue.extend({ } }, mounted() { - // for Safari bug - this.$refs.grid.style.height = this.$refs.grid.clientHeight ? `${this.$refs.grid.clientHeight}px` : '128px'; + //#region for Safari bug + if (this.$refs.grid) { + this.$refs.grid.style.height = this.$refs.grid.clientHeight ? `${this.$refs.grid.clientHeight}px` : '128px'; + } + //#endregion + }, + methods: { + previewable(file) { + return file.type.startsWith('video') || file.type.startsWith('image'); + } } }); </script> <style lang="stylus" scoped> .mk-media-list - width 100% + > .gird-container + width 100% + margin-top 4px - &:before - content '' - display block - padding-top 56.25% // 16:9 + &:before + content '' + display block + padding-top 56.25% // 16:9 - > div - position absolute - top 0 - right 0 - bottom 0 - left 0 - display grid - grid-gap 4px + > div + position absolute + top 0 + right 0 + bottom 0 + left 0 + display grid + grid-gap 4px - > * - overflow hidden - border-radius 4px + > * + overflow hidden + border-radius 4px - &[data-count="1"] - grid-template-rows 1fr + &[data-count="1"] + grid-template-rows 1fr - &[data-count="2"] - grid-template-columns 1fr 1fr - grid-template-rows 1fr + &[data-count="2"] + grid-template-columns 1fr 1fr + grid-template-rows 1fr - &[data-count="3"] - grid-template-columns 1fr 0.5fr - grid-template-rows 1fr 1fr + &[data-count="3"] + grid-template-columns 1fr 0.5fr + grid-template-rows 1fr 1fr - > *:nth-child(1) - grid-row 1 / 3 + > *:nth-child(1) + grid-row 1 / 3 - > *:nth-child(3) - grid-column 2 / 3 - grid-row 2 / 3 + > *:nth-child(3) + grid-column 2 / 3 + grid-row 2 / 3 - &[data-count="4"] - grid-template-columns 1fr 1fr - grid-template-rows 1fr 1fr + &[data-count="4"] + grid-template-columns 1fr 1fr + grid-template-rows 1fr 1fr - > *:nth-child(1) - grid-column 1 / 2 - grid-row 1 / 2 + > *:nth-child(1) + grid-column 1 / 2 + grid-row 1 / 2 - > *:nth-child(2) - grid-column 2 / 3 - grid-row 1 / 2 + > *:nth-child(2) + grid-column 2 / 3 + grid-row 1 / 2 - > *:nth-child(3) - grid-column 1 / 2 - grid-row 2 / 3 + > *:nth-child(3) + grid-column 1 / 2 + grid-row 2 / 3 - > *:nth-child(4) - grid-column 2 / 3 - grid-row 2 / 3 + > *:nth-child(4) + grid-column 2 / 3 + grid-row 2 / 3 </style> diff --git a/src/client/app/common/views/components/menu.vue b/src/client/app/common/views/components/menu.vue index 9b16732b9a..be2c03f54c 100644 --- a/src/client/app/common/views/components/menu.vue +++ b/src/client/app/common/views/components/menu.vue @@ -1,10 +1,10 @@ <template> -<div class="mk-menu"> +<div class="onchrpzrvnoruiaenfcqvccjfuupzzwv"> <div class="backdrop" ref="backdrop" @click="close"></div> <div class="popover" :class="{ hukidasi }" ref="popover"> - <template v-for="item in items"> + <template v-for="item, i in items"> <div v-if="item === null"></div> - <button v-if="item" @click="clicked(item.action)" v-html="item.icon ? item.icon + ' ' + item.text : item.text"></button> + <button v-if="item" @click="clicked(item.action)" v-html="item.icon ? item.icon + ' ' + item.text : item.text" :tabindex="i"></button> </template> </div> </div> @@ -108,7 +108,7 @@ export default Vue.extend({ easing: 'easeInBack', complete: () => { this.$emit('closed'); - this.$destroy(); + this.destroyDom(); } }); } @@ -117,11 +117,10 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> -@import '~const.styl' +.onchrpzrvnoruiaenfcqvccjfuupzzwv + $bg-color = var(--popupBg) + $border-color = rgba(27, 31, 35, 0.15) -$border-color = rgba(27, 31, 35, 0.15) - -.mk-menu position initial > .backdrop @@ -131,14 +130,14 @@ $border-color = rgba(27, 31, 35, 0.15) z-index 10000 width 100% height 100% - background rgba(#000, 0.1) + background var(--modalBackdrop) opacity 0 > .popover position absolute z-index 10001 padding 8px 0 - background #fff + background $bg-color border 1px solid $border-color border-radius 4px box-shadow 0 3px 12px rgba(27, 31, 35, 0.15) @@ -172,25 +171,26 @@ $border-color = rgba(27, 31, 35, 0.15) border-top solid $balloon-size transparent border-left solid $balloon-size transparent border-right solid $balloon-size transparent - border-bottom solid $balloon-size #fff + border-bottom solid $balloon-size $bg-color > button display block padding 8px 16px width 100% + color var(--popupFg) &:hover - color $theme-color-foreground - background $theme-color + color var(--primaryForeground) + background var(--primary) text-decoration none &:active - color $theme-color-foreground - background darken($theme-color, 10%) + color var(--primaryForeground) + background var(--primaryDarken10) > div margin 8px 0 height 1px - background #eee + background var(--faceDivider) </style> diff --git a/src/client/app/common/views/components/messaging-room.form.vue b/src/client/app/common/views/components/messaging-room.form.vue index f183749fad..c93fd7f78d 100644 --- a/src/client/app/common/views/components/messaging-room.form.vue +++ b/src/client/app/common/views/components/messaging-room.form.vue @@ -195,9 +195,7 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> -@import '~const.styl' - -root(isDark) +.mk-messaging-form > textarea cursor auto display block @@ -209,10 +207,10 @@ root(isDark) padding 8px resize none font-size 1em - color isDark ? #fff : #000 + color var(--inputText) outline none border none - border-top solid 1px isDark ? #4b5056 : #eee + border-top solid 1px var(--faceDivider) border-radius 0 box-shadow none background transparent @@ -234,10 +232,10 @@ root(isDark) transition color 0.1s ease &:hover - color $theme-color + color var(--primary) &:active - color darken($theme-color, 10%) + color var(--primaryDarken10) transition color 0s ease .files @@ -293,19 +291,13 @@ root(isDark) transition color 0.1s ease &:hover - color $theme-color + color var(--primary) &:active - color darken($theme-color, 10%) + color var(--primaryDarken10) transition color 0s ease input[type=file] display none -.mk-messaging-form[data-darkmode] - root(true) - -.mk-messaging-form:not([data-darkmode]) - root(false) - </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 648d0eee18..77bf55c52c 100644 --- a/src/client/app/common/views/components/messaging-room.message.vue +++ b/src/client/app/common/views/components/messaging-room.message.vue @@ -59,10 +59,8 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> -@import '~const.styl' - -root(isDark) - $me-balloon-color = $theme-color +.message + $me-balloon-color = var(--primary) padding 10px 12px 10px 12px background-color transparent @@ -179,7 +177,7 @@ root(isDark) display block margin 2px 0 0 0 font-size 10px - color isDark ? rgba(#fff, 0.4) : rgba(#000, 0.4) + color var(--messagingRoomMessageInfo) > [data-fa] margin-left 4px @@ -192,7 +190,7 @@ root(isDark) padding-left 66px > .balloon - $color = isDark ? #2d3338 : #eee + $color = var(--messagingRoomMessageBg) float left background $color @@ -208,8 +206,7 @@ root(isDark) > .content > .text - if isDark - color #fff + color var(--messagingRoomMessageFg) > footer text-align left @@ -250,18 +247,9 @@ root(isDark) > .read user-select none - margin 0 4px 0 0 - color isDark ? rgba(#fff, 0.5) : rgba(#000, 0.5) - font-size 11px &[data-is-deleted] > .balloon opacity 0.5 -.message[data-darkmode] - root(true) - -.message:not([data-darkmode]) - root(false) - </style> diff --git a/src/client/app/common/views/components/messaging-room.vue b/src/client/app/common/views/components/messaging-room.vue index 30143b4f1d..488dff528f 100644 --- a/src/client/app/common/views/components/messaging-room.vue +++ b/src/client/app/common/views/components/messaging-room.vue @@ -3,7 +3,7 @@ @dragover.prevent.stop="onDragover" @drop.prevent.stop="onDrop" > - <div class="stream"> + <div class="body"> <p class="init" v-if="init">%fa:spinner .spin%%i18n:common.loading%</p> <p class="empty" v-if="!init && messages.length == 0">%fa:info-circle%%i18n:@empty%</p> <p class="no-history" v-if="!init && messages.length > 0 && !existMoreMessages">%fa:flag%%i18n:@no-history%</p> @@ -30,7 +30,6 @@ <script lang="ts"> import Vue from 'vue'; -import { MessagingStream } from '../../scripts/streaming/messaging'; import XMessage from './messaging-room.message.vue'; import XForm from './messaging-room.form.vue'; import { url } from '../../../config'; @@ -72,11 +71,17 @@ export default Vue.extend({ }, mounted() { - this.connection = new MessagingStream((this as any).os, this.$store.state.i, this.user.id); + this.connection =((this as any).os.stream.connectToChannel('messaging', { otherparty: this.user.id }); this.connection.on('message', this.onMessage); this.connection.on('read', this.onRead); + if (this.isNaked) { + window.addEventListener('scroll', this.onScroll, { passive: true }); + } else { + this.$el.addEventListener('scroll', this.onScroll, { passive: true }); + } + document.addEventListener('visibilitychange', this.onVisibilitychange); this.fetchMessages().then(() => { @@ -86,9 +91,13 @@ export default Vue.extend({ }, beforeDestroy() { - this.connection.off('message', this.onMessage); - this.connection.off('read', this.onRead); - this.connection.close(); + this.connection.dispose(); + + if (this.isNaked) { + window.removeEventListener('scroll', this.onScroll); + } else { + this.$el.removeEventListener('scroll', this.onScroll); + } document.removeEventListener('visibilitychange', this.onVisibilitychange); }, @@ -226,6 +235,14 @@ export default Vue.extend({ }, 4000); }, + onScroll() { + const el = this.isNaked ? window.document.documentElement : this.$el; + const current = el.scrollTop + el.clientHeight; + if (current > el.scrollHeight - 1) { + this.showIndicator = false; + } + }, + onVisibilitychange() { if (document.hidden) return; this.messages.forEach(message => { @@ -242,39 +259,28 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> -@import '~const.styl' - -root(isDark) +.mk-messaging-room display flex flex 1 flex-direction column height 100% - background isDark ? #191b22 : #fff + background var(--messagingRoomBg) - > .stream + > .body width 100% max-width 600px margin 0 auto flex 1 - > .init - width 100% - margin 0 - padding 16px 8px 8px 8px - text-align center - font-size 0.8em - color rgba(isDark ? #fff : #000, 0.4) - - [data-fa] - margin-right 4px - + > .init, > .empty width 100% margin 0 padding 16px 8px 8px 8px text-align center font-size 0.8em - color rgba(isDark ? #fff : #000, 0.4) + color var(--messagingRoomInfo) + opacity 0.5 [data-fa] margin-right 4px @@ -285,7 +291,8 @@ root(isDark) padding 16px text-align center font-size 0.8em - color rgba(isDark ? #fff : #000, 0.4) + color var(--messagingRoomInfo) + opacity 0.5 [data-fa] margin-right 4px @@ -329,7 +336,7 @@ root(isDark) left 0 right 0 margin 0 auto - background rgba(isDark ? #fff : #000, 0.1) + background var(--messagingRoomDateDividerLine) > span display inline-block @@ -337,8 +344,8 @@ root(isDark) padding 0 16px //font-weight bold line-height 32px - color rgba(isDark ? #fff : #000, 0.3) - background isDark ? #191b22 : #fff + color var(--messagingRoomDateDividerText) + background var(--messagingRoomBg) > footer position -webkit-sticky @@ -349,7 +356,7 @@ root(isDark) max-width 600px margin 0 auto padding 0 - background rgba(isDark ? #282c37 : #fff, 0.95) + //background rgba(var(--face), 0.95) background-clip content-box > .new-message @@ -366,15 +373,15 @@ root(isDark) cursor pointer line-height 32px font-size 12px - color $theme-color-foreground - background $theme-color + color var(--primaryForeground) + background var(--primary) border-radius 16px &:hover - background lighten($theme-color, 10%) + background var(--primaryLighten10) &:active - background darken($theme-color, 10%) + background var(--primaryDarken10) > [data-fa] position absolute @@ -390,10 +397,4 @@ root(isDark) transition opacity 0.5s opacity 0 -.mk-messaging-room[data-darkmode] - root(true) - -.mk-messaging-room:not([data-darkmode]) - root(false) - </style> diff --git a/src/client/app/common/views/components/messaging.vue b/src/client/app/common/views/components/messaging.vue index 6abfc92dca..f5b5e232f6 100644 --- a/src/client/app/common/views/components/messaging.vue +++ b/src/client/app/common/views/components/messaging.vue @@ -71,13 +71,11 @@ export default Vue.extend({ messages: [], q: null, result: [], - connection: null, - connectionId: null + connection: null }; }, mounted() { - this.connection = (this as any).os.streams.messagingIndexStream.getConnection(); - this.connectionId = (this as any).os.streams.messagingIndexStream.use(); + this.connection = (this as any).os.stream.useSharedConnection('messagingIndex'); this.connection.on('message', this.onMessage); this.connection.on('read', this.onRead); @@ -88,9 +86,7 @@ export default Vue.extend({ }); }, beforeDestroy() { - this.connection.off('message', this.onMessage); - this.connection.off('read', this.onRead); - (this as any).os.streams.messagingIndexStream.dispose(this.connectionId); + this.connection.dispose(); }, methods: { getAcct, @@ -167,9 +163,7 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> -@import '~const.styl' - -root(isDark) +.mk-messaging &[data-compact] font-size 0.8em @@ -204,12 +198,10 @@ root(isDark) left 0 z-index 1 width 100% - background #fff box-shadow 0 0px 2px rgba(#000, 0.2) > .form - padding 8px - background isDark ? #282c37 : #f7f7f7 + background rgba(0, 0, 0, 0.02) > label display block @@ -229,32 +221,22 @@ root(isDark) bottom 0 left 0 width 1em - line-height 56px + line-height 48px margin auto color #555 > input margin 0 - padding 0 0 0 32px + padding 0 0 0 42px width 100% font-size 1em - line-height 38px - color #000 + line-height 48px + color var(--faceText) outline none - background isDark ? #191b22 : #fff - border solid 1px isDark ? #495156 : #eee + background transparent + border none border-radius 5px box-shadow none - transition color 0.5s ease, border 0.5s ease - - &:hover - border solid 1px isDark ? #b0b0b0 : #ddd - transition border 0.2s ease - - &:focus - color darken($theme-color, 20%) - border solid 1px $theme-color - transition color 0, border 0 > .result display block @@ -287,7 +269,7 @@ root(isDark) &:hover &:focus color #fff - background $theme-color + background var(--primary) .name color #fff @@ -297,7 +279,7 @@ root(isDark) &:active color #fff - background darken($theme-color, 10%) + background var(--primaryDarken10) .name color #fff @@ -329,21 +311,21 @@ root(isDark) > a display block text-decoration none - background isDark ? #282c37 : #fff - border-bottom solid 1px isDark ? #1c2023 : #eee + background var(--face) + border-bottom solid 1px var(--faceDivider) * pointer-events none user-select none &:hover - background isDark ? #1e2129 : #fafafa + box-shadow 0 0 0 100px inset rgba(0, 0, 0, 0.05) - > .avatar + .avatar filter saturate(200%) &:active - background isDark ? #14161b : #eee + box-shadow 0 0 0 100px inset rgba(0, 0, 0, 0.1) &[data-is-read] &[data-is-me] @@ -383,17 +365,17 @@ root(isDark) overflow hidden text-overflow ellipsis font-size 1em - color isDark ? #fff : rgba(#000, 0.9) + color var(--noteHeaderName) font-weight bold transition all 0.1s ease > .username margin 0 8px - color isDark ? #606984 : rgba(#000, 0.5) + color var(--noteHeaderAcct) > .mk-time margin 0 0 0 auto - color isDark ? #606984 : rgba(#000, 0.5) + color var(--noteHeaderInfo) font-size 80% > .avatar @@ -413,10 +395,10 @@ root(isDark) overflow hidden overflow-wrap break-word font-size 1.1em - color isDark ? #fff : rgba(#000, 0.8) + color var(--faceText) .me - color isDark ? rgba(#fff, 0.7) : rgba(#000, 0.4) + opacity 0.7 > .image display block @@ -461,10 +443,4 @@ root(isDark) > .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/misskey-flavored-markdown.ts b/src/client/app/common/views/components/misskey-flavored-markdown.ts index e97da4302c..224bd6f5de 100644 --- a/src/client/app/common/views/components/misskey-flavored-markdown.ts +++ b/src/client/app/common/views/components/misskey-flavored-markdown.ts @@ -1,4 +1,4 @@ -import Vue from 'vue'; +import Vue, { VNode } from 'vue'; import * as emojilib from 'emojilib'; import { length } from 'stringz'; import parse from '../../../../../mfm/parse'; @@ -6,10 +6,7 @@ import getAcct from '../../../../../misc/acct/render'; import { url } from '../../../config'; import MkUrl from './url.vue'; import MkGoogle from './google.vue'; - -const flatten = list => list.reduce( - (a, b) => a.concat(Array.isArray(b) ? flatten(b) : b), [] -); +import { concat } from '../../../../../prelude/array'; export default Vue.component('misskey-flavored-markdown', { props: { @@ -32,20 +29,20 @@ export default Vue.component('misskey-flavored-markdown', { }, render(createElement) { - let ast; + let ast: any[]; if (this.ast == null) { // Parse text to ast ast = parse(this.text); } else { - ast = this.ast; + ast = this.ast as any[]; } let bigCount = 0; let motionCount = 0; // Parse ast to DOM - const els = flatten(ast.map(token => { + const els = concat(ast.map((token): VNode[] => { switch (token.type) { case 'text': { const text = token.content.replace(/(\r\n|\n|\r)/g, '\n'); @@ -56,12 +53,12 @@ export default Vue.component('misskey-flavored-markdown', { x[x.length - 1].pop(); return x; } else { - return createElement('span', text.replace(/\n/g, ' ')); + return [createElement('span', text.replace(/\n/g, ' '))]; } } case 'bold': { - return createElement('b', token.bold); + return [createElement('b', token.bold)]; } case 'big': { @@ -95,23 +92,23 @@ export default Vue.component('misskey-flavored-markdown', { } case 'url': { - return createElement(MkUrl, { + return [createElement(MkUrl, { props: { url: token.content, target: '_blank' } - }); + })]; } case 'link': { - return createElement('a', { + return [createElement('a', { attrs: { class: 'link', href: token.url, target: '_blank', title: token.url } - }, token.title); + }, token.title)]; } case 'mention': { @@ -129,16 +126,16 @@ export default Vue.component('misskey-flavored-markdown', { } case 'hashtag': { - return createElement('a', { + return [createElement('a', { attrs: { href: `${url}/tags/${encodeURIComponent(token.hashtag)}`, target: '_blank' } - }, token.content); + }, token.content)]; } case 'code': { - return createElement('pre', { + return [createElement('pre', { class: 'code' }, [ createElement('code', { @@ -146,15 +143,15 @@ export default Vue.component('misskey-flavored-markdown', { innerHTML: token.html } }) - ]); + ])]; } case 'inline-code': { - return createElement('code', { + return [createElement('code', { domProps: { innerHTML: token.html } - }); + })]; } case 'quote': { @@ -164,58 +161,51 @@ export default Vue.component('misskey-flavored-markdown', { const x = text2.split('\n') .map(t => [createElement('span', t), createElement('br')]); x[x.length - 1].pop(); - return createElement('div', { + return [createElement('div', { attrs: { class: 'quote' } - }, x); + }, x)]; } else { - return createElement('span', { + return [createElement('span', { attrs: { class: 'quote' } - }, text2.replace(/\n/g, ' ')); + }, text2.replace(/\n/g, ' '))]; } } case 'title': { - return createElement('div', { + return [createElement('div', { attrs: { class: 'title' } - }, token.title); + }, token.title)]; } case 'emoji': { const emoji = emojilib.lib[token.emoji]; - return createElement('span', emoji ? emoji.char : token.content); + return [createElement('span', emoji ? emoji.char : token.content)]; } case 'search': { - return createElement(MkGoogle, { + return [createElement(MkGoogle, { props: { q: token.query } - }); + })]; } default: { console.log('unknown ast type:', token.type); - } - } - })); - const _els = []; - els.forEach((el, i) => { - if (el.tag == 'br') { - if (!['div', 'pre'].includes(els[i - 1].tag)) { - _els.push(el); + return []; } - } else { - _els.push(el); } - }); + })); + // el.tag === 'br' のとき i !== 0 が保証されるため、短絡評価により els[i - 1] は配列外参照しない + const _els = els.filter((el, i) => !(el.tag === 'br' && ['div', 'pre'].includes(els[i - 1].tag))); return createElement('span', _els); } }); diff --git a/src/client/app/common/views/components/nav.vue b/src/client/app/common/views/components/nav.vue index 27e66358e4..d52c8e27a4 100644 --- a/src/client/app/common/views/components/nav.vue +++ b/src/client/app/common/views/components/nav.vue @@ -2,6 +2,8 @@ <span class="mk-nav"> <a :href="aboutUrl">%i18n:@about%</a> <i>・</i> + <a href="/stats">%i18n:@stats%</a> + <i>・</i> <a :href="repositoryUrl">%i18n:@repository%</a> <i>・</i> <a :href="feedbackUrl" target="_blank">%i18n:@feedback%</a> diff --git a/src/client/app/common/views/components/note-header.vue b/src/client/app/common/views/components/note-header.vue index d25bd430f2..8192d88412 100644 --- a/src/client/app/common/views/components/note-header.vue +++ b/src/client/app/common/views/components/note-header.vue @@ -42,9 +42,7 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> -@import '~const.styl' - -root(isDark) +.bvonvjxbwzaiskogyhbwgyxvcgserpmu display flex align-items baseline white-space nowrap @@ -61,7 +59,7 @@ root(isDark) margin 0 .5em 0 0 padding 0 overflow hidden - color isDark ? #fff : #627079 + color var(--noteHeaderName) font-size 1em font-weight bold text-decoration none @@ -82,19 +80,19 @@ root(isDark) margin 0 .5em 0 0 padding 1px 6px font-size 80% - color isDark ? #758188 : #aaa - border solid 1px isDark ? #57616f : #ddd + color var(--noteHeaderBadgeFg) + background var(--noteHeaderBadgeBg) border-radius 3px &.is-admin - border-color isDark ? #d42c41 : #f56a7b - color isDark ? #d42c41 : #f56a7b + background var(--noteHeaderAdminBg) + color var(--noteHeaderAdminFg) > .username margin 0 .5em 0 0 overflow hidden text-overflow ellipsis - color isDark ? #606984 : #ccc + color var(--noteHeaderAcct) flex-shrink 2147483647 > .info @@ -102,7 +100,7 @@ root(isDark) font-size 0.9em > * - color isDark ? #606984 : #c0c0c0 + color var(--noteHeaderInfo) > .mobile margin-right 8px @@ -110,15 +108,9 @@ root(isDark) > .app margin-right 8px padding-right 8px - border-right solid 1px isDark ? #1c2023 : #eaeaea + border-right solid 1px var(--faceDivider) > .visibility margin-left 8px -.bvonvjxbwzaiskogyhbwgyxvcgserpmu[data-darkmode] - root(true) - -.bvonvjxbwzaiskogyhbwgyxvcgserpmu:not([data-darkmode]) - root(false) - </style> diff --git a/src/client/app/common/views/components/note-menu.vue b/src/client/app/common/views/components/note-menu.vue index 27a49a6536..c8ed1225cc 100644 --- a/src/client/app/common/views/components/note-menu.vue +++ b/src/client/app/common/views/components/note-menu.vue @@ -6,29 +6,51 @@ <script lang="ts"> import Vue from 'vue'; +import { url } from '../../../config'; +import copyToClipboard from '../../../common/scripts/copy-to-clipboard'; export default Vue.extend({ props: ['note', 'source', 'compact'], computed: { items() { - const items = []; - items.push({ + const items = [{ + icon: '%fa:info-circle%', + text: '%i18n:@detail%', + action: this.detail + }, { + icon: '%fa:link%', + text: '%i18n:@copy-link%', + action: this.copyLink + }, null, { icon: '%fa:star%', text: '%i18n:@favorite%', action: this.favorite - }); + }]; + if (this.note.userId == this.$store.state.i.id) { - items.push({ - icon: '%fa:thumbtack%', - text: '%i18n:@pin%', - action: this.pin - }); + if ((this.$store.state.i.pinnedNoteIds || []).includes(this.note.id)) { + items.push({ + icon: '%fa:thumbtack%', + text: '%i18n:@unpin%', + action: this.unpin + }); + } else { + items.push({ + icon: '%fa:thumbtack%', + text: '%i18n:@pin%', + action: this.pin + }); + } + } + + if (this.note.userId == this.$store.state.i.id || this.$store.state.i.isAdmin) { items.push({ icon: '%fa:trash-alt R%', text: '%i18n:@delete%', action: this.del }); } + if (this.note.uri) { items.push({ icon: '%fa:external-link-square-alt%', @@ -38,15 +60,33 @@ export default Vue.extend({ } }); } + return items; } }, + methods: { + detail() { + this.$router.push(`/notes/${ this.note.id }`); + }, + + copyLink() { + copyToClipboard(`${url}/notes/${ this.note.id }`); + }, + pin() { (this as any).api('i/pin', { noteId: this.note.id }).then(() => { - this.$destroy(); + this.destroyDom(); + }); + }, + + unpin() { + (this as any).api('i/unpin', { + noteId: this.note.id + }).then(() => { + this.destroyDom(); }); }, @@ -55,7 +95,7 @@ export default Vue.extend({ (this as any).api('notes/delete', { noteId: this.note.id }).then(() => { - this.$destroy(); + this.destroyDom(); }); }, @@ -63,13 +103,13 @@ export default Vue.extend({ (this as any).api('notes/favorites/create', { noteId: this.note.id }).then(() => { - this.$destroy(); + this.destroyDom(); }); }, closed() { this.$nextTick(() => { - this.$destroy(); + this.destroyDom(); }); } } diff --git a/src/client/app/common/views/components/poll-editor.vue b/src/client/app/common/views/components/poll-editor.vue index 115c934c8b..b5c57d48a5 100644 --- a/src/client/app/common/views/components/poll-editor.vue +++ b/src/client/app/common/views/components/poll-editor.vue @@ -20,6 +20,7 @@ <script lang="ts"> import Vue from 'vue'; +import { erase } from '../../../../../prelude/array'; export default Vue.extend({ data() { return { @@ -53,7 +54,7 @@ export default Vue.extend({ get() { return { - choices: this.choices.filter(choice => choice != '') + choices: erase('', this.choices) } }, @@ -67,9 +68,7 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> -@import '~const.styl' - -root(isDark) +.mk-poll-editor padding 8px > .caution @@ -102,49 +101,43 @@ root(isDark) padding 6px 8px width 300px font-size 14px - color isDark ? #fff : #000 - background isDark ? #191b22 : #fff - border solid 1px rgba($theme-color, 0.1) + color var(--inputText) + background var(--pollEditorInputBg) + border solid 1px var(--primaryAlpha01) border-radius 4px &:hover - border-color rgba($theme-color, 0.2) + border-color var(--primaryAlpha02) &:focus - border-color rgba($theme-color, 0.5) + border-color var(--primaryAlpha05) > button padding 4px 8px - color rgba($theme-color, 0.4) + color var(--primaryAlpha04) &:hover - color rgba($theme-color, 0.6) + color var(--primaryAlpha06) &:active - color darken($theme-color, 30%) + color var(--primaryDarken30) > .add margin 8px 0 0 0 vertical-align top - color $theme-color + color var(--primary) > .destroy position absolute top 0 right 0 padding 4px 8px - color rgba($theme-color, 0.4) + color var(--primaryAlpha04) &:hover - color rgba($theme-color, 0.6) + color var(--primaryAlpha06) &:active - color darken($theme-color, 30%) - -.mk-poll-editor[data-darkmode] - root(true) - -.mk-poll-editor:not([data-darkmode]) - root(false) + color var(--primaryDarken30) </style> diff --git a/src/client/app/common/views/components/poll.vue b/src/client/app/common/views/components/poll.vue index 660247edbc..0dc2622f9b 100644 --- a/src/client/app/common/views/components/poll.vue +++ b/src/client/app/common/views/components/poll.vue @@ -21,6 +21,7 @@ <script lang="ts"> import Vue from 'vue'; +import { sum } from '../../../../../prelude/array'; export default Vue.extend({ props: ['note'], data() { @@ -33,7 +34,7 @@ export default Vue.extend({ return this.note.poll; }, total(): number { - return this.poll.choices.reduce((a, b) => a + b.votes, 0); + return sum(this.poll.choices.map(x => x.votes)); }, isVoted(): boolean { return this.poll.choices.some(c => c.isVoted); @@ -66,10 +67,7 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> -@import '~const.styl' - -root(isDark) - +.mk-poll > ul display block margin 0 @@ -81,8 +79,8 @@ root(isDark) margin 4px 0 padding 4px 8px width 100% - color isDark ? #fff : #000 - border solid 1px isDark ? #5e636f : #eee + color var(--pollChoiceText) + border solid 1px var(--pollChoiceBorder) border-radius 4px overflow hidden cursor pointer @@ -98,7 +96,7 @@ root(isDark) top 0 left 0 height 100% - background $theme-color + background var(--primary) transition width 1s ease > span @@ -109,7 +107,7 @@ root(isDark) margin-left 4px > p - color isDark ? #a3aebf : #000 + color var(--text) a color inherit @@ -124,10 +122,4 @@ root(isDark) &: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-icon.vue b/src/client/app/common/views/components/reaction-icon.vue index 46886b8ab2..c668efac6b 100644 --- a/src/client/app/common/views/components/reaction-icon.vue +++ b/src/client/app/common/views/components/reaction-icon.vue @@ -1,17 +1,17 @@ <template> <span class="mk-reaction-icon"> - <img v-if="reaction == 'like'" src="/assets/reactions/like.png" alt="%i18n:common.reactions.like%"> - <img v-if="reaction == 'love'" src="/assets/reactions/love.png" alt="%i18n:common.reactions.love%"> - <img v-if="reaction == 'laugh'" src="/assets/reactions/laugh.png" alt="%i18n:common.reactions.laugh%"> - <img v-if="reaction == 'hmm'" src="/assets/reactions/hmm.png" alt="%i18n:common.reactions.hmm%"> - <img v-if="reaction == 'surprise'" src="/assets/reactions/surprise.png" alt="%i18n:common.reactions.surprise%"> - <img v-if="reaction == 'congrats'" src="/assets/reactions/congrats.png" alt="%i18n:common.reactions.congrats%"> - <img v-if="reaction == 'angry'" src="/assets/reactions/angry.png" alt="%i18n:common.reactions.angry%"> - <img v-if="reaction == 'confused'" src="/assets/reactions/confused.png" alt="%i18n:common.reactions.confused%"> - <img v-if="reaction == 'rip'" src="/assets/reactions/rip.png" alt="%i18n:common.reactions.rip%"> + <img v-if="reaction == 'like'" src="https://twemoji.maxcdn.com/2/svg/1f44d.svg" alt="%i18n:common.reactions.like%"> + <img v-if="reaction == 'love'" src="https://twemoji.maxcdn.com/2/svg/2764.svg" alt="%i18n:common.reactions.love%"> + <img v-if="reaction == 'laugh'" src="https://twemoji.maxcdn.com/2/svg/1f606.svg" alt="%i18n:common.reactions.laugh%"> + <img v-if="reaction == 'hmm'" src="https://twemoji.maxcdn.com/2/svg/1f914.svg" alt="%i18n:common.reactions.hmm%"> + <img v-if="reaction == 'surprise'" src="https://twemoji.maxcdn.com/2/svg/1f62e.svg" alt="%i18n:common.reactions.surprise%"> + <img v-if="reaction == 'congrats'" src="https://twemoji.maxcdn.com/2/svg/1f389.svg" alt="%i18n:common.reactions.congrats%"> + <img v-if="reaction == 'angry'" src="https://twemoji.maxcdn.com/2/svg/1f4a2.svg" alt="%i18n:common.reactions.angry%"> + <img v-if="reaction == 'confused'" src="https://twemoji.maxcdn.com/2/svg/1f625.svg" alt="%i18n:common.reactions.confused%"> + <img v-if="reaction == 'rip'" src="https://twemoji.maxcdn.com/2/svg/1f607.svg" alt="%i18n:common.reactions.rip%"> <template v-if="reaction == 'pudding'"> - <img v-if="$store.getters.isSignedIn && $store.state.settings.iLikeSushi" src="/assets/reactions/sushi.png" alt="%i18n:common.reactions.pudding%"> - <img v-else src="/assets/reactions/pudding.png" alt="%i18n:common.reactions.pudding%"> + <img v-if="$store.getters.isSignedIn && $store.state.settings.iLikeSushi" src="https://twemoji.maxcdn.com/2/svg/1f363.svg" alt="%i18n:common.reactions.pudding%"> + <img v-else src="https://twemoji.maxcdn.com/2/svg/1f36e.svg" alt="%i18n:common.reactions.pudding%"> </template> </span> </template> diff --git a/src/client/app/common/views/components/reaction-picker.vue b/src/client/app/common/views/components/reaction-picker.vue index a455afbf7d..13e8cf1f07 100644 --- a/src/client/app/common/views/components/reaction-picker.vue +++ b/src/client/app/common/views/components/reaction-picker.vue @@ -1,9 +1,9 @@ <template> -<div class="mk-reaction-picker"> +<div class="mk-reaction-picker" v-hotkey.global="keymap"> <div class="backdrop" ref="backdrop" @click="close"></div> <div class="popover" :class="{ compact, big }" ref="popover"> <p v-if="!compact">{{ title }}</p> - <div> + <div ref="buttons" :class="{ showFocus }"> <button @click="react('like')" @mouseover="onMouseover" @mouseout="onMouseout" tabindex="1" title="%i18n:common.reactions.like%"><mk-reaction-icon reaction='like'/></button> <button @click="react('love')" @mouseover="onMouseover" @mouseout="onMouseout" tabindex="2" title="%i18n:common.reactions.love%"><mk-reaction-icon reaction='love'/></button> <button @click="react('laugh')" @mouseover="onMouseover" @mouseout="onMouseout" tabindex="3" title="%i18n:common.reactions.laugh%"><mk-reaction-icon reaction='laugh'/></button> @@ -31,30 +31,84 @@ export default Vue.extend({ type: Object, required: true }, + source: { required: true }, + compact: { type: Boolean, required: false, default: false }, + cb: { required: false }, + big: { type: Boolean, required: false, default: false + }, + + showFocus: { + type: Boolean, + required: false, + default: false + }, + + animation: { + type: Boolean, + required: false, + default: true } }, + data() { return { - title: placeholder + title: placeholder, + focus: null }; }, + + computed: { + keymap(): any { + return { + 'esc': this.close, + 'enter|space|plus': this.choose, + 'up|k': this.focusUp, + 'left|h|shift+tab': this.focusLeft, + 'right|l|tab': this.focusRight, + 'down|j': this.focusDown, + '1': () => this.react('like'), + '2': () => this.react('love'), + '3': () => this.react('laugh'), + '4': () => this.react('hmm'), + '5': () => this.react('surprise'), + '6': () => this.react('congrats'), + '7': () => this.react('angry'), + '8': () => this.react('confused'), + '9': () => this.react('rip'), + '0': () => this.react('pudding'), + }; + } + }, + + watch: { + focus(i) { + this.$refs.buttons.children[i].focus(); + + if (this.showFocus) { + this.title = this.$refs.buttons.children[i].title; + } + } + }, + mounted() { this.$nextTick(() => { + this.focus = 0; + const popover = this.$refs.popover as any; const rect = this.source.getBoundingClientRect(); @@ -76,7 +130,7 @@ export default Vue.extend({ anime({ targets: this.$refs.backdrop, opacity: 1, - duration: 100, + duration: this.animation ? 100 : 0, easing: 'linear' }); @@ -84,10 +138,11 @@ export default Vue.extend({ targets: this.$refs.popover, opacity: 1, scale: [0.5, 1], - duration: 500 + duration: this.animation ? 500 : 0 }); }); }, + methods: { react(reaction) { (this as any).api('notes/reactions/create', { @@ -95,21 +150,25 @@ export default Vue.extend({ reaction: reaction }).then(() => { if (this.cb) this.cb(); - this.$destroy(); + this.$emit('closed'); + this.destroyDom(); }); }, + onMouseover(e) { this.title = e.target.title; }, + onMouseout(e) { this.title = placeholder; }, + close() { (this.$refs.backdrop as any).style.pointerEvents = 'none'; anime({ targets: this.$refs.backdrop, opacity: 0, - duration: 200, + duration: this.animation ? 200 : 0, easing: 'linear' }); @@ -118,21 +177,42 @@ export default Vue.extend({ targets: this.$refs.popover, opacity: 0, scale: 0.5, - duration: 200, + duration: this.animation ? 200 : 0, easing: 'easeInBack', - complete: () => this.$destroy() + complete: () => { + this.$emit('closed'); + this.destroyDom(); + } }); + }, + + focusUp() { + this.focus = this.focus == 0 ? 9 : this.focus < 5 ? (this.focus + 4) : (this.focus - 5); + }, + + focusDown() { + this.focus = this.focus == 9 ? 0 : this.focus >= 5 ? (this.focus - 4) : (this.focus + 5); + }, + + focusRight() { + this.focus = this.focus == 9 ? 0 : (this.focus + 1); + }, + + focusLeft() { + this.focus = this.focus == 0 ? 9 : (this.focus - 1); + }, + + choose() { + this.$refs.buttons.childNodes[this.focus].click(); } } }); </script> <style lang="stylus" scoped> -@import '~const.styl' - $border-color = rgba(27, 31, 35, 0.15) -root(isDark) +.mk-reaction-picker position initial > .backdrop @@ -142,11 +222,11 @@ root(isDark) z-index 10000 width 100% height 100% - background isDark ? rgba(#000, 0.4) : rgba(#000, 0.1) + background var(--modalBackdrop) opacity 0 > .popover - $bgcolor = isDark ? #2c303c : #fff + $bgcolor = var(--popupBg) position absolute z-index 10001 background $bgcolor @@ -199,14 +279,29 @@ root(isDark) margin 0 padding 8px 10px font-size 14px - color isDark ? #d6dce2 : #586069 - border-bottom solid 1px isDark ? #1c2023 : #e1e4e8 + color var(--popupFg) + border-bottom solid 1px var(--faceDivider) > div padding 4px width 240px text-align center + &.showFocus + > button:focus + z-index 1 + + &:after + content "" + pointer-events none + position absolute + top 0 + right 0 + bottom 0 + left 0 + border 2px solid var(--primaryAlpha03) + border-radius 4px + > button padding 0 width 40px @@ -215,16 +310,10 @@ root(isDark) border-radius 2px &:hover - background isDark ? #252731 : #eee + background var(--reactionPickerButtonHoverBg) &:active - background $theme-color + background var(--primary) 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 c30fa2a1dc..9212a84b31 100644 --- a/src/client/app/common/views/components/reactions-viewer.vue +++ b/src/client/app/common/views/components/reactions-viewer.vue @@ -39,10 +39,9 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> -root(isDark) - $borderColor = isDark ? #5e6673 : #eee - border-top dashed 1px $borderColor - border-bottom dashed 1px $borderColor +.mk-reactions-viewer + border-top dashed 1px var(--reactionViewerBorder) + border-bottom dashed 1px var(--reactionViewerBorder) margin 4px 0 &:empty @@ -60,12 +59,6 @@ root(isDark) > span margin-left 4px font-size 1.2em - color isDark ? #d1d5dc : #444 - -.mk-reactions-viewer[data-darkmode] - root(true) - -.mk-reactions-viewer:not([data-darkmode]) - root(false) + color var(--text) </style> diff --git a/src/client/app/common/views/components/signin.vue b/src/client/app/common/views/components/signin.vue index 5230ac371a..9224f82cb9 100644 --- a/src/client/app/common/views/components/signin.vue +++ b/src/client/app/common/views/components/signin.vue @@ -1,16 +1,16 @@ <template> <form class="mk-signin" :class="{ signing }" @submit.prevent="onSubmit"> <div class="avatar" :style="{ backgroundImage: user ? `url('${ user.avatarUrl }')` : null }" v-show="withAvatar"></div> - <ui-input v-model="username" type="text" pattern="^[a-zA-Z0-9_]+$" spellcheck="false" autofocus required @input="onUsernameChange"> + <ui-input v-model="username" type="text" pattern="^[a-zA-Z0-9_]+$" spellcheck="false" autofocus required @input="onUsernameChange" styl="fill"> <span>%i18n:@username%</span> <span slot="prefix">@</span> <span slot="suffix">@{{ host }}</span> </ui-input> - <ui-input v-model="password" type="password" required> + <ui-input v-model="password" type="password" required styl="fill"> <span>%i18n:@password%</span> <span slot="prefix">%fa:lock%</span> </ui-input> - <ui-input v-if="user && user.twoFactorEnabled" v-model="token" type="number" required/> + <ui-input v-if="user && user.twoFactorEnabled" v-model="token" type="number" required styl="fill"/> <ui-button type="submit" :disabled="signing">{{ signing ? '%i18n:@signing-in%' : '%i18n:@signin%' }}</ui-button> <p style="margin: 8px 0;">%i18n:@or% <a :href="`${apiUrl}/signin/twitter`">%i18n:@signin-with-twitter%</a></p> </form> @@ -56,7 +56,7 @@ export default Vue.extend({ username: this.username, password: this.password, token: this.user && this.user.twoFactorEnabled ? this.token : undefined - }).then(() => { + }, true).then(() => { location.reload(); }).catch(() => { alert('%i18n:@login-failed%'); @@ -68,7 +68,7 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> -@import '~const.styl' + .mk-signin color #555 @@ -78,7 +78,7 @@ export default Vue.extend({ cursor wait !important > .avatar - margin 16px auto 0 auto + margin 0 auto 0 auto width 64px height 64px background #ddd diff --git a/src/client/app/common/views/components/signup.vue b/src/client/app/common/views/components/signup.vue index f603b9545c..8e06b13491 100644 --- a/src/client/app/common/views/components/signup.vue +++ b/src/client/app/common/views/components/signup.vue @@ -1,12 +1,12 @@ <template> <form class="mk-signup" @submit.prevent="onSubmit" :autocomplete="Math.random()"> <template v-if="meta"> - <ui-input v-if="meta.disableRegistration" v-model="invitationCode" type="text" :autocomplete="Math.random()" spellcheck="false" required> + <ui-input v-if="meta.disableRegistration" v-model="invitationCode" type="text" :autocomplete="Math.random()" spellcheck="false" required styl="fill"> <span>%i18n:@invitation-code%</span> <span slot="prefix">%fa:id-card-alt%</span> <p slot="text" v-html="'%i18n:@invitation-info%'.replace('{}', meta.maintainer.url)"></p> </ui-input> - <ui-input v-model="username" type="text" pattern="^[a-zA-Z0-9_]{1,20}$" :autocomplete="Math.random()" spellcheck="false" required @input="onChangeUsername"> + <ui-input v-model="username" type="text" pattern="^[a-zA-Z0-9_]{1,20}$" :autocomplete="Math.random()" spellcheck="false" required @input="onChangeUsername" styl="fill"> <span>%i18n:@username%</span> <span slot="prefix">@</span> <span slot="suffix">@{{ host }}</span> @@ -18,7 +18,7 @@ <p slot="text" v-if="usernameState == 'min-range'" style="color:#FF1161">%fa:exclamation-triangle .fw% %i18n:@too-short%</p> <p slot="text" v-if="usernameState == 'max-range'" style="color:#FF1161">%fa:exclamation-triangle .fw% %i18n:@too-long%</p> </ui-input> - <ui-input v-model="password" type="password" :autocomplete="Math.random()" required @input="onChangePassword" :with-password-meter="true"> + <ui-input v-model="password" type="password" :autocomplete="Math.random()" required @input="onChangePassword" :with-password-meter="true" styl="fill"> <span>%i18n:@password%</span> <span slot="prefix">%fa:lock%</span> <div slot="text"> @@ -27,7 +27,7 @@ <p slot="text" v-if="passwordStrength == 'high'" style="color:#3CB7B5">%fa:check .fw% %i18n:@strong-password%</p> </div> </ui-input> - <ui-input v-model="retypedPassword" type="password" :autocomplete="Math.random()" required @input="onChangePasswordRetype"> + <ui-input v-model="retypedPassword" type="password" :autocomplete="Math.random()" required @input="onChangePasswordRetype" styl="fill"> <span>%i18n:@password% (%i18n:@retype%)</span> <span slot="prefix">%fa:lock%</span> <div slot="text"> @@ -131,11 +131,11 @@ export default Vue.extend({ password: this.password, invitationCode: this.invitationCode, 'g-recaptcha-response': this.meta.recaptchaSitekey != null ? (window as any).grecaptcha.getResponse() : null - }).then(() => { + }, true).then(() => { (this as any).api('signin', { username: this.username, password: this.password - }).then(() => { + }, true).then(() => { location.href = '/'; }); }).catch(() => { @@ -151,7 +151,7 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> -@import '~const.styl' + .mk-signup min-width 302px diff --git a/src/client/app/common/views/components/stream-indicator.vue b/src/client/app/common/views/components/stream-indicator.vue index d573db32e6..12bf78f130 100644 --- a/src/client/app/common/views/components/stream-indicator.vue +++ b/src/client/app/common/views/components/stream-indicator.vue @@ -1,14 +1,14 @@ <template> <div class="mk-stream-indicator"> - <p v-if=" stream.state == 'initializing' "> + <p v-if="stream.state == 'initializing'"> %fa:spinner .pulse% <span>%i18n:@connecting%<mk-ellipsis/></span> </p> - <p v-if=" stream.state == 'reconnecting' "> + <p v-if="stream.state == 'reconnecting'"> %fa:spinner .pulse% <span>%i18n:@reconnecting%<mk-ellipsis/></span> </p> - <p v-if=" stream.state == 'connected' "> + <p v-if="stream.state == 'connected'"> %fa:check% <span>%i18n:@connected%</span> </p> diff --git a/src/client/app/common/views/components/switch.vue b/src/client/app/common/views/components/switch.vue deleted file mode 100644 index 32caab638a..0000000000 --- a/src/client/app/common/views/components/switch.vue +++ /dev/null @@ -1,199 +0,0 @@ -<template> -<div - class="mk-switch" - :class="{ disabled, checked }" - role="switch" - :aria-checked="checked" - :aria-disabled="disabled" - @click="switchValue" - @mouseover="mouseenter" -> - <input - type="checkbox" - @change="handleChange" - ref="input" - :disabled="disabled" - @keydown.enter="switchValue" - > - <span class="button"> - <span :style="{ transform }"></span> - </span> - <span class="label"> - <span :aria-hidden="!checked">{{ text }}</span> - <p :aria-hidden="!checked"> - <slot></slot> - </p> - </span> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -export default Vue.extend({ - props: { - value: { - type: Boolean, - default: false - }, - disabled: { - type: Boolean, - default: false - }, - text: String - },/* - created() { - if (!~[true, false].indexOf(this.value)) { - this.$emit('input', false); - } - },*/ - computed: { - checked(): boolean { - return this.value; - }, - transform(): string { - return this.checked ? 'translate3d(20px, 0, 0)' : ''; - } - }, - watch: { - value() { - (this.$el).style.transition = 'all 0.3s'; - (this.$refs.input as any).checked = this.checked; - } - }, - mounted() { - (this.$refs.input as any).checked = this.checked; - }, - methods: { - mouseenter() { - (this.$el).style.transition = 'all 0s'; - }, - handleChange() { - (this.$el).style.transition = 'all 0.3s'; - this.$emit('input', !this.checked); - this.$emit('change', !this.checked); - this.$nextTick(() => { - // set input's checked property - // in case parent refuses to change component's value - (this.$refs.input as any).checked = this.checked; - }); - }, - switchValue() { - !this.disabled && this.handleChange(); - } - } -}); -</script> - -<style lang="stylus" scoped> -@import '~const.styl' - -root(isDark) - display flex - margin 12px 0 - cursor pointer - transition all 0.3s - - > * - user-select none - - &.disabled - opacity 0.6 - cursor not-allowed - - &.checked - > .button - background-color $theme-color - border-color $theme-color - - > .label - > span - color $theme-color - - &:hover - > .label - > span - color darken($theme-color, 10%) - - > .button - background darken($theme-color, 10%) - border-color darken($theme-color, 10%) - - &:hover - > .label - > span - color isDark ? #fff : #2e3338 - - > .button - $color = isDark ? #15181d : #ced2da - background $color - border-color $color - - > input - position absolute - width 0 - height 0 - opacity 0 - margin 0 - - &:focus + .button - &: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 14px - - > .button - $color = isDark ? #1c1f25 : #dcdfe6 - - display inline-block - margin 0 - width 40px - min-width 40px - height 20px - min-height 20px - background $color - border 1px solid $color - outline none - border-radius 10px - transition inherit - - > * - position absolute - top 1px - left 1px - border-radius 100% - transition transform 0.3s - width 16px - height 16px - background-color #fff - - > .label - margin-left 8px - display block - font-size 15px - cursor pointer - transition inherit - - > span - display block - line-height 20px - color isDark ? #c4ccd2 : #4a535a - transition inherit - - > p - margin 0 - //font-size 90% - 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/tag-cloud.vue b/src/client/app/common/views/components/tag-cloud.vue new file mode 100644 index 0000000000..5cc828082f --- /dev/null +++ b/src/client/app/common/views/components/tag-cloud.vue @@ -0,0 +1,84 @@ +<template> +<div class="jtivnzhfwquxpsfidertopbmwmchmnmo"> + <p class="fetching" v-if="fetching">%fa:spinner .pulse .fw%%i18n:common.loading%<mk-ellipsis/></p> + <p class="empty" v-else-if="tags.length == 0">%fa:exclamation-circle%%i18n:@empty%</p> + <div v-else> + <vue-word-cloud + :words="tags.slice(0, 20).map(x => [x.name, x.count])" + :color="color" + :spacing="1"> + <template slot-scope="{word, text, weight}"> + <div style="cursor: pointer;" :title="weight"> + {{ text }} + </div> + </template> + </vue-word-cloud> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import * as VueWordCloud from 'vuewordcloud'; + +export default Vue.extend({ + components: { + [VueWordCloud.name]: VueWordCloud + }, + data() { + return { + tags: [], + fetching: true, + clock: null + }; + }, + mounted() { + this.fetch(); + this.clock = setInterval(this.fetch, 1000 * 60); + }, + beforeDestroy() { + clearInterval(this.clock); + }, + methods: { + fetch() { + (this as any).api('aggregation/hashtags').then(tags => { + this.tags = tags; + this.fetching = false; + }); + }, + color([, weight]) { + const peak = Math.max.apply(null, this.tags.map(x => x.count)); + const w = weight / peak; + + if (w > 0.9) { + return this.$store.state.device.darkmode ? '#ff4e69' : '#ff4e69'; + } else if (w > 0.5) { + return this.$store.state.device.darkmode ? '#3bc4c7' : '#3bc4c7'; + } else { + return this.$store.state.device.darkmode ? '#fff' : '#555'; + } + } + } +}); +</script> + +<style lang="stylus" scoped> +.jtivnzhfwquxpsfidertopbmwmchmnmo + height 100% + width 100% + + > .fetching + > .empty + margin 0 + padding 16px + text-align center + color #aaa + + > [data-fa] + margin-right 4px + + > div + height 100% + width 100% + +</style> diff --git a/src/client/app/common/views/components/theme.vue b/src/client/app/common/views/components/theme.vue new file mode 100644 index 0000000000..9eda3c5796 --- /dev/null +++ b/src/client/app/common/views/components/theme.vue @@ -0,0 +1,308 @@ +<template> +<div class="nicnklzforebnpfgasiypmpdaaglujqm"> + <label> + <span>%i18n:@light-theme%</span> + <ui-select v-model="light" placeholder="%i18n:@light-theme%"> + <optgroup label="%i18n:@light-themes%"> + <option v-for="x in lightThemes" :value="x.id" :key="x.id">{{ x.name }}</option> + </optgroup> + <optgroup label="%i18n:@dark-themes%"> + <option v-for="x in darkThemes" :value="x.id" :key="x.id">{{ x.name }}</option> + </optgroup> + </ui-select> + </label> + + <label> + <span>%i18n:@dark-theme%</span> + <ui-select v-model="dark" placeholder="%i18n:@dark-theme%"> + <optgroup label="%i18n:@dark-themes%"> + <option v-for="x in darkThemes" :value="x.id" :key="x.id">{{ x.name }}</option> + </optgroup> + <optgroup label="%i18n:@light-themes%"> + <option v-for="x in lightThemes" :value="x.id" :key="x.id">{{ x.name }}</option> + </optgroup> + </ui-select> + </label> + + <details class="creator"> + <summary>%fa:palette% %i18n:@create-a-theme%</summary> + <div> + <span>%i18n:@base-theme%:</span> + <ui-radio v-model="myThemeBase" value="light">%i18n:@base-theme-light%</ui-radio> + <ui-radio v-model="myThemeBase" value="dark">%i18n:@base-theme-dark%</ui-radio> + </div> + <div> + <ui-input v-model="myThemeName"> + <span>%i18n:@theme-name%</span> + </ui-input> + <ui-textarea v-model="myThemeDesc"> + <span>%i18n:@desc%</span> + </ui-textarea> + </div> + <div> + <div style="padding-bottom:8px;">%i18n:@primary-color%:</div> + <color-picker v-model="myThemePrimary"/> + </div> + <div> + <div style="padding-bottom:8px;">%i18n:@secondary-color%:</div> + <color-picker v-model="myThemeSecondary"/> + </div> + <div> + <div style="padding-bottom:8px;">%i18n:@text-color%:</div> + <color-picker v-model="myThemeText"/> + </div> + <ui-button @click="preview()">%fa:eye% %i18n:@preview-created-theme%</ui-button> + <ui-button primary @click="gen()">%fa:save R% %i18n:@save-created-theme%</ui-button> + </details> + + <details> + <summary>%fa:download% %i18n:@install-a-theme%</summary> + <ui-button @click="import_()">%fa:file-import% %i18n:@import%</ui-button> + <input ref="file" type="file" accept=".misskeytheme" style="display:none;" @change="onUpdateImportFile"/> + <p>%i18n:@import-by-code%:</p> + <ui-textarea v-model="installThemeCode"> + <span>%i18n:@theme-code%</span> + </ui-textarea> + <ui-button @click="() => install(this.installThemeCode)">%fa:check% %i18n:@install%</ui-button> + </details> + + <details> + <summary>%fa:folder-open% %i18n:@installed-themes%</summary> + <ui-select v-model="selectedInstalledThemeId" placeholder="%i18n:@select-theme%"> + <option v-for="x in installedThemes" :value="x.id" :key="x.id">{{ x.name }}</option> + </ui-select> + <template v-if="selectedInstalledTheme"> + <ui-input readonly :value="selectedInstalledTheme.author"> + <span>%i18n:@author%</span> + </ui-input> + <ui-textarea v-if="selectedInstalledTheme.desc" readonly :value="selectedInstalledTheme.desc"> + <span>%i18n:@desc%</span> + </ui-textarea> + <ui-textarea readonly :value="selectedInstalledThemeCode"> + <span>%i18n:@theme-code%</span> + </ui-textarea> + <ui-button @click="export_()" link :download="`${selectedInstalledTheme.name}.misskeytheme`" ref="export">%fa:box% %i18n:@export%</ui-button> + <ui-button @click="uninstall()">%fa:trash-alt R% %i18n:@uninstall%</ui-button> + </template> + </details> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { lightTheme, darkTheme, builtinThemes, applyTheme, Theme } from '../../../theme'; +import { Chrome } from 'vue-color'; +import * as uuid from 'uuid'; +import * as tinycolor from 'tinycolor2'; +import * as JSON5 from 'json5'; + +// 後方互換性のため +function convertOldThemedefinition(t) { + const t2 = { + id: t.meta.id, + name: t.meta.name, + author: t.meta.author, + base: t.meta.base, + vars: t.meta.vars, + props: t + }; + delete t2.props.meta; + return t2; +} + +export default Vue.extend({ + components: { + ColorPicker: Chrome + }, + + data() { + return { + installThemeCode: null, + selectedInstalledThemeId: null, + myThemeBase: 'light', + myThemeName: '', + myThemeDesc: '', + myThemePrimary: lightTheme.vars.primary, + myThemeSecondary: lightTheme.vars.secondary, + myThemeText: lightTheme.vars.text + }; + }, + + computed: { + themes(): Theme[] { + return builtinThemes.concat(this.$store.state.device.themes); + }, + + darkThemes(): Theme[] { + return this.themes.filter(t => t.base == 'dark' || t.kind == 'dark'); + }, + + lightThemes(): Theme[] { + return this.themes.filter(t => t.base == 'light' || t.kind == 'light'); + }, + + installedThemes(): Theme[] { + return this.$store.state.device.themes; + }, + + light: { + get() { return this.$store.state.device.lightTheme; }, + set(value) { this.$store.commit('device/set', { key: 'lightTheme', value }); } + }, + + dark: { + get() { return this.$store.state.device.darkTheme; }, + set(value) { this.$store.commit('device/set', { key: 'darkTheme', value }); } + }, + + selectedInstalledTheme() { + if (this.selectedInstalledThemeId == null) return null; + return this.installedThemes.find(x => x.id == this.selectedInstalledThemeId); + }, + + selectedInstalledThemeCode() { + if (this.selectedInstalledTheme == null) return null; + return JSON5.stringify(this.selectedInstalledTheme, null, '\t'); + }, + + myTheme(): any { + return { + name: this.myThemeName, + author: this.$store.state.i.username, + desc: this.myThemeDesc, + base: this.myThemeBase, + vars: { + primary: tinycolor(typeof this.myThemePrimary == 'string' ? this.myThemePrimary : this.myThemePrimary.rgba).toRgbString(), + secondary: tinycolor(typeof this.myThemeSecondary == 'string' ? this.myThemeSecondary : this.myThemeSecondary.rgba).toRgbString(), + text: tinycolor(typeof this.myThemeText == 'string' ? this.myThemeText : this.myThemeText.rgba).toRgbString() + } + }; + } + }, + + watch: { + myThemeBase(v) { + const theme = v == 'light' ? lightTheme : darkTheme; + this.myThemePrimary = theme.vars.primary; + this.myThemeSecondary = theme.vars.secondary; + this.myThemeText = theme.vars.text; + } + }, + + beforeCreate() { + // migrate old theme definitions + // 後方互換性のため + this.$store.commit('device/set', { + key: 'themes', value: this.$store.state.device.themes.map(t => { + if (t.id == null) { + return convertOldThemedefinition(t); + } else { + return t; + } + }) + }); + }, + + methods: { + install(code) { + let theme; + + try { + theme = JSON5.parse(code); + } catch (e) { + alert('%i18n:@invalid-theme%'); + return; + } + + // 後方互換性のため + if (theme.id == null && theme.meta != null) { + theme = convertOldThemedefinition(theme); + } + + if (theme.id == null) { + alert('%i18n:@invalid-theme%'); + return; + } + + if (this.$store.state.device.themes.some(t => t.id == theme.id)) { + alert('%i18n:@already-installed%'); + return; + } + + const themes = this.$store.state.device.themes.concat(theme); + this.$store.commit('device/set', { + key: 'themes', value: themes + }); + + alert('%i18n:@installed%'.replace('{}', theme.name)); + }, + + uninstall() { + const theme = this.selectedInstalledTheme; + const themes = this.$store.state.device.themes.filter(t => t.id != theme.id); + this.$store.commit('device/set', { + key: 'themes', value: themes + }); + alert('%i18n:@uninstalled%'.replace('{}', theme.name)); + }, + + import_() { + (this.$refs.file as any).click(); + } + + export_() { + const blob = new Blob([this.selectedInstalledThemeCode], { + type: 'application/json5' + }); + this.$refs.export.$el.href = window.URL.createObjectURL(blob); + }, + + onUpdateImportFile() { + const f = (this.$refs.file as any).files[0]; + + const reader = new FileReader(); + + reader.onload = e => { + this.install(e.target.result); + }; + + reader.readAsText(f); + }, + + preview() { + applyTheme(this.myTheme, false); + }, + + gen() { + const theme = this.myTheme; + if (theme.name == null || theme.name.trim() == '') { + alert('%i18n:@theme-name-required%'); + return; + } + theme.id = uuid(); + const themes = this.$store.state.device.themes.concat(theme); + this.$store.commit('device/set', { + key: 'themes', value: themes + }); + alert('%i18n:@saved%'); + } + } +}); +</script> + +<style lang="stylus" scoped> +.nicnklzforebnpfgasiypmpdaaglujqm + > details + border-top solid 1px var(--faceDivider) + + > summary + padding 16px 0 + + > *:last-child + margin-bottom 16px + + > .creator + > div + padding 16px 0 + border-bottom solid 1px var(--faceDivider) +</style> diff --git a/src/client/app/common/views/widgets/hashtags.chart.vue b/src/client/app/common/views/components/trends.chart.vue index 723a3947f8..723a3947f8 100644 --- a/src/client/app/common/views/widgets/hashtags.chart.vue +++ b/src/client/app/common/views/components/trends.chart.vue diff --git a/src/client/app/common/views/components/trends.vue b/src/client/app/common/views/components/trends.vue new file mode 100644 index 0000000000..3d36d7449c --- /dev/null +++ b/src/client/app/common/views/components/trends.vue @@ -0,0 +1,98 @@ +<template> +<div class="csqvmxybqbycalfhkxvyfrgbrdalkaoc"> + <p class="fetching" v-if="fetching">%fa:spinner .pulse .fw%%i18n:common.loading%<mk-ellipsis/></p> + <p class="empty" v-else-if="stats.length == 0">%fa:exclamation-circle%%i18n:@empty%</p> + <!-- トランジションを有効にするとなぜかメモリリークする --> + <transition-group v-else tag="div" name="chart"> + <div v-for="stat in stats" :key="stat.tag"> + <div class="tag"> + <router-link :to="`/tags/${ encodeURIComponent(stat.tag) }`" :title="stat.tag">#{{ stat.tag }}</router-link> + <p>{{ '%i18n:@count%'.replace('{}', stat.usersCount) }}</p> + </div> + <x-chart class="chart" :src="stat.chart"/> + </div> + </transition-group> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import XChart from './trends.chart.vue'; + +export default Vue.extend({ + components: { + XChart + }, + data() { + return { + stats: [], + fetching: true, + clock: null + }; + }, + mounted() { + this.fetch(); + this.clock = setInterval(this.fetch, 1000 * 60); + }, + beforeDestroy() { + clearInterval(this.clock); + }, + methods: { + fetch() { + (this as any).api('hashtags/trend').then(stats => { + this.stats = stats; + this.fetching = false; + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +.csqvmxybqbycalfhkxvyfrgbrdalkaoc + > .fetching + > .empty + margin 0 + padding 16px + text-align center + color var(--text) + opacity 0.7 + + > [data-fa] + margin-right 4px + + > div + .chart-move + transition transform 1s ease + + > div + display flex + align-items center + padding 14px 16px + + &:not(:last-child) + border-bottom solid 1px var(--faceDivider) + + > .tag + flex 1 + overflow hidden + font-size 14px + color var(--text) + + > a + display block + width 100% + white-space nowrap + overflow hidden + text-overflow ellipsis + color inherit + + > p + margin 0 + font-size 75% + opacity 0.7 + + > .chart + height 30px + +</style> diff --git a/src/client/app/common/views/components/ui/button.vue b/src/client/app/common/views/components/ui/button.vue index e778750354..a509632520 100644 --- a/src/client/app/common/views/components/ui/button.vue +++ b/src/client/app/common/views/components/ui/button.vue @@ -1,9 +1,7 @@ <template> -<div class="ui-button" :class="[styl]"> - <button :type="type" @click="$emit('click')"> - <slot></slot> - </button> -</div> +<component class="dmtdnykelhudezerjlfpbhgovrgnqqgr" :is="link ? 'a' : 'button'" :class="[styl, { inline, primary }]" :type="type" @click="$emit('click')"> + <slot></slot> +</component> </template> <script lang="ts"> @@ -13,70 +11,100 @@ export default Vue.extend({ type: { type: String, required: false + }, + primary: { + type: Boolean, + required: false, + default: false + }, + inline: { + type: Boolean, + required: false, + default: false + }, + link: { + type: Boolean, + required: false, + default: false } }, data() { return { styl: 'fill' }; - }, - inject: { - isCardChild: { default: false } - }, - created() { - if (this.isCardChild) { - this.styl = 'line'; - } } }); </script> <style lang="stylus" scoped> -@import '~const.styl' +.dmtdnykelhudezerjlfpbhgovrgnqqgr + display block + width 100% + margin 0 + padding 8px + text-align center + font-weight normal + font-size 16px + border none + border-radius 6px + outline none + box-shadow none + text-decoration none + user-select none + + * + pointer-events none -root(isDark, fill) - > button - display block - width 100% - margin 0 - padding 0 + &:focus + &:after + content "" + pointer-events none + position absolute + top -5px + right -5px + bottom -5px + left -5px + border 2px solid var(--primaryAlpha03) + border-radius 10px + + &:not(.inline) + .dmtdnykelhudezerjlfpbhgovrgnqqgr + margin-top 16px + + &.inline + display inline-block + width auto + + &.primary font-weight bold - font-size 16px - line-height 44px - border none - border-radius 6px - outline none - box-shadow none - if fill - color $theme-color-foreground - background $theme-color + &.fill + color var(--text) + background var(--buttonBg) - &:hover - background lighten($theme-color, 5%) + &:hover + background var(--buttonHoverBg) - &:active - background darken($theme-color, 5%) - else - color $theme-color - background none + &:active + background var(--buttonActiveBg) + + &.primary + color var(--primaryForeground) + background var(--primary) &:hover - color darken($theme-color, 5%) + background var(--primaryLighten5) &:active - background rgba($theme-color, 0.3) + background var(--primaryDarken5) -.ui-button[data-darkmode] - &.fill - root(true, true) &:not(.fill) - root(true, false) + color var(--primary) + background none -.ui-button:not([data-darkmode]) - &.fill - root(false, true) - &:not(.fill) - root(false, false) + &:hover + color var(--primaryDarken5) + + &:active + background var(--primaryAlpha03) </style> diff --git a/src/client/app/common/views/components/ui/card.vue b/src/client/app/common/views/components/ui/card.vue index 05c51bca6b..a37a38d340 100644 --- a/src/client/app/common/views/components/ui/card.vue +++ b/src/client/app/common/views/components/ui/card.vue @@ -20,27 +20,33 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> -@import '~const.styl' - -root(isDark) +.ui-card margin 16px - padding 16px - color isDark ? #fff : #000 - background isDark ? #282C37 : #fff + color var(--faceText) + background var(--face) box-shadow 0 3px 1px -2px rgba(#000, 0.2), 0 2px 2px 0 rgba(#000, 0.14), 0 1px 5px 0 rgba(#000, 0.12) - @media (min-width 500px) - padding 32px - > header - font-weight normal - font-size 24px - color isDark ? #fff : #444 + padding 16px + font-weight bold + font-size 20px + color var(--faceText) + + @media (min-width 500px) + padding 24px 32px + + > section + padding 20px 16px + border-top solid 1px var(--faceDivider) -.ui-card[data-darkmode] - root(true) + @media (min-width 500px) + padding 32px -.ui-card:not([data-darkmode]) - root(false) + &.fit-top + padding-top 0 + > header + margin-bottom 16px + font-weight bold + color var(--faceText) </style> diff --git a/src/client/app/common/views/components/ui/form.vue b/src/client/app/common/views/components/ui/form.vue index fc8fdad9c4..5c5bbd7256 100644 --- a/src/client/app/common/views/components/ui/form.vue +++ b/src/client/app/common/views/components/ui/form.vue @@ -19,7 +19,7 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> -@import '~const.styl' + .ui-form > fieldset diff --git a/src/client/app/common/views/components/ui/form/button.vue b/src/client/app/common/views/components/ui/form/button.vue index 9c37b3118b..3fd7b47629 100644 --- a/src/client/app/common/views/components/ui/form/button.vue +++ b/src/client/app/common/views/components/ui/form/button.vue @@ -25,9 +25,7 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> -@import '~const.styl' - -root(isDark) +.nvemkhtwcnnpkdrwfcbzuwhfulejhmzg display inline-block & + .nvemkhtwcnnpkdrwfcbzuwhfulejhmzg @@ -38,11 +36,11 @@ root(isDark) margin 0 padding 12px 20px font-size 14px - border 1px solid isDark ? #6d727d : #dcdfe6 + border 1px solid var(--formButtonBorder) border-radius 4px outline none box-shadow none - color isDark ? #fff : #606266 + color var(--text) transition 0.1s * @@ -50,40 +48,34 @@ root(isDark) &:hover &:focus - color $theme-color - background rgba($theme-color, isDark ? 0.2 : 0.12) - border-color rgba($theme-color, isDark ? 0.5 : 0.3) + color var(--primary) + background var(--formButtonHoverBg) + border-color var(--formButtonHoverBorder) &:active - color darken($theme-color, 20%) - background rgba($theme-color, 0.12) - border-color $theme-color + color var(--primaryDarken20) + background var(--formButtonActiveBg) + border-color var(--primary) transition all 0s &.primary > button - border 1px solid $theme-color - background $theme-color - color $theme-color-foreground + border 1px solid var(--primary) + background var(--primary) + color var(--primaryForeground) &:hover &:focus - background lighten($theme-color, 20%) - border-color lighten($theme-color, 20%) + background var(--primaryLighten20) + border-color var(--primaryLighten20) &:active - background darken($theme-color, 20%) - border-color darken($theme-color, 20%) + background var(--primaryDarken20) + border-color var(--primaryDarken20) transition all 0s &.round > button border-radius 64px -.nvemkhtwcnnpkdrwfcbzuwhfulejhmzg[data-darkmode] - root(true) - -.nvemkhtwcnnpkdrwfcbzuwhfulejhmzg:not([data-darkmode]) - root(false) - </style> diff --git a/src/client/app/common/views/components/ui/form/radio.vue b/src/client/app/common/views/components/ui/form/radio.vue index 831981bb3e..396b2997e5 100644 --- a/src/client/app/common/views/components/ui/form/radio.vue +++ b/src/client/app/common/views/components/ui/form/radio.vue @@ -49,9 +49,7 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> -@import '~const.styl' - -root(isDark) +.uywduthvrdnlpsvsjkqigicixgyfctto display inline-flex margin 0 16px 0 0 cursor pointer @@ -62,7 +60,7 @@ root(isDark) &:hover > .button - border solid 2px isDark ? rgba(#fff, 0.7) : rgba(#000, 0.54) + border solid 2px var(--inputLabel) &.disabled opacity 0.6 @@ -70,15 +68,15 @@ root(isDark) &.checked > .button - border-color $theme-color + border-color var(--primary) &:after - background-color $theme-color + background-color var(--primary) transform scale(1) opacity 1 > .label - color $theme-color + color var(--primary) > input position absolute @@ -93,7 +91,7 @@ root(isDark) width 20px height 20px background none - border solid 2px isDark ? rgba(#fff, 0.6) : rgba(#000, 0.4) + border solid 2px var(--radioBorder) border-radius 100% transition inherit @@ -117,10 +115,4 @@ root(isDark) line-height 20px cursor pointer -.uywduthvrdnlpsvsjkqigicixgyfctto[data-darkmode] - root(true) - -.uywduthvrdnlpsvsjkqigicixgyfctto:not([data-darkmode]) - root(false) - </style> diff --git a/src/client/app/common/views/components/ui/input.vue b/src/client/app/common/views/components/ui/input.vue index ce28bfb12a..abbd5a2feb 100644 --- a/src/client/app/common/views/components/ui/input.vue +++ b/src/client/app/common/views/components/ui/input.vue @@ -71,14 +71,18 @@ export default Vue.extend({ type: Boolean, required: false, default: false + }, + styl: { + type: String, + required: false, + default: 'line' } }, data() { return { v: this.value, focused: false, - passwordStrength: '', - styl: 'fill' + passwordStrength: '' }; }, computed: { @@ -117,14 +121,6 @@ export default Vue.extend({ } } }, - inject: { - isCardChild: { default: false } - }, - created() { - if (this.isCardChild) { - this.styl = 'line'; - } - }, mounted() { if (this.$refs.prefix) { this.$refs.label.style.left = (this.$refs.prefix.offsetLeft + this.$refs.prefix.offsetWidth) + 'px'; @@ -155,9 +151,7 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> -@import '~const.styl' - -root(isDark, fill) +root(fill) margin 32px 0 > .icon @@ -167,7 +161,7 @@ root(isDark, fill) width 24px text-align center line-height 32px - color isDark ? rgba(#fff, 0.7) : rgba(#000, 0.54) + color var(--inputLabel) &:not(:empty) + .input margin-left 28px @@ -183,7 +177,7 @@ root(isDark, fill) left 0 right 0 height 1px - background isDark ? rgba(#fff, 0.7) : rgba(#000, 0.42) + background var(--inputBorder) &:after content '' @@ -193,7 +187,7 @@ root(isDark, fill) left 0 right 0 height 2px - background $theme-color + background var(--primary) opacity 0 transform scaleX(0.12) transition border 0.3s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1), transform 0.3s cubic-bezier(0.4, 0, 0.2, 1) @@ -242,7 +236,7 @@ root(isDark, fill) transition-duration 0.3s font-size 16px line-height 32px - color isDark ? rgba(#fff, 0.7) : rgba(#000, 0.54) + color var(--inputLabel) pointer-events none //will-change transform transform-origin top left @@ -257,7 +251,7 @@ root(isDark, fill) font-weight fill ? bold : normal font-size 16px line-height 32px - color isDark ? #fff : #000 + color var(--inputText) background transparent border none border-radius 0 @@ -280,7 +274,7 @@ root(isDark, fill) top 0 font-size 16px line-height fill ? 44px : 32px - color isDark ? rgba(#fff, 0.7) : rgba(#000, 0.54) + color var(--inputLabel) pointer-events none &:empty @@ -325,7 +319,7 @@ root(isDark, fill) transform scaleX(1) > .label - color $theme-color + color var(--primary) &.focused &.filled @@ -335,16 +329,10 @@ root(isDark, fill) left 0 !important transform scale(0.75) -.ui-input[data-darkmode] - &.fill - root(true, true) - &:not(.fill) - root(true, false) - -.ui-input:not([data-darkmode]) +.ui-input &.fill - root(false, true) + root(true) &:not(.fill) - root(false, false) + root(false) </style> diff --git a/src/client/app/common/views/components/ui/radio.vue b/src/client/app/common/views/components/ui/radio.vue index 04a46c5a96..868a339aa4 100644 --- a/src/client/app/common/views/components/ui/radio.vue +++ b/src/client/app/common/views/components/ui/radio.vue @@ -51,11 +51,9 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> -@import '~const.styl' - -root(isDark) +.ui-radio display inline-block - margin 32px 32px 32px 0 + margin 0 32px 0 0 cursor pointer transition all 0.3s @@ -68,10 +66,10 @@ root(isDark) &.checked > .button - border-color $theme-color + border-color var(--primary) &:after - background-color $theme-color + background-color var(--primary) transform scale(1) opacity 1 @@ -87,7 +85,7 @@ root(isDark) width 20px height 20px background none - border solid 2px isDark ? rgba(#fff, 0.7) : rgba(#000, 0.54) + border solid 2px var(--inputLabel) border-radius 100% transition inherit @@ -111,10 +109,4 @@ root(isDark) line-height 20px cursor pointer -.ui-radio[data-darkmode] - root(true) - -.ui-radio:not([data-darkmode]) - root(false) - </style> diff --git a/src/client/app/common/views/components/ui/select.vue b/src/client/app/common/views/components/ui/select.vue index 4273a4a0de..da6f9696b5 100644 --- a/src/client/app/common/views/components/ui/select.vue +++ b/src/client/app/common/views/components/ui/select.vue @@ -29,13 +29,17 @@ export default Vue.extend({ required: { type: Boolean, required: false + }, + styl: { + type: String, + required: false, + default: 'line' } }, data() { return { v: this.value, - focused: false, - styl: 'fill' + focused: false }; }, computed: { @@ -48,14 +52,6 @@ export default Vue.extend({ this.v = v; } }, - inject: { - isCardChild: { default: false } - }, - created() { - if (this.isCardChild) { - this.styl = 'line'; - } - }, mounted() { if (this.$refs.prefix) { this.$refs.label.style.left = (this.$refs.prefix.offsetLeft + this.$refs.prefix.offsetWidth) + 'px'; @@ -70,9 +66,7 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> -@import '~const.styl' - -root(isDark, fill) +root(fill) margin 32px 0 > .icon @@ -103,7 +97,7 @@ root(isDark, fill) left 0 right 0 height 1px - background isDark ? rgba(#fff, 0.7) : rgba(#000, 0.42) + background var(--inputBorder) &:after content '' @@ -113,7 +107,7 @@ root(isDark, fill) left 0 right 0 height 2px - background $theme-color + background var(--primary) opacity 0 transform scaleX(0.12) transition border 0.3s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1), transform 0.3s cubic-bezier(0.4, 0, 0.2, 1) @@ -143,7 +137,7 @@ root(isDark, fill) font-weight fill ? bold : normal font-size 16px height 32px - color isDark ? #fff : #000 + color var(--inputText) background transparent border none border-radius 0 @@ -190,7 +184,7 @@ root(isDark, fill) transform scaleX(1) > .label - color $theme-color + color var(--primary) &.focused &.filled @@ -200,16 +194,10 @@ root(isDark, fill) left 0 !important transform scale(0.75) -.ui-select[data-darkmode] - &.fill - root(true, true) - &:not(.fill) - root(true, false) - -.ui-select:not([data-darkmode]) +.ui-select &.fill - root(false, true) + root(true) &:not(.fill) - root(false, false) + root(false) </style> diff --git a/src/client/app/common/views/components/ui/switch.vue b/src/client/app/common/views/components/ui/switch.vue index a9e00d73d2..935f219833 100644 --- a/src/client/app/common/views/components/ui/switch.vue +++ b/src/client/app/common/views/components/ui/switch.vue @@ -19,7 +19,7 @@ <span class="label"> <span :aria-hidden="!checked"><slot></slot></span> <p :aria-hidden="!checked"> - <slot name="text"></slot> + <slot name="desc"></slot> </p> </span> </div> @@ -56,14 +56,18 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> -@import '~const.styl' - -root(isDark) +.ui-switch display flex margin 32px 0 cursor pointer transition all 0.3s + &:first-child + margin-top 0 + + &:last-child + margin-bottom 0 + > * user-select none @@ -73,11 +77,11 @@ root(isDark) &.checked > .button - background-color rgba($theme-color, 0.4) - border-color rgba($theme-color, 0.4) + background-color var(--primaryAlpha04) + border-color var(--primaryAlpha04) > * - background-color $theme-color + background-color var(--primary) transform translateX(14px) > input @@ -89,10 +93,11 @@ root(isDark) > .button display inline-block + flex-shrink 0 margin 3px 0 0 0 width 34px height 14px - background isDark ? rgba(#fff, 0.15) : rgba(#000, 0.25) + background var(--switchTrack) outline none border-radius 14px transition inherit @@ -118,18 +123,11 @@ root(isDark) > span display block line-height 20px - color isDark ? #c4ccd2 : rgba(#000, 0.75) + color currentColor transition inherit > p margin 0 - //font-size 90% - color isDark ? #78858e : #9daab3 - -.ui-switch[data-darkmode] - root(true) - -.ui-switch:not([data-darkmode]) - root(false) + opacity 0.7 </style> diff --git a/src/client/app/common/views/components/ui/textarea.vue b/src/client/app/common/views/components/ui/textarea.vue index 60fe1cdd82..67898ee059 100644 --- a/src/client/app/common/views/components/ui/textarea.vue +++ b/src/client/app/common/views/components/ui/textarea.vue @@ -63,9 +63,7 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> -@import '~const.styl' - -root(isDark, fill) +root(fill) margin 42px 0 32px 0 > .input @@ -84,7 +82,7 @@ root(isDark, fill) left 0 right 0 background none - border solid 1px isDark ? rgba(#fff, 0.7) : rgba(#000, 0.42) + border solid 1px var(--inputBorder) border-radius 3px pointer-events none @@ -97,7 +95,7 @@ root(isDark, fill) left 0 right 0 background none - border solid 2px $theme-color + border solid 2px var(--primary) border-radius 3px opacity 0 transition opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1) @@ -112,7 +110,7 @@ root(isDark, fill) transition-duration 0.3s font-size 16px line-height 32px - color isDark ? rgba(#fff, 0.7) : rgba(#000, 0.54) + color var(--inputLabel) pointer-events none //will-change transform transform-origin top left @@ -126,7 +124,7 @@ root(isDark, fill) font inherit font-weight fill ? bold : normal font-size 16px - color isDark ? #fff : #000 + color var(--inputText) background transparent border none border-radius 0 @@ -149,7 +147,7 @@ root(isDark, fill) opacity 1 > .label - color $theme-color + color var(--primary) &.focused &.filled @@ -159,16 +157,10 @@ root(isDark, fill) left 0 !important transform scale(0.75) -.ui-textarea[data-darkmode] - &.fill - root(true, true) - &:not(.fill) - root(true, false) +.ui-textarea.fill + root(true) -.ui-textarea:not([data-darkmode]) - &.fill - root(false, true) - &:not(.fill) - root(false, false) +.ui-textarea:not(.fill) + root(false) </style> diff --git a/src/client/app/common/views/components/uploader.vue b/src/client/app/common/views/components/uploader.vue index f4797d89f7..b812064bbb 100644 --- a/src/client/app/common/views/components/uploader.vue +++ b/src/client/app/common/views/components/uploader.vue @@ -20,6 +20,7 @@ <script lang="ts"> import Vue from 'vue'; import { apiUrl } from '../../../config'; +import getMD5 from '../../scripts/get-md5'; export default Vue.extend({ data() { @@ -28,61 +29,83 @@ export default Vue.extend({ }; }, methods: { - upload(file, folder) { - if (folder && typeof folder == 'object') folder = folder.id; + checkExistence(fileData: ArrayBuffer): Promise<any> { + return new Promise((resolve, reject) => { + const data = new FormData(); + data.append('md5', getMD5(fileData)); - const id = Math.random(); + (this as any).api('drive/files/check_existence', { + md5: getMD5(fileData) + }).then(resp => { + resolve(resp.file); + }); + }); + }, - const ctx = { - id: id, - name: file.name || 'untitled', - progress: undefined, - img: undefined - }; + upload(file: File, folder: any) { + if (folder && typeof folder == 'object') folder = folder.id; - this.uploads.push(ctx); - this.$emit('change', this.uploads); + const id = Math.random(); const reader = new FileReader(); reader.onload = (e: any) => { - ctx.img = e.target.result; - }; - reader.readAsDataURL(file); + this.checkExistence(e.target.result).then(result => { + if (result !== null) { + this.$emit('uploaded', result); + return; + } - const data = new FormData(); - data.append('i', this.$store.state.i.token); - data.append('file', file); + // Upload if the file didn't exist yet + const buf = new Uint8Array(e.target.result); + let bin = ''; + // We use for-of loop instead of apply() to avoid RangeError + // SEE: https://stackoverflow.com/questions/9267899/arraybuffer-to-base64-encoded-string + for (const byte of buf) bin += String.fromCharCode(byte); + const ctx = { + id: id, + name: file.name || 'untitled', + progress: undefined, + img: 'data:*/*;base64,' + btoa(bin) + }; - if (folder) data.append('folderId', folder); + this.uploads.push(ctx); + this.$emit('change', this.uploads); - const xhr = new XMLHttpRequest(); - xhr.open('POST', apiUrl + '/drive/files/create', true); - xhr.onload = (e: any) => { - const driveFile = JSON.parse(e.target.response); + const data = new FormData(); + data.append('i', this.$store.state.i.token); + data.append('file', file); - this.$emit('uploaded', driveFile); + if (folder) data.append('folderId', folder); - this.uploads = this.uploads.filter(x => x.id != id); - this.$emit('change', this.uploads); - }; + const xhr = new XMLHttpRequest(); + xhr.open('POST', apiUrl + '/drive/files/create', true); + xhr.onload = (e: any) => { + const driveFile = JSON.parse(e.target.response); - xhr.upload.onprogress = e => { - if (e.lengthComputable) { - if (ctx.progress == undefined) ctx.progress = {}; - ctx.progress.max = e.total; - ctx.progress.value = e.loaded; - } - }; + this.$emit('uploaded', driveFile); - xhr.send(data); + this.uploads = this.uploads.filter(x => x.id != id); + this.$emit('change', this.uploads); + }; + + xhr.upload.onprogress = e => { + if (e.lengthComputable) { + if (ctx.progress == undefined) ctx.progress = {}; + ctx.progress.max = e.total; + ctx.progress.value = e.loaded; + } + }; + + xhr.send(data); + }) + } + reader.readAsArrayBuffer(file); } } }); </script> <style lang="stylus" scoped> -@import '~const.styl' - .mk-uploader overflow auto @@ -100,7 +123,7 @@ export default Vue.extend({ margin 8px 0 0 0 padding 0 height 36px - box-shadow 0 -1px 0 rgba($theme-color, 0.1) + box-shadow 0 -1px 0 var(--primaryAlpha01) border-top solid 8px transparent &:first-child @@ -127,7 +150,7 @@ export default Vue.extend({ padding 0 max-width 256px font-size 0.8em - color rgba($theme-color, 0.7) + color var(--primaryAlpha07) white-space nowrap text-overflow ellipsis overflow hidden @@ -145,17 +168,17 @@ export default Vue.extend({ font-size 0.8em > .initing - color rgba($theme-color, 0.5) + color var(--primaryAlpha05) > .kb - color rgba($theme-color, 0.5) + color var(--primaryAlpha05) > .percentage display inline-block width 48px text-align right - color rgba($theme-color, 0.7) + color var(--primaryAlpha07) &:after content '%' @@ -174,10 +197,10 @@ export default Vue.extend({ overflow hidden &::-webkit-progress-value - background $theme-color + background var(--primary) &::-webkit-progress-bar - background rgba($theme-color, 0.1) + background var(--primaryAlpha01) > .progress display block @@ -191,13 +214,13 @@ export default Vue.extend({ border-radius 4px background linear-gradient( 45deg, - lighten($theme-color, 30%) 25%, - $theme-color 25%, - $theme-color 50%, - lighten($theme-color, 30%) 50%, - lighten($theme-color, 30%) 75%, - $theme-color 75%, - $theme-color + var(--primaryLighten30) 25%, + var(--primary) 25%, + var(--primary) 50%, + var(--primaryLighten30) 50%, + var(--primaryLighten30) 75%, + var(--primary) 75%, + var(--primary) ) background-size 32px 32px animation bg 1.5s linear infinite diff --git a/src/client/app/common/views/components/url-preview.vue b/src/client/app/common/views/components/url-preview.vue index 242d9ba5c6..86489cf8be 100644 --- a/src/client/app/common/views/components/url-preview.vue +++ b/src/client/app/common/views/components/url-preview.vue @@ -8,13 +8,13 @@ </blockquote> </div> <div v-else class="mk-url-preview"> - <a :href="url" target="_blank" :title="url" v-if="!fetching"> + <a :class="{ mini }" :href="url" target="_blank" :title="url" v-if="!fetching"> <div class="thumbnail" v-if="thumbnail" :style="`background-image: url(${thumbnail})`"></div> <article> <header> <h1>{{ title }}</h1> </header> - <p>{{ description }}</p> + <p>{{ description.length > 85 ? description.slice(0, 85) + '…' : description }}</p> <footer> <img class="icon" v-if="icon" :src="icon"/> <p>{{ sitename }}</p> @@ -118,6 +118,12 @@ export default Vue.extend({ type: Boolean, required: false, default: false + }, + + mini: { + type: Boolean, + required: false, + default: false } }, @@ -164,7 +170,7 @@ export default Vue.extend({ return; } - fetch('/url?url=' + encodeURIComponent(this.url)).then(res => { + fetch(`/url?url=${encodeURIComponent(this.url)}`).then(res => { res.json().then(info => { if (info.url == null) return; this.title = info.title; @@ -194,17 +200,17 @@ export default Vue.extend({ top 0 width 100% -root(isDark) +.mk-url-preview > a display block font-size 14px - border solid 1px isDark ? #191b1f : #eee + border solid 1px var(--urlPreviewBorder) border-radius 4px overflow hidden &:hover text-decoration none - border-color isDark ? #4f5561 : #ddd + border-color var(--urlPreviewBorderHover) > article > header > h1 text-decoration underline @@ -229,11 +235,11 @@ root(isDark) > h1 margin 0 font-size 1em - color isDark ? #d6dae0 : #555 + color var(--urlPreviewTitle) > p margin 0 - color isDark ? #a4aab3 : #777 + color var(--urlPreviewText) font-size 0.8em > footer @@ -250,7 +256,7 @@ root(isDark) > p display inline-block margin 0 - color isDark ? #b0b4bf : #666 + color var(--urlPreviewInfo) font-size 0.8em line-height 16px vertical-align top @@ -293,10 +299,27 @@ root(isDark) width 12px height 12px -.mk-url-preview[data-darkmode] - root(true) + &.mini + font-size 10px + + > .thumbnail + position relative + width 100% + height 60px -.mk-url-preview:not([data-darkmode]) - root(false) + > article + left 0 + width 100% + padding 8px + + > header + margin-bottom 4px + + > footer + margin-top 4px + + > img + width 12px + height 12px </style> diff --git a/src/client/app/common/views/components/url.vue b/src/client/app/common/views/components/url.vue index e6ffe4466d..04a1f30135 100644 --- a/src/client/app/common/views/components/url.vue +++ b/src/client/app/common/views/components/url.vue @@ -12,6 +12,7 @@ <script lang="ts"> import Vue from 'vue'; +import { toUnicode as decodePunycode } from 'punycode'; export default Vue.extend({ props: ['url', 'target'], data() { @@ -27,11 +28,11 @@ export default Vue.extend({ created() { const url = new URL(this.url); this.schema = url.protocol; - this.hostname = url.hostname; + this.hostname = decodePunycode(url.hostname); this.port = url.port; - this.pathname = url.pathname; - this.query = url.search; - this.hash = url.hash; + this.pathname = decodeURIComponent(url.pathname); + this.query = decodeURIComponent(url.search); + this.hash = decodeURIComponent(url.hash); } }); </script> diff --git a/src/client/app/common/views/components/visibility-chooser.vue b/src/client/app/common/views/components/visibility-chooser.vue index 4691604e57..02f33bfbc0 100644 --- a/src/client/app/common/views/components/visibility-chooser.vue +++ b/src/client/app/common/views/components/visibility-chooser.vue @@ -47,7 +47,7 @@ export default Vue.extend({ props: ['source', 'compact'], data() { return { - v: this.$store.state.device.visibility || 'public' + v: this.$store.state.settings.rememberNoteVisibility ? (this.$store.state.device.visibility || this.$store.state.settings.defaultNoteVisibility) : this.$store.state.settings.defaultNoteVisibility } }, mounted() { @@ -97,9 +97,11 @@ export default Vue.extend({ }, methods: { choose(visibility) { - this.$store.commit('device/setVisibility', visibility); + if (this.$store.state.settings.rememberNoteVisibility) { + this.$store.commit('device/setVisibility', visibility); + } this.$emit('chosen', visibility); - this.$destroy(); + this.destroyDom(); }, close() { (this.$refs.backdrop as any).style.pointerEvents = 'none'; @@ -117,7 +119,7 @@ export default Vue.extend({ scale: 0.5, duration: 200, easing: 'easeInBack', - complete: () => this.$destroy() + complete: () => this.destroyDom() }); } } @@ -125,11 +127,9 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> -@import '~const.styl' - $border-color = rgba(27, 31, 35, 0.15) -root(isDark) +.mk-visibility-chooser position initial > .backdrop @@ -139,11 +139,11 @@ root(isDark) z-index 10000 width 100% height 100% - background isDark ? rgba(#000, 0.4) : rgba(#000, 0.1) + background var(--modalBackdrop) opacity 0 > .popover - $bgcolor = isDark ? #2c303c : #fff + $bgcolor = var(--popupBg) position absolute z-index 10001 width 240px @@ -187,18 +187,18 @@ root(isDark) display flex padding 8px 14px font-size 12px - color isDark ? #fff : #666 + color var(--popupFg) cursor pointer &:hover - background isDark ? #252731 : #eee + background var(--faceClearButtonHover) &:active - background isDark ? #21242b : #ddd + background var(--faceClearButtonActive) &.active - color $theme-color-foreground - background $theme-color + color var(--primaryForeground) + background var(--primary) > * user-select none @@ -220,11 +220,4 @@ root(isDark) > 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 5a8b9df476..4a66db57b8 100644 --- a/src/client/app/common/views/components/welcome-timeline.vue +++ b/src/client/app/common/views/components/welcome-timeline.vue @@ -1,22 +1,24 @@ <template> <div class="mk-welcome-timeline"> - <div v-for="note in notes"> - <mk-avatar class="avatar" :user="note.user" target="_blank"/> - <div class="body"> - <header> - <router-link class="name" :to="note.user | userPage" v-user-preview="note.user.id">{{ note.user | userName }}</router-link> - <span class="username">@{{ note.user | acct }}</span> - <div class="info"> - <router-link class="created-at" :to="note | notePage"> - <mk-time :time="note.createdAt"/> - </router-link> + <transition-group name="ldzpakcixzickvggyixyrhqwjaefknon" tag="div"> + <div v-for="note in notes" :key="note.id"> + <mk-avatar class="avatar" :user="note.user" target="_blank"/> + <div class="body"> + <header> + <router-link class="name" :to="note.user | userPage" v-user-preview="note.user.id">{{ note.user | userName }}</router-link> + <span class="username">@{{ note.user | acct }}</span> + <div class="info"> + <router-link class="created-at" :to="note | notePage"> + <mk-time :time="note.createdAt"/> + </router-link> + </div> + </header> + <div class="text"> + <misskey-flavored-markdown v-if="note.text" :text="note.text"/> </div> - </header> - <div class="text"> - <misskey-flavored-markdown v-if="note.text" :text="note.text"/> </div> </div> - </div> + </transition-group> </div> </template> @@ -31,15 +33,27 @@ export default Vue.extend({ default: undefined } }, + data() { return { fetching: true, - notes: [] + notes: [], + connection: null }; }, + mounted() { this.fetch(); + + this.connection = (this as any).os.stream.useSharedConnection('localTimeline'); + + this.connection.on('note', this.onNote); + }, + + beforeDestroy() { + this.connection.dispose(); }, + methods: { fetch(cb?) { this.fetching = true; @@ -48,82 +62,92 @@ export default Vue.extend({ local: true, reply: false, renote: false, - media: false, - poll: false, - bot: false + file: false, + poll: false }).then(notes => { this.notes = notes; this.fetching = false; }); - } + }, + + onNote(note) { + if (note.replyId != null) return; + if (note.renoteId != null) return; + if (note.poll != null) return; + + this.notes.unshift(note); + }, } }); </script> <style lang="stylus" scoped> -root(isDark) - background isDark ? #282C37 : #fff +.ldzpakcixzickvggyixyrhqwjaefknon-enter +.ldzpakcixzickvggyixyrhqwjaefknon-leave-to + opacity 0 + transform translateY(-30px) - > div - padding 16px - overflow-wrap break-word - font-size .9em - color isDark ? #fff : #4C4C4C - border-bottom 1px solid isDark ? rgba(#000, 0.1) : rgba(#000, 0.05) +.mk-welcome-timeline + background var(--face) - &:after - content "" - display block - clear both + > div + > * + transition transform .3s ease, opacity .3s ease - > .avatar - display block - float left - position -webkit-sticky - position sticky - top 16px - width 42px - height 42px - border-radius 6px + > div + padding 16px + overflow-wrap break-word + font-size .9em + color var(--noteText) + border-bottom 1px solid var(--faceDivider) - > .body - float right - width calc(100% - 42px) - padding-left 12px + &:after + content "" + display block + clear both - > header - display flex - align-items center - margin-bottom 4px - white-space nowrap + > .avatar + display block + float left + position -webkit-sticky + position sticky + top 16px + width 42px + height 42px + border-radius 6px - > .name - display block - margin 0 .5em 0 0 - padding 0 - overflow hidden - font-weight bold - text-overflow ellipsis - color isDark ? #fff : #627079 + > .body + float right + width calc(100% - 42px) + padding-left 12px - > .username - margin 0 .5em 0 0 - color isDark ? #606984 : #ccc + > header + display flex + align-items center + margin-bottom 4px + white-space nowrap - > .info - margin-left auto - font-size 0.9em + > .name + display block + margin 0 .5em 0 0 + padding 0 + overflow hidden + font-weight bold + text-overflow ellipsis + color var(--noteHeaderName) - > .created-at - color isDark ? #606984 : #c0c0c0 + > .username + margin 0 .5em 0 0 + color var(--noteHeaderAcct) - > .text - text-align left + > .info + margin-left auto + font-size 0.9em -.mk-welcome-timeline[data-darkmode] - root(true) + > .created-at + color var(--noteHeaderInfo) -.mk-welcome-timeline:not([data-darkmode]) - root(false) + > .text + text-align left </style> diff --git a/src/client/app/common/views/directives/autocomplete.ts b/src/client/app/common/views/directives/autocomplete.ts index b252cf5c1f..f7f8e9bf16 100644 --- a/src/client/app/common/views/directives/autocomplete.ts +++ b/src/client/app/common/views/directives/autocomplete.ts @@ -167,7 +167,7 @@ class Autocomplete { private close() { if (this.suggestion == null) return; - this.suggestion.$destroy(); + this.suggestion.destroyDom(); this.suggestion = null; this.textarea.focus(); @@ -191,7 +191,7 @@ class Autocomplete { const acct = renderAcct(value); // 挿入 - this.text = trimmedBefore + '@' + acct + ' ' + after; + this.text = `${trimmedBefore}@${acct} ${after}`; // キャレットを戻す this.vm.$nextTick(() => { @@ -207,7 +207,7 @@ class Autocomplete { const after = source.substr(caret); // 挿入 - this.text = trimmedBefore + '#' + value + ' ' + after; + this.text = `${trimmedBefore}#${value} ${after}`; // キャレットを戻す this.vm.$nextTick(() => { diff --git a/src/client/app/common/views/filters/note.ts b/src/client/app/common/views/filters/note.ts index a611dc8685..3c9c8b7485 100644 --- a/src/client/app/common/views/filters/note.ts +++ b/src/client/app/common/views/filters/note.ts @@ -1,5 +1,5 @@ import Vue from 'vue'; Vue.filter('notePage', note => { - return '/notes/' + note.id; + return `/notes/${note.id}`; }); diff --git a/src/client/app/common/views/filters/user.ts b/src/client/app/common/views/filters/user.ts index ca0910fc53..e5220229b7 100644 --- a/src/client/app/common/views/filters/user.ts +++ b/src/client/app/common/views/filters/user.ts @@ -11,5 +11,5 @@ Vue.filter('userName', user => { }); Vue.filter('userPage', (user, path?) => { - return '/@' + Vue.filter('acct')(user) + (path ? '/' + path : ''); + return `/@${Vue.filter('acct')(user)}${(path ? `/${path}` : '')}`; }); diff --git a/src/client/app/common/views/pages/follow.vue b/src/client/app/common/views/pages/follow.vue index 13d855d20a..92f24fb538 100644 --- a/src/client/app/common/views/pages/follow.vue +++ b/src/client/app/common/views/pages/follow.vue @@ -1,6 +1,6 @@ <template> -<div class="syxhndwprovvuqhmyvveewmbqayniwkv" v-if="!fetching" :data-darkmode="$store.state.device.darkmode"> - <div class="signed-in-as" v-html="'%i18n:@signed-in-as%'.replace('{}', '<b>' + myName + '</b>')"></div> +<div class="syxhndwprovvuqhmyvveewmbqayniwkv" v-if="!fetching"> + <div class="signed-in-as" v-html="'%i18n:@signed-in-as%'.replace('{}', `<b>${myName}`)"></div> <main> <div class="banner" :style="bannerStyle"></div> @@ -19,7 +19,8 @@ @click="onClick" :disabled="followWait"> <template v-if="!followWait"> - <template v-if="user.hasPendingFollowRequestFromYou">%fa:hourglass-half% %i18n:@request-pending%</template> + <template v-if="user.hasPendingFollowRequestFromYou && user.isLocked">%fa:hourglass-half% %i18n:@request-pending%</template> + <template v-else-if="user.hasPendingFollowRequestFromYou && !user.isLocked">%fa:hourglass-start% %i18n:@follow-processing%</template> <template v-else-if="user.isFollowing">%fa:minus% %i18n:@following%</template> <template v-else-if="!user.isFollowing && user.isLocked">%fa:plus% %i18n:@follow-request%</template> <template v-else-if="!user.isFollowing && !user.isLocked">%fa:plus% %i18n:@follow%</template> @@ -32,7 +33,6 @@ <script lang="ts"> import Vue from 'vue'; import parseAcct from '../../../../../misc/acct/parse'; -import getUserName from '../../../../../misc/get-user-name'; import Progress from '../../../common/scripts/loading'; export default Vue.extend({ @@ -83,7 +83,7 @@ export default Vue.extend({ userId: this.user.id }); } else { - if (this.user.isLocked && this.user.hasPendingFollowRequestFromYou) { + if (this.user.hasPendingFollowRequestFromYou) { this.user = await (this as any).api('following/requests/cancel', { userId: this.user.id }); @@ -108,16 +108,14 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> -@import '~const.styl' - -root(isDark) +.syxhndwprovvuqhmyvveewmbqayniwkv padding 32px max-width 500px margin 0 auto text-align center - color isDark ? #9baec8 : #868c8c + color var(--text) - $bg = isDark ? #282C37 : #fff + $bg = var(--face) @media (max-width 400px) padding 16px @@ -125,7 +123,6 @@ root(isDark) > .signed-in-as margin-bottom 16px font-size 14px - color isDark ? #9baec8 : #9daab3 > main margin-bottom 16px @@ -174,29 +171,29 @@ root(isDark) min-width 150px font-size 14px font-weight bold - color $theme-color + color var(--primary) background transparent outline none - border solid 1px $theme-color + border solid 1px var(--primary) border-radius 36px &:hover - background rgba($theme-color, 0.1) + background var(--primaryAlpha01) &:active - background rgba($theme-color, 0.2) + background var(--primaryAlpha02) &.active - color $theme-color-foreground - background $theme-color + color var(--primaryForeground) + background var(--primary) &:hover - background lighten($theme-color, 10%) - border-color lighten($theme-color, 10%) + background var(--primaryLighten10) + border-color var(--primaryLighten10) &:active - background darken($theme-color, 10%) - border-color darken($theme-color, 10%) + background var(--primaryDarken10) + border-color var(--primaryDarken10) &.wait cursor wait !important @@ -205,10 +202,4 @@ root(isDark) * pointer-events none -.syxhndwprovvuqhmyvveewmbqayniwkv[data-darkmode] - root(true) - -.syxhndwprovvuqhmyvveewmbqayniwkv:not([data-darkmode]) - root(false) - </style> diff --git a/src/client/app/common/views/widgets/analog-clock.vue b/src/client/app/common/views/widgets/analog-clock.vue index 0de30228b3..cfcdd5a1b6 100644 --- a/src/client/app/common/views/widgets/analog-clock.vue +++ b/src/client/app/common/views/widgets/analog-clock.vue @@ -1,8 +1,8 @@ <template> <div class="mkw-analog-clock"> - <mk-widget-container :naked="!(props.design % 2)" :show-header="false"> + <mk-widget-container :naked="props.style % 2 === 0" :show-header="false"> <div class="mkw-analog-clock--body"> - <mk-analog-clock :dark="$store.state.device.darkmode" :smooth="!(props.design && ~props.design)"/> + <mk-analog-clock :dark="$store.state.device.darkmode" :smooth="props.style < 2"/> </div> </mk-widget-container> </div> @@ -13,13 +13,12 @@ import define from '../../../common/define-widget'; export default define({ name: 'analog-clock', props: () => ({ - design: -1 + style: 0 }) }).extend({ methods: { func() { - if (++this.props.design > 2) - this.props.design = -1; + this.props.style = (this.props.style + 1) % 4; this.save(); } } @@ -27,16 +26,8 @@ export default define({ </script> <style lang="stylus" scoped> -@import '~const.styl' - -root(isDark) +.mkw-analog-clock .mkw-analog-clock--body padding 8px -.mkw-analog-clock[data-darkmode] - root(true) - -.mkw-analog-clock:not([data-darkmode]) - root(false) - </style> diff --git a/src/client/app/common/views/widgets/broadcast.vue b/src/client/app/common/views/widgets/broadcast.vue index 69b2a54fe9..620b09ff0e 100644 --- a/src/client/app/common/views/widgets/broadcast.vue +++ b/src/client/app/common/views/widgets/broadcast.vue @@ -1,31 +1,34 @@ <template> -<div class="mkw-broadcast" - :data-found="broadcasts.length != 0" - :data-melt="props.design == 1" - :data-mobile="platform == 'mobile'" -> - <div class="icon"> - <svg height="32" version="1.1" viewBox="0 0 32 32" width="32"> - <path class="tower" d="M16.04,11.24c1.79,0,3.239-1.45,3.239-3.24S17.83,4.76,16.04,4.76c-1.79,0-3.24,1.45-3.24,3.24 C12.78,9.78,14.24,11.24,16.04,11.24z M16.04,13.84c-0.82,0-1.66-0.2-2.4-0.6L7.34,29.98h2.98l1.72-2h8l1.681,2H24.7L18.42,13.24 C17.66,13.64,16.859,13.84,16.04,13.84z M16.02,14.8l2.02,7.2h-4L16.02,14.8z M12.04,25.98l2-2h4l2,2H12.04z"></path> - <path class="wave a" d="M4.66,1.04c-0.508-0.508-1.332-0.508-1.84,0c-1.86,1.92-2.8,4.44-2.8,6.94c0,2.52,0.94,5.04,2.8,6.96 c0.5,0.52,1.32,0.52,1.82,0s0.5-1.36,0-1.88C3.28,11.66,2.6,9.82,2.6,7.98S3.28,4.3,4.64,2.9C5.157,2.391,5.166,1.56,4.66,1.04z"></path> - <path class="wave b" d="M9.58,12.22c0.5-0.5,0.5-1.34,0-1.84C8.94,9.72,8.62,8.86,8.62,8s0.32-1.72,0.96-2.38c0.5-0.52,0.5-1.34,0-1.84 C9.346,3.534,9.02,3.396,8.68,3.4c-0.32,0-0.66,0.12-0.9,0.38C6.64,4.94,6.08,6.48,6.08,8s0.58,3.06,1.7,4.22 C8.28,12.72,9.1,12.72,9.58,12.22z"></path> - <path class="wave c" d="M22.42,3.78c-0.5,0.5-0.5,1.34,0,1.84c0.641,0.66,0.96,1.52,0.96,2.38s-0.319,1.72-0.96,2.38c-0.5,0.52-0.5,1.34,0,1.84 c0.487,0.497,1.285,0.505,1.781,0.018c0.007-0.006,0.013-0.012,0.02-0.018c1.139-1.16,1.699-2.7,1.699-4.22s-0.561-3.06-1.699-4.22 c-0.494-0.497-1.297-0.5-1.794-0.007C22.424,3.775,22.422,3.778,22.42,3.78z"></path> - <path class="wave d" d="M29.18,1.06c-0.479-0.502-1.273-0.522-1.775-0.044c-0.016,0.015-0.029,0.029-0.045,0.044c-0.5,0.52-0.5,1.36,0,1.88 c1.361,1.4,2.041,3.24,2.041,5.08s-0.68,3.66-2.041,5.08c-0.5,0.52-0.5,1.36,0,1.88c0.509,0.508,1.332,0.508,1.841,0 c1.86-1.92,2.8-4.44,2.8-6.96C31.99,5.424,30.98,2.931,29.18,1.06z"></path> - </svg> - </div> - <p class="fetching" v-if="fetching">%i18n:@fetching%<mk-ellipsis/></p> - <h1 v-if="!fetching">{{ broadcasts.length == 0 ? '%i18n:@no-broadcasts%' : broadcasts[i].title }}</h1> - <p v-if="!fetching"> - <span v-if="broadcasts.length != 0" v-html="broadcasts[i].text"></span> - <template v-if="broadcasts.length == 0">%i18n:@have-a-nice-day%</template> - </p> - <a v-if="broadcasts.length > 1" @click="next">%i18n:@next% >></a> +<div class="anltbovirfeutcigvwgmgxipejaeozxi"> + <mk-widget-container :show-header="false" :naked="props.design == 1"> + <div class="anltbovirfeutcigvwgmgxipejaeozxi-body" + :data-found="announcements && announcements.length != 0" + :data-melt="props.design == 1" + :data-mobile="platform == 'mobile'" + > + <div class="icon"> + <svg height="32" version="1.1" viewBox="0 0 32 32" width="32"> + <path class="tower" d="M16.04,11.24c1.79,0,3.239-1.45,3.239-3.24S17.83,4.76,16.04,4.76c-1.79,0-3.24,1.45-3.24,3.24 C12.78,9.78,14.24,11.24,16.04,11.24z M16.04,13.84c-0.82,0-1.66-0.2-2.4-0.6L7.34,29.98h2.98l1.72-2h8l1.681,2H24.7L18.42,13.24 C17.66,13.64,16.859,13.84,16.04,13.84z M16.02,14.8l2.02,7.2h-4L16.02,14.8z M12.04,25.98l2-2h4l2,2H12.04z"></path> + <path class="wave a" d="M4.66,1.04c-0.508-0.508-1.332-0.508-1.84,0c-1.86,1.92-2.8,4.44-2.8,6.94c0,2.52,0.94,5.04,2.8,6.96 c0.5,0.52,1.32,0.52,1.82,0s0.5-1.36,0-1.88C3.28,11.66,2.6,9.82,2.6,7.98S3.28,4.3,4.64,2.9C5.157,2.391,5.166,1.56,4.66,1.04z"></path> + <path class="wave b" d="M9.58,12.22c0.5-0.5,0.5-1.34,0-1.84C8.94,9.72,8.62,8.86,8.62,8s0.32-1.72,0.96-2.38c0.5-0.52,0.5-1.34,0-1.84 C9.346,3.534,9.02,3.396,8.68,3.4c-0.32,0-0.66,0.12-0.9,0.38C6.64,4.94,6.08,6.48,6.08,8s0.58,3.06,1.7,4.22 C8.28,12.72,9.1,12.72,9.58,12.22z"></path> + <path class="wave c" d="M22.42,3.78c-0.5,0.5-0.5,1.34,0,1.84c0.641,0.66,0.96,1.52,0.96,2.38s-0.319,1.72-0.96,2.38c-0.5,0.52-0.5,1.34,0,1.84 c0.487,0.497,1.285,0.505,1.781,0.018c0.007-0.006,0.013-0.012,0.02-0.018c1.139-1.16,1.699-2.7,1.699-4.22s-0.561-3.06-1.699-4.22 c-0.494-0.497-1.297-0.5-1.794-0.007C22.424,3.775,22.422,3.778,22.42,3.78z"></path> + <path class="wave d" d="M29.18,1.06c-0.479-0.502-1.273-0.522-1.775-0.044c-0.016,0.015-0.029,0.029-0.045,0.044c-0.5,0.52-0.5,1.36,0,1.88 c1.361,1.4,2.041,3.24,2.041,5.08s-0.68,3.66-2.041,5.08c-0.5,0.52-0.5,1.36,0,1.88c0.509,0.508,1.332,0.508,1.841,0 c1.86-1.92,2.8-4.44,2.8-6.96C31.99,5.424,30.98,2.931,29.18,1.06z"></path> + </svg> + </div> + <p class="fetching" v-if="fetching">%i18n:@fetching%<mk-ellipsis/></p> + <h1 v-if="!fetching">{{ announcements.length == 0 ? '%i18n:@no-broadcasts%' : announcements[i].title }}</h1> + <p v-if="!fetching"> + <span v-if="announcements.length != 0" v-html="announcements[i].text"></span> + <template v-if="announcements.length == 0">%i18n:@have-a-nice-day%</template> + </p> + <a v-if="announcements.length > 1" @click="next">%i18n:@next% >></a> + </div> + </mk-widget-container> </div> </template> <script lang="ts"> import define from '../../../common/define-widget'; -import { lang } from '../../../config'; export default define({ name: 'broadcast', @@ -37,26 +40,18 @@ export default define({ return { i: 0, fetching: true, - broadcasts: [] + announcements: [] }; }, mounted() { (this as any).os.getMeta().then(meta => { - let broadcasts = []; - if (meta.broadcasts) { - meta.broadcasts.forEach(broadcast => { - if (broadcast[lang]) { - broadcasts.push(broadcast[lang]); - } - }); - } - this.broadcasts = broadcasts; + this.announcements = meta.broadcasts; this.fetching = false; }); }, methods: { next() { - if (this.i == this.broadcasts.length - 1) { + if (this.i == this.announcements.length - 1) { this.i = 0; } else { this.i++; @@ -75,13 +70,12 @@ export default define({ </script> <style lang="stylus" scoped> -.mkw-broadcast +.anltbovirfeutcigvwgmgxipejaeozxi-body padding 10px - border solid 1px #4078c0 - border-radius 6px + background var(--announcementsBg) &[data-melt] - border none + background transparent &[data-found] padding-left 50px @@ -135,22 +129,18 @@ export default define({ margin 0 font-size 0.95em font-weight normal - color #4078c0 + color var(--announcementsTitle) > p display block z-index 1 margin 0 font-size 0.7em - color #555 + color var(--announcementsText) &.fetching text-align center - a - color #555 - text-decoration underline - > a display block font-size 0.7em diff --git a/src/client/app/common/views/widgets/calendar.vue b/src/client/app/common/views/widgets/calendar.vue index eb15030370..308d73bad8 100644 --- a/src/client/app/common/views/widgets/calendar.vue +++ b/src/client/app/common/views/widgets/calendar.vue @@ -116,15 +116,13 @@ export default define({ </script> <style lang="stylus" scoped> -@import '~const.styl' - -root(isDark) +.mkw-calendar &[data-special='on-new-years-day'] border-color #ef95a0 .mkw-calendar--body padding 16px 0 - color isDark ? #c5ced6 : #777 + color var(--calendarDay) &:after content "" @@ -169,7 +167,8 @@ root(isDark) margin 0 0 2px 0 font-size 12px line-height 18px - color isDark ? #7a8692 : #888 + color var(--text) + opacity 0.8 > b margin-left 2px @@ -177,12 +176,12 @@ root(isDark) > .meter width 100% overflow hidden - background isDark ? #1c1f25 : #eee + background var(--materBg) border-radius 8px > .val height 4px - background $theme-color + background var(--primary) transition width .3s cubic-bezier(0.23, 1, 0.32, 1) &:nth-child(1) @@ -197,10 +196,4 @@ root(isDark) > .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 544ca1bd9d..b025b41e7d 100644 --- a/src/client/app/common/views/widgets/donation.vue +++ b/src/client/app/common/views/widgets/donation.vue @@ -1,13 +1,15 @@ <template> -<div class="mkw-donation" :data-mobile="platform == 'mobile'"> - <article> - <h1>%fa:heart%%i18n:@title%</h1> - <p v-if="meta"> - {{ '%i18n:@text%'.substr(0, '%i18n:@text%'.indexOf('{')) }} - <a :href="meta.maintainer.url">{{ meta.maintainer.name }}</a> - {{ '%i18n:@text%'.substr('%i18n:@text%'.indexOf('}') + 1) }} - </p> - </article> +<div> + <mk-widget-container :show-header="false"> + <article class="dolfvtibguprpxxhfndqaosjitixjohx"> + <h1>%fa:heart%%i18n:@title%</h1> + <p v-if="meta"> + {{ '%i18n:@text%'.substr(0, '%i18n:@text%'.indexOf('{')) }} + <a :href="meta.maintainer.url">{{ meta.maintainer.name }}</a> + {{ '%i18n:@text%'.substr('%i18n:@text%'.indexOf('}') + 1) }} + </p> + </article> + </mk-widget-container> </div> </template> @@ -30,46 +32,22 @@ export default define({ </script> <style lang="stylus" scoped> -root(isDark) - background isDark ? #282c37 : #fff - border solid 1px isDark ? #c3831c : #ead8bb - border-radius 6px +.dolfvtibguprpxxhfndqaosjitixjohx + padding 20px + background var(--donationBg) + color var(--donationFg) - > article - padding 20px + > h1 + margin 0 0 5px 0 + font-size 1em - > h1 - margin 0 0 5px 0 - font-size 1em - color isDark ? #b2bac1 : #888 + > [data-fa] + margin-right 0.25em - > [data-fa] - margin-right 0.25em - - > p - display block - z-index 1 - margin 0 - font-size 0.8em - color isDark ? #a1a6ab : #999 - - &[data-mobile] - border none - background #ead8bb - border-radius 8px - box-shadow 0 0 0 1px rgba(#000, 0.2) - - > article - > h1 - color #7b8871 - - > p - color #777d71 - -.mkw-donation[data-darkmode] - root(true) - -.mkw-donation:not([data-darkmode]) - root(false) + > p + display block + z-index 1 + margin 0 + font-size 0.8em </style> diff --git a/src/client/app/common/views/widgets/hashtags.vue b/src/client/app/common/views/widgets/hashtags.vue index 56520400b6..0cb6b2df10 100644 --- a/src/client/app/common/views/widgets/hashtags.vue +++ b/src/client/app/common/views/widgets/hashtags.vue @@ -4,20 +4,7 @@ <template slot="header">%fa:hashtag%%i18n:@title%</template> <div class="mkw-hashtags--body" :data-mobile="platform == 'mobile'"> - <p class="fetching" v-if="fetching">%fa:spinner .pulse .fw%%i18n:common.loading%<mk-ellipsis/></p> - <p class="empty" v-else-if="stats.length == 0">%fa:exclamation-circle%%i18n:@empty%</p> - <!-- トランジションを有効にするとなぜかメモリリークする --> - <!-- <transition-group v-else tag="div" name="chart"> --> - <div> - <div v-for="stat in stats" :key="stat.tag"> - <div class="tag"> - <router-link :to="`/tags/${ encodeURIComponent(stat.tag) }`" :title="stat.tag">#{{ stat.tag }}</router-link> - <p>{{ '%i18n:@count%'.replace('{}', stat.usersCount) }}</p> - </div> - <x-chart class="chart" :src="stat.chart"/> - </div> - </div> - <!-- </transition-group> --> + <mk-trends/> </div> </mk-widget-container> </div> @@ -25,7 +12,6 @@ <script lang="ts"> import define from '../../../common/define-widget'; -import XChart from './hashtags.chart.vue'; export default define({ name: 'hashtags', @@ -33,89 +19,11 @@ export default define({ compact: false }) }).extend({ - components: { - XChart - }, - data() { - return { - stats: [], - fetching: true, - clock: null - }; - }, - mounted() { - this.fetch(); - this.clock = setInterval(this.fetch, 1000 * 60); - }, - beforeDestroy() { - clearInterval(this.clock); - }, methods: { func() { this.props.compact = !this.props.compact; this.save(); - }, - fetch() { - (this as any).api('hashtags/trend').then(stats => { - this.stats = stats; - this.fetching = false; - }); } } }); </script> - -<style lang="stylus" scoped> -root(isDark) - .mkw-hashtags--body - > .fetching - > .empty - margin 0 - padding 16px - text-align center - color #aaa - - > [data-fa] - margin-right 4px - - > div - .chart-move - transition transform 1s ease - - > div - display flex - align-items center - padding 14px 16px - - &:not(:last-child) - border-bottom solid 1px isDark ? #393f4f : #eee - - > .tag - flex 1 - overflow hidden - font-size 14px - color isDark ? #9baec8 : #65727b - - > a - display block - width 100% - white-space nowrap - overflow hidden - text-overflow ellipsis - color inherit - - > p - margin 0 - font-size 75% - opacity 0.7 - - > .chart - height 30px - -.mkw-hashtags[data-darkmode] - root(true) - -.mkw-hashtags:not([data-darkmode]) - root(false) - -</style> diff --git a/src/client/app/common/views/widgets/memo.vue b/src/client/app/common/views/widgets/memo.vue index 30f0d3b009..be8b18a4e9 100644 --- a/src/client/app/common/views/widgets/memo.vue +++ b/src/client/app/common/views/widgets/memo.vue @@ -57,9 +57,7 @@ export default define({ </script> <style lang="stylus" scoped> -@import '~const.styl' - -root(isDark) +.mkw-memo .mkw-memo--body padding-bottom 28px + 16px @@ -69,10 +67,10 @@ root(isDark) max-width 100% min-width 100% padding 16px - color isDark ? #fff : #222 - background isDark ? #282c37 : #fff + color var(--inputText) + background var(--face) border none - border-bottom solid 1px isDark ? #1c2023 : #eee + border-bottom solid 1px var(--faceDivider) border-radius 0 > button @@ -83,8 +81,8 @@ root(isDark) margin 0 padding 0 10px height 28px - color $theme-color-foreground - background $theme-color !important + color var(--primaryForeground) + background var(--primary) !important outline none border none border-radius 4px @@ -92,20 +90,14 @@ root(isDark) cursor pointer &:hover - background lighten($theme-color, 10%) !important + background var(--primaryLighten10) !important &:active - background darken($theme-color, 10%) !important + background var(--primaryDarken10) !important transition background 0s ease &:disabled opacity 0.7 cursor default -.mkw-memo[data-darkmode] - root(true) - -.mkw-memo: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 0cbf7c158e..12003db3f2 100644 --- a/src/client/app/common/views/widgets/nav.vue +++ b/src/client/app/common/views/widgets/nav.vue @@ -16,23 +16,17 @@ export default define({ </script> <style lang="stylus" scoped> -root(isDark) +.mkw-nav .mkw-nav--body padding 16px font-size 12px - color isDark ? #9aa4b3 : #aaa - background isDark ? #282c37 : #fff + color var(--text) + background var(--face) a - color isDark ? #9aa4b3 : #999 + color var(--text) i - color isDark ? #9aa4b3 : #ccc - -.mkw-nav[data-darkmode] - root(true) - -.mkw-nav:not([data-darkmode]) - root(false) + color var(--text) </style> diff --git a/src/client/app/common/views/widgets/photo-stream.vue b/src/client/app/common/views/widgets/photo-stream.vue index 3e24c58e8e..047b01df4f 100644 --- a/src/client/app/common/views/widgets/photo-stream.vue +++ b/src/client/app/common/views/widgets/photo-stream.vue @@ -24,15 +24,13 @@ export default define({ return { images: [], fetching: true, - connection: null, - connectionId: null + connection: null }; }, mounted() { - this.connection = (this as any).os.stream.getConnection(); - this.connectionId = (this as any).os.stream.use(); + this.connection = (this as any).os.stream.useSharedConnection('main'); - this.connection.on('drive_file_created', this.onDriveFileCreated); + this.connection.on('driveFileCreated', this.onDriveFileCreated); (this as any).api('drive/stream', { type: 'image/*', @@ -43,8 +41,7 @@ export default define({ }); }, beforeDestroy() { - this.connection.off('drive_file_created', this.onDriveFileCreated); - (this as any).os.stream.dispose(this.connectionId); + this.connection.dispose(); }, methods: { onDriveFileCreated(file) { diff --git a/src/client/app/common/views/widgets/posts-monitor.vue b/src/client/app/common/views/widgets/posts-monitor.vue index 801307be54..1c70e6dbc4 100644 --- a/src/client/app/common/views/widgets/posts-monitor.vue +++ b/src/client/app/common/views/widgets/posts-monitor.vue @@ -4,7 +4,7 @@ <template slot="header">%fa:chart-line%%i18n:@title%</template> <button slot="func" @click="toggle" title="%i18n:@toggle%">%fa:sort%</button> - <div class="qpdmibaztplkylerhdbllwcokyrfxeyj" :class="{ dual: props.view == 0 }" :data-darkmode="$store.state.device.darkmode"> + <div class="qpdmibaztplkylerhdbllwcokyrfxeyj" :class="{ dual: props.view == 0 }"> <svg :viewBox="`0 0 ${ viewBoxX } ${ viewBoxY }`" v-show="props.view != 2"> <defs> <linearGradient :id="localGradientId" x1="0" x2="0" y1="1" y2="0"> @@ -82,7 +82,6 @@ export default define({ data() { return { connection: null, - connectionId: null, viewBoxY: 30, stats: [], fediGradientId: uuid(), @@ -110,8 +109,7 @@ export default define({ } }, mounted() { - this.connection = (this as any).os.streams.notesStatsStream.getConnection(); - this.connectionId = (this as any).os.streams.notesStatsStream.use(); + this.connection = (this as any).os.stream.useSharedConnection('notesStats'); this.connection.on('stats', this.onStats); this.connection.on('statsLog', this.onStatsLog); @@ -121,9 +119,7 @@ export default define({ }); }, beforeDestroy() { - this.connection.off('stats', this.onStats); - this.connection.off('statsLog', this.onStatsLog); - (this as any).os.streams.notesStatsStream.dispose(this.connectionId); + this.connection.dispose(); }, methods: { toggle() { @@ -173,7 +169,7 @@ export default define({ </script> <style lang="stylus" scoped> -root(isDark) +.qpdmibaztplkylerhdbllwcokyrfxeyj &.dual > svg width 50% @@ -192,7 +188,7 @@ root(isDark) > text font-size 5px - fill isDark ? rgba(#fff, 0.55) : rgba(#000, 0.55) + fill var(--chartCaption) > tspan opacity 0.5 @@ -202,10 +198,4 @@ root(isDark) display block clear both -.qpdmibaztplkylerhdbllwcokyrfxeyj[data-darkmode] - root(true) - -.qpdmibaztplkylerhdbllwcokyrfxeyj:not([data-darkmode]) - root(false) - </style> diff --git a/src/client/app/common/views/widgets/rss.vue b/src/client/app/common/views/widgets/rss.vue index a777388cdb..448eee9fb6 100644 --- a/src/client/app/common/views/widgets/rss.vue +++ b/src/client/app/common/views/widgets/rss.vue @@ -65,7 +65,7 @@ export default define({ </script> <style lang="stylus" scoped> -root(isDark) +.mkw-rss .mkw-rss--body .feed padding 12px 16px @@ -74,8 +74,8 @@ root(isDark) > a display block padding 4px 0 - color isDark ? #9aa4b3 : #666 - border-bottom dashed 1px isDark ? #1c2023 : #eee + color var(--text) + border-bottom dashed 1px var(--faceDivider) &:last-child border-bottom none @@ -90,7 +90,7 @@ root(isDark) margin-right 4px &[data-mobile] - background isDark ? #21242f : #f3f3f3 + background var(--face) .feed padding 0 @@ -100,12 +100,6 @@ root(isDark) border-bottom none &:nth-child(even) - background isDark ? rgba(#000, 0.05) : rgba(#fff, 0.7) - -.mkw-rss[data-darkmode] - root(true) - -.mkw-rss:not([data-darkmode]) - root(false) + background rgba(#000, 0.05) </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 b0421d6150..55aa1ea895 100644 --- a/src/client/app/common/views/widgets/server.cpu-memory.vue +++ b/src/client/app/common/views/widgets/server.cpu-memory.vue @@ -129,7 +129,7 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> -root(isDark) +.cpu-memory > svg display block padding 10px @@ -144,7 +144,7 @@ root(isDark) > text font-size 5px - fill isDark ? rgba(#fff, 0.55) : rgba(#000, 0.55) + fill var(--chartCaption) > tspan opacity 0.5 @@ -154,10 +154,4 @@ root(isDark) 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 b9748bdf7c..2034aee0eb 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> -root(isDark) +.cpu > .pie padding 10px height 100px @@ -52,7 +52,7 @@ root(isDark) > p margin 0 font-size 12px - color isDark ? #a8b4bd : #505050 + color var(--chartCaption) &:first-child font-weight bold @@ -65,10 +65,4 @@ root(isDark) 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 99ce624051..667576ab76 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> -root(isDark) +.disk > .pie padding 10px height 100px @@ -60,7 +60,7 @@ root(isDark) > p margin 0 font-size 12px - color isDark ? #a8b4bd : #505050 + color var(--chartCaption) &:first-child font-weight bold @@ -73,10 +73,4 @@ root(isDark) 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 8a60621343..9e12884cf9 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> -root(isDark) +.memory > .pie padding 10px height 100px @@ -60,7 +60,7 @@ root(isDark) > p margin 0 font-size 12px - color isDark ? #a8b4bd : #505050 + color var(--chartCaption) &:first-child font-weight bold @@ -73,10 +73,4 @@ root(isDark) 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 d557c52ea5..ce342fd41b 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> -root(isDark) +svg display block height 100% @@ -56,12 +56,6 @@ root(isDark) > text font-size 0.15px - fill isDark ? rgba(#fff, 0.6) : rgba(#000, 0.6) - -svg[data-darkmode] - root(true) - -svg:not([data-darkmode]) - root(false) + fill var(--chartCaption) </style> diff --git a/src/client/app/common/views/widgets/server.vue b/src/client/app/common/views/widgets/server.vue index d796a3ae05..62d75e2bf6 100644 --- a/src/client/app/common/views/widgets/server.vue +++ b/src/client/app/common/views/widgets/server.vue @@ -45,8 +45,7 @@ export default define({ return { fetching: true, meta: null, - connection: null, - connectionId: null + connection: null }; }, mounted() { @@ -55,11 +54,10 @@ export default define({ this.fetching = false; }); - this.connection = (this as any).os.streams.serverStatsStream.getConnection(); - this.connectionId = (this as any).os.streams.serverStatsStream.use(); + this.connection = (this as any).os.stream.useSharedConnection('serverStats'); }, beforeDestroy() { - (this as any).os.streams.serverStatsStream.dispose(this.connectionId); + this.connection.dispose(); }, methods: { toggle() { diff --git a/src/client/app/config.ts b/src/client/app/config.ts index 74b9ea21c8..c3bc427eab 100644 --- a/src/client/app/config.ts +++ b/src/client/app/config.ts @@ -4,6 +4,7 @@ declare const _THEME_COLOR_: string; declare const _COPYRIGHT_: string; declare const _VERSION_: string; declare const _CODENAME_: string; +declare const _ENV_: string; const address = new URL(location.href); @@ -11,10 +12,11 @@ export const host = address.host; export const hostname = address.hostname; export const url = address.origin; export const apiUrl = url + '/api'; -export const wsUrl = url.replace('http://', 'ws://').replace('https://', 'wss://'); +export const wsUrl = url.replace('http://', 'ws://').replace('https://', 'wss://') + '/streaming'; export const lang = _LANG_; export const langs = _LANGS_; export const themeColor = _THEME_COLOR_; export const copyright = _COPYRIGHT_; export const version = _VERSION_; export const codename = _CODENAME_; +export const env = _ENV_; diff --git a/src/client/app/desktop/api/update-avatar.ts b/src/client/app/desktop/api/update-avatar.ts index e9d92d1eb1..f08e8a2b4e 100644 --- a/src/client/app/desktop/api/update-avatar.ts +++ b/src/client/app/desktop/api/update-avatar.ts @@ -16,7 +16,7 @@ export default (os: OS) => { text: '%i18n:common.got-it%' }] }); - reject(); + return reject('invalid-filetype'); } const w = os.new(CropWindow, { diff --git a/src/client/app/desktop/api/update-banner.ts b/src/client/app/desktop/api/update-banner.ts index e8fa35149b..42c9d69349 100644 --- a/src/client/app/desktop/api/update-banner.ts +++ b/src/client/app/desktop/api/update-banner.ts @@ -16,7 +16,7 @@ export default (os: OS) => { text: '%i18n:common.got-it%' }] }); - reject(); + return reject('invalid-filetype'); } const w = os.new(CropWindow, { diff --git a/src/client/app/desktop/assets/header-icon.light.svg b/src/client/app/desktop/assets/header-icon.light.svg deleted file mode 100644 index 61e2026243..0000000000 --- a/src/client/app/desktop/assets/header-icon.light.svg +++ /dev/null @@ -1,150 +0,0 @@ -<?xml version="1.0" encoding="UTF-8" standalone="no"?> -<!-- Created with Inkscape (http://www.inkscape.org/) --> - -<svg - xmlns:dc="http://purl.org/dc/elements/1.1/" - xmlns:cc="http://creativecommons.org/ns#" - xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" - xmlns:svg="http://www.w3.org/2000/svg" - xmlns="http://www.w3.org/2000/svg" - xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" - xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" - width="512" - height="512" - viewBox="0 0 135.46667 135.46667" - version="1.1" - id="svg8" - inkscape:version="0.92.1 r15371" - sodipodi:docname="header-icon.light.svg" - inkscape:export-filename="C:\Users\syuilo\projects\misskey\assets\favicon\32.png" - inkscape:export-xdpi="6" - inkscape:export-ydpi="6"> - <defs - id="defs2"> - <inkscape:path-effect - effect="simplify" - id="path-effect5115" - is_visible="true" - steps="1" - threshold="0.000408163" - smooth_angles="360" - helper_size="0" - simplify_individual_paths="false" - simplify_just_coalesce="false" - simplifyindividualpaths="false" - simplifyJustCoalesce="false" /> - <inkscape:path-effect - effect="simplify" - id="path-effect5111" - is_visible="true" - steps="1" - threshold="0.000408163" - smooth_angles="360" - helper_size="0" - simplify_individual_paths="false" - simplify_just_coalesce="false" - simplifyindividualpaths="false" - simplifyJustCoalesce="false" /> - <inkscape:path-effect - effect="simplify" - id="path-effect5104" - is_visible="true" - steps="1" - threshold="0.000408163" - smooth_angles="360" - helper_size="0" - simplify_individual_paths="false" - simplify_just_coalesce="false" - simplifyindividualpaths="false" - simplifyJustCoalesce="false" /> - </defs> - <sodipodi:namedview - id="base" - pagecolor="#ffffff" - bordercolor="#666666" - borderopacity="1.0" - inkscape:pageopacity="0.0" - inkscape:pageshadow="2" - inkscape:zoom="1.4142136" - inkscape:cx="114.309" - inkscape:cy="251.50613" - inkscape:document-units="px" - inkscape:current-layer="g4502" - showgrid="true" - units="px" - inkscape:snap-bbox="true" - inkscape:bbox-nodes="true" - inkscape:snap-bbox-edge-midpoints="false" - inkscape:snap-smooth-nodes="true" - inkscape:snap-center="true" - inkscape:snap-page="true" - inkscape:window-width="1920" - inkscape:window-height="1027" - inkscape:window-x="-8" - inkscape:window-y="1072" - inkscape:window-maximized="1" - inkscape:snap-object-midpoints="true" - inkscape:snap-midpoints="true" - inkscape:object-paths="true" - fit-margin-top="0" - fit-margin-left="0" - fit-margin-right="0" - fit-margin-bottom="0" - objecttolerance="1" - guidetolerance="1" - inkscape:snap-nodes="false" - inkscape:snap-others="false"> - <inkscape:grid - type="xygrid" - id="grid4504" - spacingx="4.2333334" - spacingy="4.2333334" - empcolor="#ff3fff" - empopacity="0.25098039" - empspacing="4" /> - </sodipodi:namedview> - <metadata - id="metadata5"> - <rdf:RDF> - <cc:Work - rdf:about=""> - <dc:format>image/svg+xml</dc:format> - <dc:type - rdf:resource="http://purl.org/dc/dcmitype/StillImage" /> - <dc:title /> - </cc:Work> - </rdf:RDF> - </metadata> - <g - inkscape:label="レイヤー 1" - inkscape:groupmode="layer" - id="layer1" - transform="translate(-30.809093,-111.78601)"> - <g - id="g4502" - transform="matrix(1.096096,0,0,1.096096,-2.960633,-44.023579)"> - <g - style="fill:#000000;fill-opacity:1" - transform="translate(-1.3333333e-6,-1.3439941e-6)" - id="g5125"> - <g - transform="matrix(0.91391326,0,0,0.91391326,7.9719907,17.595761)" - id="text4489" - style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:141.03404236px;line-height:476.69509888px;font-family:'OTADESIGN Rounded';-inkscape-font-specification:'OTADESIGN Rounded';letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.28950602px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" - aria-label="Mi"> - <path - sodipodi:nodetypes="zccssscssccscczzzccsccsscscsccz" - inkscape:connector-curvature="0" - id="path5210" - style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:'OTADESIGN Rounded';-inkscape-font-specification:'OTADESIGN Rounded';fill:#000000;fill-opacity:1;stroke-width:0.28950602px" - d="m 75.196381,231.17126 c -5.855419,0.0202 -10.885068,-3.50766 -13.2572,-7.61584 -1.266603,-1.79454 -3.772419,-2.43291 -3.807919,0 v 11.2332 c 0,4.51309 -1.645397,8.41504 -4.936191,11.70583 -3.196772,3.19677 -7.098714,4.79516 -11.705826,4.79516 -4.513089,0 -8.415031,-1.59839 -11.705825,-4.79516 -3.196772,-3.29079 -4.795158,-7.19274 -4.795158,-11.70583 v -61.7729 c 0,-3.47884 0.987238,-6.6286 2.961715,-9.44928 2.068499,-2.91471 4.701135,-4.9362 7.897906,-6.06447 1.786431,-0.65816 3.666885,-0.98724 5.641362,-0.98724 5.077225,0 9.308247,1.97448 12.693064,5.92343 1.786431,1.97448 2.820681,3.00873 3.102749,3.10275 0,0 13.408119,16.21319 13.78421,16.49526 0.376091,0.28206 1.480789,2.43848 4.127113,2.43848 2.646324,0 3.89218,-2.15642 4.26827,-2.43848 0.376091,-0.28207 13.784088,-16.49526 13.784088,-16.49526 0.09402,0.094 1.081261,-0.94022 2.961715,-3.10275 3.478837,-3.94895 7.756866,-5.92343 12.834096,-5.92343 1.88045,0 3.76091,0.32908 5.64136,0.98724 3.19677,1.12827 5.7824,3.14976 7.75688,6.06447 2.06849,2.82068 3.10274,5.97044 3.10274,9.44928 v 61.7729 c 0,4.51309 -1.6454,8.41504 -4.93619,11.70583 -3.19677,3.19677 -7.09871,4.79516 -11.70582,4.79516 -4.51309,0 -8.41504,-1.59839 -11.705828,-4.79516 -3.196772,-3.29079 -4.795158,-7.19274 -4.795158,-11.70583 v -11.2332 c -0.277898,-3.06563 -2.987588,-1.13379 -3.948953,0 -2.538613,4.70114 -7.401781,7.59567 -13.2572,7.61584 z" /> - <path - inkscape:connector-curvature="0" - id="path5212" - style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:'OTADESIGN Rounded';-inkscape-font-specification:'OTADESIGN Rounded';fill:#000000;fill-opacity:1;stroke-width:0.28950602px" - d="m 145.83461,185.00361 q -5.92343,0 -10.15445,-4.08999 -4.08999,-4.23102 -4.08999,-10.15445 0,-5.92343 4.08999,-10.01342 4.23102,-4.23102 10.15445,-4.23102 5.92343,0 10.15445,4.23102 4.23102,4.08999 4.23102,10.01342 0,5.92343 -4.23102,10.15445 -4.23102,4.08999 -10.15445,4.08999 z m 0.14103,2.82068 q 5.92343,0 10.01342,4.23102 4.23102,4.23102 4.23102,10.15445 v 34.83541 q 0,5.92343 -4.23102,10.15445 -4.08999,4.08999 -10.01342,4.08999 -5.92343,0 -10.15445,-4.08999 -4.23102,-4.23102 -4.23102,-10.15445 v -34.83541 q 0,-5.92343 4.23102,-10.15445 4.23102,-4.23102 10.15445,-4.23102 z" /> - </g> - </g> - </g> - </g> -</svg> diff --git a/src/client/app/desktop/assets/header-icon.dark.svg b/src/client/app/desktop/assets/header-icon.svg index fa42856fa5..d677d2d163 100644 --- a/src/client/app/desktop/assets/header-icon.dark.svg +++ b/src/client/app/desktop/assets/header-icon.svg @@ -124,24 +124,24 @@ id="g4502" transform="matrix(1.096096,0,0,1.096096,-2.960633,-44.023579)"> <g - style="fill:#ffffff;fill-opacity:1" + style="fill-opacity:1" transform="translate(-1.3333333e-6,-1.3439941e-6)" id="g5125"> <g transform="matrix(0.91391326,0,0,0.91391326,7.9719907,17.595761)" id="text4489" - style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:141.03404236px;line-height:476.69509888px;font-family:'OTADESIGN Rounded';-inkscape-font-specification:'OTADESIGN Rounded';letter-spacing:0px;word-spacing:0px;fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:0.28950602px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" + style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:141.03404236px;line-height:476.69509888px;font-family:'OTADESIGN Rounded';-inkscape-font-specification:'OTADESIGN Rounded';letter-spacing:0px;word-spacing:0px;fill-opacity:1;stroke:none;stroke-width:0.28950602px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" aria-label="Mi"> <path sodipodi:nodetypes="zccssscssccscczzzccsccsscscsccz" inkscape:connector-curvature="0" id="path5210" - style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:'OTADESIGN Rounded';-inkscape-font-specification:'OTADESIGN Rounded';fill:#ffffff;fill-opacity:1;stroke-width:0.28950602px" + style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:'OTADESIGN Rounded';-inkscape-font-specification:'OTADESIGN Rounded';fill-opacity:1;stroke-width:0.28950602px" d="m 75.196381,231.17126 c -5.855419,0.0202 -10.885068,-3.50766 -13.2572,-7.61584 -1.266603,-1.79454 -3.772419,-2.43291 -3.807919,0 v 11.2332 c 0,4.51309 -1.645397,8.41504 -4.936191,11.70583 -3.196772,3.19677 -7.098714,4.79516 -11.705826,4.79516 -4.513089,0 -8.415031,-1.59839 -11.705825,-4.79516 -3.196772,-3.29079 -4.795158,-7.19274 -4.795158,-11.70583 v -61.7729 c 0,-3.47884 0.987238,-6.6286 2.961715,-9.44928 2.068499,-2.91471 4.701135,-4.9362 7.897906,-6.06447 1.786431,-0.65816 3.666885,-0.98724 5.641362,-0.98724 5.077225,0 9.308247,1.97448 12.693064,5.92343 1.786431,1.97448 2.820681,3.00873 3.102749,3.10275 0,0 13.408119,16.21319 13.78421,16.49526 0.376091,0.28206 1.480789,2.43848 4.127113,2.43848 2.646324,0 3.89218,-2.15642 4.26827,-2.43848 0.376091,-0.28207 13.784088,-16.49526 13.784088,-16.49526 0.09402,0.094 1.081261,-0.94022 2.961715,-3.10275 3.478837,-3.94895 7.756866,-5.92343 12.834096,-5.92343 1.88045,0 3.76091,0.32908 5.64136,0.98724 3.19677,1.12827 5.7824,3.14976 7.75688,6.06447 2.06849,2.82068 3.10274,5.97044 3.10274,9.44928 v 61.7729 c 0,4.51309 -1.6454,8.41504 -4.93619,11.70583 -3.19677,3.19677 -7.09871,4.79516 -11.70582,4.79516 -4.51309,0 -8.41504,-1.59839 -11.705828,-4.79516 -3.196772,-3.29079 -4.795158,-7.19274 -4.795158,-11.70583 v -11.2332 c -0.277898,-3.06563 -2.987588,-1.13379 -3.948953,0 -2.538613,4.70114 -7.401781,7.59567 -13.2572,7.61584 z" /> <path inkscape:connector-curvature="0" id="path5212" - style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:'OTADESIGN Rounded';-inkscape-font-specification:'OTADESIGN Rounded';fill:#ffffff;fill-opacity:1;stroke-width:0.28950602px" + style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:'OTADESIGN Rounded';-inkscape-font-specification:'OTADESIGN Rounded';fill-opacity:1;stroke-width:0.28950602px" d="m 145.83461,185.00361 q -5.92343,0 -10.15445,-4.08999 -4.08999,-4.23102 -4.08999,-10.15445 0,-5.92343 4.08999,-10.01342 4.23102,-4.23102 10.15445,-4.23102 5.92343,0 10.15445,4.23102 4.23102,4.08999 4.23102,10.01342 0,5.92343 -4.23102,10.15445 -4.23102,4.08999 -10.15445,4.08999 z m 0.14103,2.82068 q 5.92343,0 10.01342,4.23102 4.23102,4.23102 4.23102,10.15445 v 34.83541 q 0,5.92343 -4.23102,10.15445 -4.08999,4.08999 -10.01342,4.08999 -5.92343,0 -10.15445,-4.08999 -4.23102,-4.23102 -4.23102,-10.15445 v -34.83541 q 0,-5.92343 4.23102,-10.15445 4.23102,-4.23102 10.15445,-4.23102 z" /> </g> </g> diff --git a/src/client/app/desktop/script.ts b/src/client/app/desktop/script.ts index f0e8a42662..85c81d73a2 100644 --- a/src/client/app/desktop/script.ts +++ b/src/client/app/desktop/script.ts @@ -6,11 +6,9 @@ import VueRouter from 'vue-router'; // Style import './style.styl'; -import '../../element.scss'; import init from '../init'; import fuckAdBlock from '../common/scripts/fuck-ad-block'; -import { HomeStreamManager } from '../common/scripts/streaming/home'; import composeNotification from '../common/scripts/compose-notification'; import chooseDriveFolder from './api/choose-drive-folder'; @@ -30,7 +28,6 @@ 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'; @@ -39,6 +36,7 @@ import MkTag from './views/pages/tag.vue'; import MkReversi from './views/pages/games/reversi.vue'; import MkShare from './views/pages/share.vue'; import MkFollow from '../common/views/pages/follow.vue'; +import MiOS from '../mios'; /** * init @@ -64,7 +62,6 @@ init(async (launch) => { { 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: '/tags/:tag', component: MkTag }, @@ -88,10 +85,12 @@ init(async (launch) => { updateBanner: updateBanner(os) })); - /** - * Fuck AD Block - */ - fuckAdBlock(os); + if (os.store.getters.isSignedIn) { + /** + * Fuck AD Block + */ + fuckAdBlock(os); + } /** * Init Notification @@ -103,62 +102,56 @@ init(async (launch) => { } if ((Notification as any).permission == 'granted') { - registerNotifications(os.stream); + registerNotifications(os); } } }, true); -function registerNotifications(stream: HomeStreamManager) { - if (stream == null) return; +function registerNotifications(os: MiOS) { + const stream = os.stream; - if (stream.hasConnection) { - attach(stream.borrow()); - } + if (stream == null) return; - stream.on('connected', connection => { - attach(connection); - }); + const connection = stream.useSharedConnection('main'); - function attach(connection) { - connection.on('notification', notification => { - const _n = composeNotification('notification', notification); - const n = new Notification(_n.title, { - body: _n.body, - icon: _n.icon - }); - setTimeout(n.close.bind(n), 6000); + connection.on('notification', notification => { + const _n = composeNotification('notification', notification); + const n = new Notification(_n.title, { + body: _n.body, + icon: _n.icon }); + setTimeout(n.close.bind(n), 6000); + }); - connection.on('drive_file_created', file => { - const _n = composeNotification('drive_file_created', file); - const n = new Notification(_n.title, { - body: _n.body, - icon: _n.icon - }); - setTimeout(n.close.bind(n), 5000); + connection.on('driveFileCreated', file => { + const _n = composeNotification('driveFileCreated', file); + const n = new Notification(_n.title, { + body: _n.body, + icon: _n.icon }); + setTimeout(n.close.bind(n), 5000); + }); - connection.on('unread_messaging_message', message => { - const _n = composeNotification('unread_messaging_message', message); - const n = new Notification(_n.title, { - body: _n.body, - icon: _n.icon - }); - n.onclick = () => { - n.close(); - /*(riot as any).mount(document.body.appendChild(document.createElement('mk-messaging-room-window')), { - user: message.user - });*/ - }; - setTimeout(n.close.bind(n), 7000); + connection.on('unreadMessagingMessage', message => { + const _n = composeNotification('unreadMessagingMessage', message); + const n = new Notification(_n.title, { + body: _n.body, + icon: _n.icon }); + n.onclick = () => { + n.close(); + /*(riot as any).mount(document.body.appendChild(document.createElement('mk-messaging-room-window')), { + user: message.user + });*/ + }; + setTimeout(n.close.bind(n), 7000); + }); - connection.on('reversi_invited', matching => { - const _n = composeNotification('reversi_invited', matching); - const n = new Notification(_n.title, { - body: _n.body, - icon: _n.icon - }); + connection.on('reversiInvited', matching => { + const _n = composeNotification('reversiInvited', matching); + const n = new Notification(_n.title, { + body: _n.body, + icon: _n.icon }); - } + }); } diff --git a/src/client/app/desktop/style.styl b/src/client/app/desktop/style.styl index 3cd36482e4..96481a9808 100644 --- a/src/client/app/desktop/style.styl +++ b/src/client/app/desktop/style.styl @@ -1,8 +1,6 @@ @import "../app" @import "../reset" -@import "./ui" - *::input-placeholder color #D8CBC5 @@ -11,34 +9,21 @@ html height 100% - background #f7f7f7 + background var(--bg) &, * &::-webkit-scrollbar width 6px height 6px + &::-webkit-scrollbar-track + background var(--scrollbarTrack) + &::-webkit-scrollbar-thumb - background rgba(0, 0, 0, 0.2) + background var(--scrollbarHandle) &:hover - background rgba(0, 0, 0, 0.4) + background var(--scrollbarHandleHover) &:active - background $theme-color - - &[data-darkmode] - background #191B22 - - &, * - &::-webkit-scrollbar-track - background-color #282C37 - - &::-webkit-scrollbar-thumb - background-color #454954 - - &:hover - background-color #535660 - - &:active - background-color $theme-color + background var(--primary) diff --git a/src/client/app/desktop/ui.styl b/src/client/app/desktop/ui.styl deleted file mode 100644 index b66c8f4025..0000000000 --- a/src/client/app/desktop/ui.styl +++ /dev/null @@ -1,181 +0,0 @@ -@import "../../const" - -button - font-family sans-serif - - * - pointer-events none - -button.ui -.button.ui - display inline-block - cursor pointer - padding 0 14px - margin 0 - min-width 100px - line-height 38px - font-size 14px - color #888 - text-decoration none - background linear-gradient(to bottom, #ffffff 0%, #f5f5f5 100%) - border solid 1px #e2e2e2 - border-radius 4px - outline none - - &.block - display block - - &: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 - - &:disabled - opacity 0.7 - cursor default - - &:hover - background linear-gradient(to bottom, #f9f9f9 0%, #ececec 100%) - border-color #dcdcdc - - &:active - background #ececec - border-color #dcdcdc - - &.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%) - - &:not(:disabled) - font-weight bold - - &: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 #55595c - border solid 1px #dadada - border-radius 4px - - &:hover - border-color #b0b0b0 - - &:focus - border-color $theme-color - -textarea.ui - min-width 100% - max-width 100% - min-height 64px - -.ui.info - display block - margin 1em 0 - padding 0 1em - font-size 90% - color rgba(#000, 0.87) - background #f8f8f9 - border solid 1px rgba(34, 36, 38, 0.22) - border-radius 4px - - > p - opacity 0.8 - - > [data-fa]:first-child - margin-right 0.25em - - &.warn - color #573a08 - background #FFFAF3 - border-color #C9BA9B - -.ui.from.group - display block - margin 16px 0 - - > p:first-child - margin 0 0 6px 0 - 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/calendar.vue b/src/client/app/desktop/views/components/calendar.vue index de9650b21b..e2f1329b3b 100644 --- a/src/client/app/desktop/views/components/calendar.vue +++ b/src/client/app/desktop/views/components/calendar.vue @@ -128,13 +128,11 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> -@import '~const.styl' - -root(isDark) - color isDark ? #c5ced6 : #777 - background isDark ? #282C37 : #fff - border solid 1px rgba(#000, 0.075) - border-radius 6px +.mk-calendar + color var(--calendarDay) + background var(--face) + box-shadow var(--shadow) + border-radius var(--round) overflow hidden &[data-melt] @@ -149,12 +147,10 @@ root(isDark) line-height 42px font-size 0.9em font-weight bold - color isDark ? #c5ced6 : #888 + color var(--faceHeaderText) + background var(--faceHeader) box-shadow 0 1px rgba(#000, 0.07) - if isDark - background #313543 - > [data-fa] margin-right 4px @@ -166,13 +162,13 @@ root(isDark) width 42px font-size 0.9em line-height 42px - color isDark ? #9baec8 : #ccc + color var(--faceTextButton) &:hover - color isDark ? #b2c1d5 : #aaa + color var(--faceTextButtonHover) &:active - color isDark ? #b2c1d5 : #999 + color var(--faceTextButtonActive) &:first-of-type left 0 @@ -195,65 +191,56 @@ root(isDark) font-size 14px &.weekday - color isDark ? #43d5dc : #19a2a9 + color var(--calendarWeek) &[data-is-donichi] - color isDark ? #ff6679 : #ef95a0 + color var(--calendarSaturdayOrSunday) &[data-today] - box-shadow 0 0 0 1px isDark ? #43d5dc : #19a2a9 inset + box-shadow 0 0 0 1px var(--calendarWeek) inset border-radius 6px &[data-is-donichi] - box-shadow 0 0 0 1px isDark ? #ff6679 : #ef95a0 inset + box-shadow 0 0 0 1px var(--calendarSaturdayOrSunday) inset &.day cursor pointer - color isDark ? #c5ced6 : #777 + color var(--calendarDay) > div border-radius 6px &:hover > div - background rgba(#000, isDark ? 0.1 : 0.025) + background var(--faceClearButtonHover) &:active > div - background rgba(#000, isDark ? 0.2 : 0.05) + background var(--faceClearButtonActive) &[data-is-donichi] - color isDark ? #ff6679 : #ef95a0 + color var(--calendarSaturdayOrSunday) &[data-is-out-of-range] cursor default - color rgba(isDark ? #c5ced6 : #777, 0.5) - - &[data-is-donichi] - color rgba(isDark ? #ff6679 : #ef95a0, 0.5) + opacity 0.5 &[data-selected] font-weight bold > div - background rgba(#000, isDark ? 0.1 : 0.025) + background var(--faceClearButtonHover) &:active > div - background rgba(#000, isDark ? 0.2 : 0.05) + background var(--faceClearButtonActive) &[data-today] > div - color $theme-color-foreground - background $theme-color + color var(--primaryForeground) + background var(--primary) &:hover > div - background lighten($theme-color, 10%) + background var(--primaryLighten10) &:active > div - background darken($theme-color, 10%) - -.mk-calendar[data-darkmode] - root(true) - -.mk-calendar:not([data-darkmode]) - root(false) + background var(--primaryDarken10) </style> diff --git a/src/client/app/desktop/views/components/charts.vue b/src/client/app/desktop/views/components/charts.vue index c4e92e429f..6d6f3a3596 100644 --- a/src/client/app/desktop/views/components/charts.vue +++ b/src/client/app/desktop/views/components/charts.vue @@ -19,6 +19,11 @@ <option value="drive">%i18n:@charts.drive%</option> <option value="drive-total">%i18n:@charts.drive-total%</option> </optgroup> + <optgroup label="%i18n:@network%"> + <option value="network-requests">%i18n:@charts.network-requests%</option> + <option value="network-time">%i18n:@charts.network-time%</option> + <option value="network-usage">%i18n:@charts.network-usage%</option> + </optgroup> </select> <div> <span @click="span = 'day'" :class="{ active: span == 'day' }">%i18n:@per-day%</span> | <span @click="span = 'hour'" :class="{ active: span == 'hour' }">%i18n:@per-hour%</span> @@ -41,7 +46,10 @@ const colors = { localPlus: 'rgb(52, 178, 118)', remotePlus: 'rgb(158, 255, 209)', localMinus: 'rgb(255, 97, 74)', - remoteMinus: 'rgb(255, 149, 134)' + remoteMinus: 'rgb(255, 149, 134)', + + incoming: 'rgb(52, 178, 118)', + outgoing: 'rgb(255, 97, 74)', }; const rgba = (color: string): string => { @@ -75,6 +83,9 @@ export default Vue.extend({ case 'drive-total': return this.driveTotalChart(); case 'drive-files': return this.driveFilesChart(); case 'drive-files-total': return this.driveFilesTotalChart(); + case 'network-requests': return this.networkRequestsChart(); + case 'network-time': return this.networkTimeChart(); + case 'network-usage': return this.networkUsageChart(); } }, @@ -89,7 +100,7 @@ export default Vue.extend({ created() { (this as any).api('chart', { - limit: 32 + limit: 35 }).then(chart => { this.chart = chart; }); @@ -544,13 +555,101 @@ export default Vue.extend({ } } }]; - } + }, + + networkRequestsChart(): any { + const data = this.stats.slice().reverse().map(x => ({ + date: new Date(x.date), + requests: x.network.requests + })); + + return [{ + datasets: [{ + label: 'Requests', + fill: true, + backgroundColor: rgba(colors.localPlus), + borderColor: colors.localPlus, + borderWidth: 2, + pointBackgroundColor: '#fff', + lineTension: 0, + data: data.map(x => ({ t: x.date, y: x.requests })) + }] + }]; + }, + + networkTimeChart(): any { + const data = this.stats.slice().reverse().map(x => ({ + date: new Date(x.date), + time: x.network.requests != 0 ? (x.network.totalTime / x.network.requests) : 0, + })); + + return [{ + datasets: [{ + label: 'Avg time (ms)', + fill: true, + backgroundColor: rgba(colors.localPlus), + borderColor: colors.localPlus, + borderWidth: 2, + pointBackgroundColor: '#fff', + lineTension: 0, + data: data.map(x => ({ t: x.date, y: x.time })) + }] + }]; + }, + + networkUsageChart(): any { + const data = this.stats.slice().reverse().map(x => ({ + date: new Date(x.date), + incoming: x.network.incomingBytes, + outgoing: x.network.outgoingBytes + })); + + return [{ + datasets: [{ + label: 'Incoming', + fill: true, + backgroundColor: rgba(colors.incoming), + borderColor: colors.incoming, + borderWidth: 2, + pointBackgroundColor: '#fff', + lineTension: 0, + data: data.map(x => ({ t: x.date, y: x.incoming })) + }, { + label: 'Outgoing', + fill: true, + backgroundColor: rgba(colors.outgoing), + borderColor: colors.outgoing, + borderWidth: 2, + pointBackgroundColor: '#fff', + lineTension: 0, + data: data.map(x => ({ t: x.date, y: x.outgoing })) + }] + }, { + scales: { + yAxes: [{ + ticks: { + callback: value => { + return Vue.filter('bytes')(value, 1); + } + } + }] + }, + tooltips: { + callbacks: { + label: (tooltipItem, data) => { + const label = data.datasets[tooltipItem.datasetIndex].label || ''; + return `${label}: ${Vue.filter('bytes')(tooltipItem.yLabel, 1)}`; + } + } + } + }]; + }, } }); </script> <style lang="stylus" scoped> -@import '~const.styl' + .gkgckalzgidaygcxnugepioremxvxvpt padding 32px @@ -576,12 +675,12 @@ export default Vue.extend({ * &:not(.active) - color $theme-color + color var(--primary) cursor pointer > div > * display block - height 320px + height 350px </style> diff --git a/src/client/app/desktop/views/components/choose-file-from-drive-window.vue b/src/client/app/desktop/views/components/choose-file-from-drive-window.vue index b894f0e109..806f7f5c3f 100644 --- a/src/client/app/desktop/views/components/choose-file-from-drive-window.vue +++ b/src/client/app/desktop/views/components/choose-file-from-drive-window.vue @@ -1,5 +1,5 @@ <template> -<mk-window ref="window" is-modal width="800px" height="500px" @closed="$destroy"> +<mk-window ref="window" is-modal width="800px" height="500px" @closed="destroyDom"> <span slot="header"> <span v-html="title" :class="$style.title"></span> <span :class="$style.count" v-if="multiple && files.length > 0">({{ files.length }}%i18n:@choose-file%)</span> @@ -59,7 +59,7 @@ export default Vue.extend({ </script> <style lang="stylus" module> -@import '~const.styl' + .title > [data-fa] @@ -74,7 +74,7 @@ export default Vue.extend({ .footer height 72px - background lighten($theme-color, 95%) + background var(--primaryLighten95) .upload display inline-block @@ -87,7 +87,7 @@ export default Vue.extend({ width 40px height 40px font-size 1em - color rgba($theme-color, 0.5) + color var(--primaryAlpha05) background transparent outline none border solid 1px transparent @@ -95,13 +95,13 @@ export default Vue.extend({ &:hover background transparent - border-color rgba($theme-color, 0.3) + border-color var(--primaryAlpha03) &:active - color rgba($theme-color, 0.6) + color var(--primaryAlpha06) background transparent - border-color rgba($theme-color, 0.5) - box-shadow 0 2px 4px rgba(darken($theme-color, 50%), 0.15) inset + border-color var(--primaryAlpha05) + //box-shadow 0 2px 4px rgba(var(--primaryDarken50), 0.15) inset &:focus &:after @@ -112,7 +112,7 @@ export default Vue.extend({ right -5px bottom -5px left -5px - border 2px solid rgba($theme-color, 0.3) + border 2px solid var(--primaryAlpha03) border-radius 8px .ok @@ -138,7 +138,7 @@ export default Vue.extend({ right -5px bottom -5px left -5px - border 2px solid rgba($theme-color, 0.3) + border 2px solid var(--primaryAlpha03) border-radius 8px &:disabled @@ -147,20 +147,20 @@ export default Vue.extend({ .ok right 16px - 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%) + color var(--primaryForeground) + background linear-gradient(to bottom, var(--primaryLighten25) 0%, var(--primaryLighten10) 100%) + border solid 1px var(--primaryLighten15) &:not(:disabled) font-weight bold &:hover:not(:disabled) - background linear-gradient(to bottom, lighten($theme-color, 8%) 0%, darken($theme-color, 8%) 100%) - border-color $theme-color + background linear-gradient(to bottom, var(--primaryLighten8) 0%, var(--primaryDarken8) 100%) + border-color var(--primary) &:active:not(:disabled) - background $theme-color - border-color $theme-color + background var(--primary) + border-color var(--primary) .cancel right 148px diff --git a/src/client/app/desktop/views/components/choose-folder-from-drive-window.vue b/src/client/app/desktop/views/components/choose-folder-from-drive-window.vue index 0c4643fdcb..b970218e58 100644 --- a/src/client/app/desktop/views/components/choose-folder-from-drive-window.vue +++ b/src/client/app/desktop/views/components/choose-folder-from-drive-window.vue @@ -1,5 +1,5 @@ <template> -<mk-window ref="window" is-modal width="800px" height="500px" @closed="$destroy"> +<mk-window ref="window" is-modal width="800px" height="500px" @closed="destroyDom"> <span slot="header"> <span v-html="title" :class="$style.title"></span> </span> @@ -37,7 +37,7 @@ export default Vue.extend({ </script> <style lang="stylus" module> -@import '~const.styl' + .title > [data-fa] @@ -48,7 +48,7 @@ export default Vue.extend({ .footer height 72px - background lighten($theme-color, 95%) + background var(--primaryLighten95) .ok .cancel @@ -73,7 +73,7 @@ export default Vue.extend({ right -5px bottom -5px left -5px - border 2px solid rgba($theme-color, 0.3) + border 2px solid var(--primaryAlpha03) border-radius 8px &:disabled @@ -82,20 +82,20 @@ export default Vue.extend({ .ok right 16px - 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%) + color var(--primaryForeground) + background linear-gradient(to bottom, var(--primaryLighten25) 0%, var(--primaryLighten10) 100%) + border solid 1px var(--primaryLighten15) &:not(:disabled) font-weight bold &:hover:not(:disabled) - background linear-gradient(to bottom, lighten($theme-color, 8%) 0%, darken($theme-color, 8%) 100%) - border-color $theme-color + background linear-gradient(to bottom, var(--primaryLighten8) 0%, var(--primaryDarken8) 100%) + border-color var(--primary) &:active:not(:disabled) - background $theme-color - border-color $theme-color + background var(--primary) + border-color var(--primary) .cancel right 148px 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 e7deec675e..9e4541a752 100644 --- a/src/client/app/desktop/views/components/context-menu.menu.vue +++ b/src/client/app/desktop/views/components/context-menu.menu.vue @@ -31,9 +31,7 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> -@import '~const.styl' - -root(isDark) +.menu $width = 240px $item-height = 38px $padding = 10px @@ -48,7 +46,7 @@ root(isDark) &.divider margin-top $padding padding-top $padding - border-top solid 1px isDark ? #1c2023 : #eee + border-top solid 1px var(--faceDivider) &.nest > p @@ -69,7 +67,7 @@ root(isDark) &:active > p, a - background $theme-color + background var(--primary) > p, a display block @@ -77,7 +75,7 @@ root(isDark) margin 0 padding 0 32px 0 38px line-height $item-height - color isDark ? #c8cece : #868C8C + color var(--text) text-decoration none cursor pointer @@ -90,14 +88,14 @@ root(isDark) &:hover > p, a text-decoration none - background $theme-color - color $theme-color-foreground + background var(--primary) + color var(--primaryForeground) &:active > p, a text-decoration none - background darken($theme-color, 10%) - color $theme-color-foreground + background var(--primaryDarken10) + color var(--primaryForeground) li > ul visibility hidden @@ -106,17 +104,11 @@ root(isDark) left $width margin-top -($padding) width $width - background isDark ? #282c37 :#fff + background var(--popupBg) border-radius 0 4px 4px 4px 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 afb6838eb6..b0a34866cd 100644 --- a/src/client/app/desktop/views/components/context-menu.vue +++ b/src/client/app/desktop/views/components/context-menu.vue @@ -64,14 +64,14 @@ export default Vue.extend({ }); this.$emit('closed'); - this.$destroy(); + this.destroyDom(); } } }); </script> <style lang="stylus" scoped> -root(isDark) +.context-menu $width = 240px $item-height = 38px $padding = 10px @@ -82,15 +82,9 @@ root(isDark) z-index 4096 width $width font-size 0.8em - background isDark ? #282c37 : #fff + background var(--popupBg) border-radius 0 4px 4px 4px 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/crop-window.vue b/src/client/app/desktop/views/components/crop-window.vue index 4fa258549f..629c3b013a 100644 --- a/src/client/app/desktop/views/components/crop-window.vue +++ b/src/client/app/desktop/views/components/crop-window.vue @@ -61,7 +61,7 @@ export default Vue.extend({ </script> <style lang="stylus" module> -@import '~const.styl' + .header > [data-fa] @@ -73,7 +73,7 @@ export default Vue.extend({ .actions height 72px - background lighten($theme-color, 95%) + background var(--primaryLighten95) .ok .cancel @@ -98,7 +98,7 @@ export default Vue.extend({ right -5px bottom -5px left -5px - border 2px solid rgba($theme-color, 0.3) + border 2px solid var(--primaryAlpha03) border-radius 8px &:disabled @@ -111,20 +111,20 @@ export default Vue.extend({ .ok right 16px - 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%) + color var(--primaryForeground) + background linear-gradient(to bottom, var(--primaryLighten25) 0%, var(--primaryLighten10) 100%) + border solid 1px var(--primaryLighten15) &:not(:disabled) font-weight bold &:hover:not(:disabled) - background linear-gradient(to bottom, lighten($theme-color, 8%) 0%, darken($theme-color, 8%) 100%) - border-color $theme-color + background linear-gradient(to bottom, var(--primaryLighten8) 0%, var(--primaryDarken8) 100%) + border-color var(--primary) &:active:not(:disabled) - background $theme-color - border-color $theme-color + background var(--primary) + border-color var(--primary) .cancel .skip @@ -155,11 +155,11 @@ export default Vue.extend({ } .cropper-view-box { - outline-color: $theme-color; + outline-color: var(--primary); } .cropper-line, .cropper-point { - background-color: $theme-color; + background-color: var(--primary); } .cropper-bg { diff --git a/src/client/app/desktop/views/components/dialog.vue b/src/client/app/desktop/views/components/dialog.vue index aff21c1754..baa6f911fe 100644 --- a/src/client/app/desktop/views/components/dialog.vue +++ b/src/client/app/desktop/views/components/dialog.vue @@ -78,7 +78,7 @@ export default Vue.extend({ scale: 0.8, duration: 300, easing: [ 0.5, -0.5, 1, 0.5 ], - complete: () => this.$destroy() + complete: () => this.destroyDom() }); }, onBgClick() { @@ -91,7 +91,7 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> -@import '~const.styl' + .mk-dialog > .bg @@ -144,20 +144,20 @@ export default Vue.extend({ margin 0 0.375em &:hover - color $theme-color + color var(--primary) &:active - color darken($theme-color, 10%) + color var(--primaryDarken10) transition color 0s ease </style> <style lang="stylus" module> -@import '~const.styl' + .header margin 1em 0 - color $theme-color + color var(--primary) // color #43A4EC font-weight bold diff --git a/src/client/app/desktop/views/components/drive-window.vue b/src/client/app/desktop/views/components/drive-window.vue index 1f45b64324..191579538d 100644 --- a/src/client/app/desktop/views/components/drive-window.vue +++ b/src/client/app/desktop/views/components/drive-window.vue @@ -1,5 +1,5 @@ <template> -<mk-window ref="window" @closed="$destroy" width="800px" height="500px" :popout-url="popout"> +<mk-window ref="window" @closed="destroyDom" width="800px" height="500px" :popout-url="popout"> <template slot="header"> <p v-if="usage" :class="$style.info"><b>{{ usage.toFixed(1) }}%</b> %i18n:@used%</p> <span :class="$style.title">%fa:cloud%%i18n:@drive%</span> diff --git a/src/client/app/desktop/views/components/drive.file.vue b/src/client/app/desktop/views/components/drive.file.vue index 3ac8923c51..d7e24cfe71 100644 --- a/src/client/app/desktop/views/components/drive.file.vue +++ b/src/client/app/desktop/views/components/drive.file.vue @@ -200,9 +200,7 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> -@import '~const.styl' - -root(isDark) +.gvfdktuvdgwhmztnuekzkswkjygptfcv padding 8px 0 0 0 height 180px border-radius 4px @@ -237,13 +235,13 @@ root(isDark) background #ce2212 &[data-is-selected] - background $theme-color + background var(--primary) &:hover - background lighten($theme-color, 10%) + background var(--primaryLighten10) &:active - background darken($theme-color, 10%) + background var(--primaryDarken10) > .label &:before @@ -251,7 +249,7 @@ root(isDark) display none > .name - color $theme-color-foreground + color var(--primaryForeground) &[data-is-contextmenu-showing] &:after @@ -262,7 +260,7 @@ root(isDark) right -4px bottom -4px left -4px - border 2px dashed rgba($theme-color, 0.3) + border 2px dashed var(--primaryAlpha03) border-radius 4px > .label @@ -337,16 +335,10 @@ root(isDark) font-size 0.8em text-align center word-break break-all - color isDark ? #fff : #444 + color var(--text) overflow hidden > .ext opacity 0.5 -.gvfdktuvdgwhmztnuekzkswkjygptfcv[data-darkmode] - root(true) - -.gvfdktuvdgwhmztnuekzkswkjygptfcv:not([data-darkmode]) - root(false) - </style> diff --git a/src/client/app/desktop/views/components/drive.folder.vue b/src/client/app/desktop/views/components/drive.folder.vue index 83880fef5c..cfc2b64ff4 100644 --- a/src/client/app/desktop/views/components/drive.folder.vue +++ b/src/client/app/desktop/views/components/drive.folder.vue @@ -163,7 +163,7 @@ export default Vue.extend({ }); break; default: - alert('%i18n:@unhandled-error% ' + err); + alert(`%i18n:@unhandled-error% ${err}`); } }); } @@ -214,12 +214,10 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> -@import '~const.styl' - -root(isDark) +.ynntpczxvnusfwdyxsfuhvcmuypqopdd padding 8px height 64px - background isDark ? rgba($theme-color, 0.2) : lighten($theme-color, 95%) + background var(--desktopDriveFolderBg) border-radius 4px &, * @@ -229,10 +227,10 @@ root(isDark) pointer-events none &:hover - background isDark ? rgba(lighten($theme-color, 10%), 0.2) : lighten($theme-color, 90%) + background var(--desktopDriveFolderHoverBg) &:active - background isDark ? rgba(darken($theme-color, 10%), 0.2) : lighten($theme-color, 85%) + background var(--desktopDriveFolderActiveBg) &[data-is-contextmenu-showing] &[data-draghover] @@ -244,26 +242,20 @@ root(isDark) right -4px bottom -4px left -4px - border 2px dashed rgba($theme-color, 0.3) + border 2px dashed var(--primaryAlpha03) border-radius 4px &[data-draghover] - background isDark ? rgba(darken($theme-color, 10%), 0.2) : lighten($theme-color, 90%) + background var(--desktopDriveFolderActiveBg) > .name margin 0 font-size 0.9em - color isDark ? #fff : darken($theme-color, 30%) + color var(--desktopDriveFolderFg) > [data-fa] margin-right 4px margin-left 2px text-align left -.ynntpczxvnusfwdyxsfuhvcmuypqopdd[data-darkmode] - root(true) - -.ynntpczxvnusfwdyxsfuhvcmuypqopdd: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 d919e4a5ea..1376a04d99 100644 --- a/src/client/app/desktop/views/components/drive.vue +++ b/src/client/app/desktop/views/components/drive.vue @@ -98,8 +98,7 @@ export default Vue.extend({ hierarchyFolders: [], selectedFiles: [], uploadings: [], - connection: null, - connectionId: null, + connection: null /** * ドロップされようとしているか @@ -116,8 +115,7 @@ export default Vue.extend({ }; }, mounted() { - this.connection = (this as any).os.streams.driveStream.getConnection(); - this.connectionId = (this as any).os.streams.driveStream.use(); + this.connection = (this as any).os.stream.useSharedConnection('drive'); this.connection.on('file_created', this.onStreamDriveFileCreated); this.connection.on('file_updated', this.onStreamDriveFileUpdated); @@ -132,12 +130,7 @@ export default Vue.extend({ } }, beforeDestroy() { - this.connection.off('file_created', this.onStreamDriveFileCreated); - this.connection.off('file_updated', this.onStreamDriveFileUpdated); - this.connection.off('file_deleted', this.onStreamDriveFileDeleted); - this.connection.off('folder_created', this.onStreamDriveFolderCreated); - this.connection.off('folder_updated', this.onStreamDriveFolderUpdated); - (this as any).os.streams.driveStream.dispose(this.connectionId); + this.connection.dispose(); }, methods: { onContextmenu(e) { @@ -323,7 +316,7 @@ export default Vue.extend({ }); break; default: - alert('%i18n:@unhandled-error% ' + err); + alert(`%i18n:@unhandled-error% ${err}`); } }); } @@ -404,7 +397,7 @@ export default Vue.extend({ folder: folder }); } else { - window.open(url + '/i/drive/folder/' + folder.id, + window.open(`${url}/i/drive/folder/${folder.id}`, 'drive_window', 'height=500, width=800'); } @@ -585,18 +578,15 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> -@import '~const.styl' - -root(isDark) - +.mk-drive > nav display block z-index 2 width 100% overflow auto font-size 0.9em - color isDark ? #d2d9dc : #555 - background isDark ? #282c37 : #fff + color var(--text) + background var(--face) box-shadow 0 1px 0 rgba(#000, 0.05) &, * @@ -674,7 +664,7 @@ root(isDark) padding 8px height calc(100% - 38px) overflow auto - background isDark ? #191b22 : #fff + background var(--desktopDriveBg) &, * user-select none @@ -697,8 +687,8 @@ root(isDark) z-index 128 top 0 left 0 - border solid 1px $theme-color - background rgba($theme-color, 0.5) + border solid 1px var(--primary) + background var(--primaryAlpha05) pointer-events none > .contents @@ -769,7 +759,7 @@ root(isDark) top 38px width 100% height calc(100% - 38px) - border dashed 2px rgba($theme-color, 0.5) + border dashed 2px var(--primaryAlpha05) pointer-events none > .mk-uploader @@ -780,10 +770,4 @@ root(isDark) > 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/follow-button.vue b/src/client/app/desktop/views/components/follow-button.vue index 62742a8f39..4d3d61dfe0 100644 --- a/src/client/app/desktop/views/components/follow-button.vue +++ b/src/client/app/desktop/views/components/follow-button.vue @@ -5,7 +5,8 @@ :disabled="wait" > <template v-if="!wait"> - <template v-if="u.hasPendingFollowRequestFromYou">%fa:hourglass-half%<template v-if="size == 'big'"> %i18n:@request-pending%</template></template> + <template v-if="u.hasPendingFollowRequestFromYou && u.isLocked">%fa:hourglass-half%<template v-if="size == 'big'"> %i18n:@request-pending%</template></template> + <template v-else-if="u.hasPendingFollowRequestFromYou && !u.isLocked">%fa:hourglass-start%<template v-if="size == 'big'"> %i18n:@follow-processing%</template></template> <template v-else-if="u.isFollowing">%fa:minus%<template v-if="size == 'big'"> %i18n:@following%</template></template> <template v-else-if="!u.isFollowing && u.isLocked">%fa:plus%<template v-if="size == 'big'"> %i18n:@follow-request%</template></template> <template v-else-if="!u.isFollowing && !u.isLocked">%fa:plus%<template v-if="size == 'big'"> %i18n:@follow%</template></template> @@ -33,35 +34,32 @@ export default Vue.extend({ return { u: this.user, wait: false, - connection: null, - connectionId: null + connection: null }; }, mounted() { - this.connection = (this as any).os.stream.getConnection(); - this.connectionId = (this as any).os.stream.use(); - + this.connection = (this as any).os.stream.useSharedConnection('main'); 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); + this.connection.dispose(); }, methods: { onFollow(user) { if (user.id == this.u.id) { - this.user.isFollowing = user.isFollowing; + this.u.isFollowing = user.isFollowing; + this.u.hasPendingFollowRequestFromYou = user.hasPendingFollowRequestFromYou; } }, onUnfollow(user) { if (user.id == this.u.id) { - this.user.isFollowing = user.isFollowing; + this.u.isFollowing = user.isFollowing; + this.u.hasPendingFollowRequestFromYou = user.hasPendingFollowRequestFromYou; } }, @@ -74,7 +72,7 @@ export default Vue.extend({ userId: this.u.id }); } else { - if (this.u.isLocked && this.u.hasPendingFollowRequestFromYou) { + if (this.u.hasPendingFollowRequestFromYou) { this.u = await (this as any).api('following/requests/cancel', { userId: this.u.id }); @@ -99,9 +97,7 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> -@import '~const.styl' - -root(isDark) +.mk-follow-button display block cursor pointer padding 0 @@ -124,37 +120,34 @@ root(isDark) right -5px bottom -5px left -5px - border 2px solid rgba($theme-color, 0.3) + border 2px solid var(--primaryAlpha03) border-radius 8px &:not(.active) - 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 + color var(--primary) + border solid 1px var(--primary) &:hover - background isDark ? linear-gradient(to bottom, #2c2f3c 0%, #22262f 100%) : linear-gradient(to bottom, #f9f9f9 0%, #ececec 100%) - border-color isDark ? #151a1d : #dcdcdc + background var(--primaryAlpha03) &:active - background isDark ? #22262f : #ececec - border-color isDark ? #151a1d : #dcdcdc + background var(--primaryAlpha05) &.active - 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%) + color var(--primaryForeground) + background var(--primary) + border solid 1px var(--primary) &:not(:disabled) font-weight bold &:hover:not(:disabled) - background linear-gradient(to bottom, lighten($theme-color, 8%) 0%, darken($theme-color, 8%) 100%) - border-color $theme-color + background var(--primaryLighten5) + border-color var(--primaryLighten5) &:active:not(:disabled) - background $theme-color - border-color $theme-color + background var(--primaryDarken5) + border-color var(--primaryDarken5) &.wait cursor wait !important @@ -165,10 +158,4 @@ root(isDark) height 38px line-height 38px -.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/followers-window.vue b/src/client/app/desktop/views/components/followers-window.vue index fdab7bc1ce..d5214adb2f 100644 --- a/src/client/app/desktop/views/components/followers-window.vue +++ b/src/client/app/desktop/views/components/followers-window.vue @@ -1,5 +1,5 @@ <template> -<mk-window width="400px" height="550px" @closed="$destroy"> +<mk-window width="400px" height="550px" @closed="destroyDom"> <span slot="header" :class="$style.header"> <img :src="user.avatarUrl" alt=""/>{{ '%i18n:@followers%'.replace('{}', name) }} </span> diff --git a/src/client/app/desktop/views/components/following-window.vue b/src/client/app/desktop/views/components/following-window.vue index 7cca833a82..aa9f2bde7b 100644 --- a/src/client/app/desktop/views/components/following-window.vue +++ b/src/client/app/desktop/views/components/following-window.vue @@ -1,5 +1,5 @@ <template> -<mk-window width="400px" height="550px" @closed="$destroy"> +<mk-window width="400px" height="550px" @closed="destroyDom"> <span slot="header" :class="$style.header"> <img :src="user.avatarUrl" alt=""/>{{ '%i18n:@following%'.replace('{}', name) }} </span> diff --git a/src/client/app/desktop/views/components/friends-maker.vue b/src/client/app/desktop/views/components/friends-maker.vue index 7dfd9e4359..4e8a212b00 100644 --- a/src/client/app/desktop/views/components/friends-maker.vue +++ b/src/client/app/desktop/views/components/friends-maker.vue @@ -14,7 +14,7 @@ <p class="empty" v-if="!fetching && users.length == 0">%i18n:@empty%</p> <p class="fetching" v-if="fetching">%fa:spinner .pulse .fw%%i18n:@fetching%<mk-ellipsis/></p> <a class="refresh" @click="refresh">%i18n:@refresh%</a> - <button class="close" @click="$destroy()" title="%i18n:@close%">%fa:times%</button> + <button class="close" @click="destroyDom()" title="%i18n:@close%">%fa:times%</button> </div> </template> diff --git a/src/client/app/desktop/views/components/game-window.vue b/src/client/app/desktop/views/components/game-window.vue index 7c6cb9cd40..594eae58f8 100644 --- a/src/client/app/desktop/views/components/game-window.vue +++ b/src/client/app/desktop/views/components/game-window.vue @@ -1,5 +1,5 @@ <template> -<mk-window ref="window" width="500px" height="560px" :popout-url="popout" @closed="$destroy"> +<mk-window ref="window" width="500px" height="560px" :popout-url="popout" @closed="destroyDom"> <span slot="header" :class="$style.header">%fa:gamepad%%i18n:@game%</span> <mk-reversi :class="$style.content" @gamed="g => game = g"/> </mk-window> diff --git a/src/client/app/desktop/views/components/home.vue b/src/client/app/desktop/views/components/home.vue index d45cc82e13..9008e26263 100644 --- a/src/client/app/desktop/views/components/home.vue +++ b/src/client/app/desktop/views/components/home.vue @@ -141,7 +141,6 @@ export default Vue.extend({ data() { return { connection: null, - connectionId: null, widgetAdderSelected: null, trash: [] }; @@ -176,12 +175,11 @@ export default Vue.extend({ }, mounted() { - this.connection = (this as any).os.stream.getConnection(); - this.connectionId = (this as any).os.stream.use(); + this.connection = (this as any).os.stream.useSharedConnection('main'); }, beforeDestroy() { - (this as any).os.stream.dispose(this.connectionId); + this.connection.dispose(); }, methods: { @@ -237,15 +235,17 @@ export default Vue.extend({ warp(date) { (this.$refs.tl as any).warp(date); + }, + + focus() { + (this.$refs.tl as any).focus(); } } }); </script> <style lang="stylus" scoped> -@import '~const.styl' - -root(isDark) +.mk-home display block &[data-customize] @@ -275,8 +275,8 @@ root(isDark) left 0 width 100% height 48px - color isDark ? #fff : #000 - background isDark ? #313543 : #f7f7f7 + color var(--text) + background var(--desktopHeaderBg) box-shadow 0 1px 1px rgba(#000, 0.075) > a @@ -288,15 +288,15 @@ root(isDark) padding 0 16px line-height 48px text-decoration none - color $theme-color-foreground - background $theme-color + color var(--primaryForeground) + background var(--primary) transition background 0.1s ease &:hover - background lighten($theme-color, 10%) + background var(--primaryLighten10) &:active - background darken($theme-color, 10%) + background var(--primaryDarken10) transition background 0s ease > [data-fa] @@ -316,7 +316,7 @@ root(isDark) line-height 48px &.trash - border-left solid 1px isDark ? #1c2023 : #ddd + border-left solid 1px var(--faceDivider) > div width 100% @@ -336,7 +336,7 @@ root(isDark) display flex justify-content center margin 0 auto - max-width 1220px + max-width 1240px > * .customize-container @@ -351,13 +351,13 @@ root(isDark) > .main padding 16px - width calc(100% - 275px * 2) + width calc(100% - 280px * 2) order 2 > .form margin-bottom 16px - border solid 1px rgba(#000, 0.075) - border-radius 4px + box-shadow var(--shadow) + border-radius var(--round) @media (max-width 700px) padding 0 @@ -367,7 +367,7 @@ root(isDark) border-radius 0 > *:not(.main) - width 275px + width 280px padding 16px 0 16px 0 > *:not(:last-child) @@ -391,10 +391,4 @@ root(isDark) 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/input-dialog.vue b/src/client/app/desktop/views/components/input-dialog.vue index e2cf4e48fd..976e897fe8 100644 --- a/src/client/app/desktop/views/components/input-dialog.vue +++ b/src/client/app/desktop/views/components/input-dialog.vue @@ -1,5 +1,5 @@ <template> -<mk-window ref="window" is-modal width="500px" @before-close="beforeClose" @closed="$destroy"> +<mk-window ref="window" is-modal width="500px" @before-close="beforeClose" @closed="destroyDom"> <span slot="header" :class="$style.header"> %fa:i-cursor%{{ title }} </span> @@ -76,7 +76,7 @@ export default Vue.extend({ <style lang="stylus" module> -@import '~const.styl' + .header > [data-fa] @@ -96,25 +96,25 @@ export default Vue.extend({ color #333 background #fff outline none - border solid 1px rgba($theme-color, 0.1) + border solid 1px var(--primaryAlpha01) border-radius 4px transition border-color .3s ease &:hover - border-color rgba($theme-color, 0.2) + border-color var(--primaryAlpha02) transition border-color .1s ease &:focus - color $theme-color - border-color rgba($theme-color, 0.5) + color var(--primary) + border-color var(--primaryAlpha05) transition border-color 0s ease &::-webkit-input-placeholder - color rgba($theme-color, 0.3) + color var(--primaryAlpha03) .actions height 72px - background lighten($theme-color, 95%) + background var(--primaryLighten95) .ok .cancel @@ -139,7 +139,7 @@ export default Vue.extend({ right -5px bottom -5px left -5px - border 2px solid rgba($theme-color, 0.3) + border 2px solid var(--primaryAlpha03) border-radius 8px &:disabled @@ -148,20 +148,20 @@ export default Vue.extend({ .ok right 16px - 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%) + color var(--primaryForeground) + background linear-gradient(to bottom, var(--primaryLighten25) 0%, var(--primaryLighten10) 100%) + border solid 1px var(--primaryLighten15) &:not(:disabled) font-weight bold &:hover:not(:disabled) - background linear-gradient(to bottom, lighten($theme-color, 8%) 0%, darken($theme-color, 8%) 100%) - border-color $theme-color + background linear-gradient(to bottom, var(--primaryLighten8) 0%, var(--primaryDarken8) 100%) + border-color var(--primary) &:active:not(:disabled) - background $theme-color - border-color $theme-color + background var(--primary) + border-color var(--primary) .cancel right 148px diff --git a/src/client/app/desktop/views/components/media-image-dialog.vue b/src/client/app/desktop/views/components/media-image-dialog.vue index 026522d907..89a340d3ae 100644 --- a/src/client/app/desktop/views/components/media-image-dialog.vue +++ b/src/client/app/desktop/views/components/media-image-dialog.vue @@ -26,7 +26,7 @@ export default Vue.extend({ opacity: 0, duration: 100, easing: 'linear', - complete: () => this.$destroy() + complete: () => this.destroyDom() }); } } diff --git a/src/client/app/desktop/views/components/media-image.vue b/src/client/app/desktop/views/components/media-image.vue index 8b68f260fa..f9ab188ca5 100644 --- a/src/client/app/desktop/views/components/media-image.vue +++ b/src/client/app/desktop/views/components/media-image.vue @@ -1,5 +1,5 @@ <template> -<div class="ldwbgwstjsdgcjruamauqdrffetqudry" v-if="image.isSensitive && hide" @click="hide = false"> +<div class="ldwbgwstjsdgcjruamauqdrffetqudry" v-if="image.isSensitive && hide && !$store.state.device.alwaysShowNsfw" @click="hide = false"> <div> <b>%fa:exclamation-triangle% %i18n:@sensitive%</b> <span>%i18n:@click-to-show%</span> @@ -27,12 +27,13 @@ export default Vue.extend({ }, raw: { default: false - }, - hide: { - type: Boolean, - default: true } }, + data() { + return { + hide: true + }; + }, computed: { style(): any { return { @@ -48,7 +49,7 @@ export default Vue.extend({ const mouseY = e.clientY - rect.top; const xp = mouseX / this.$el.offsetWidth * 100; const yp = mouseY / this.$el.offsetHeight * 100; - this.$el.style.backgroundPosition = xp + '% ' + yp + '%'; + this.$el.style.backgroundPosition = `${xp}% ${yp}%`; this.$el.style.backgroundImage = `url("${this.image.url}")`; }, @@ -89,7 +90,7 @@ export default Vue.extend({ text-align center font-size 12px - > b + > * display block </style> diff --git a/src/client/app/desktop/views/components/media-video-dialog.vue b/src/client/app/desktop/views/components/media-video-dialog.vue index 959cefa42c..03c93c8939 100644 --- a/src/client/app/desktop/views/components/media-video-dialog.vue +++ b/src/client/app/desktop/views/components/media-video-dialog.vue @@ -28,7 +28,7 @@ export default Vue.extend({ opacity: 0, duration: 100, easing: 'linear', - complete: () => this.$destroy() + complete: () => this.destroyDom() }); } } diff --git a/src/client/app/desktop/views/components/media-video.vue b/src/client/app/desktop/views/components/media-video.vue index 6c60f2da96..7859a59254 100644 --- a/src/client/app/desktop/views/components/media-video.vue +++ b/src/client/app/desktop/views/components/media-video.vue @@ -6,19 +6,12 @@ </div> </div> <div class="vwxdhznewyashiknzolsoihtlpicqepe" v-else> - <video class="video" - :src="video.url" - :title="video.name" - controls - @dblclick.prevent="onClick" - ref="video" - v-if="inlinePlayable" /> <a class="thumbnail" :href="video.url" :style="imageStyle" @click.prevent="onClick" :title="video.name" - v-else> + > %fa:R play-circle% </a> </div> @@ -36,16 +29,17 @@ export default Vue.extend({ }, inlinePlayable: { default: false - }, - hide: { - type: Boolean, - default: true } }, + data() { + return { + hide: true + }; + }, computed: { imageStyle(): any { return { - 'background-image': `url(${this.video.url})` + 'background-image': null // TODO `url(${this.video.thumbnailUrl})` }; } }, @@ -79,7 +73,6 @@ export default Vue.extend({ justify-content center align-items center font-size 3.5em - cursor zoom-in overflow hidden background-position center @@ -101,5 +94,4 @@ export default Vue.extend({ > b display block - </style> diff --git a/src/client/app/desktop/views/components/messaging-room-window.vue b/src/client/app/desktop/views/components/messaging-room-window.vue index 41b421b0e7..3706377607 100644 --- a/src/client/app/desktop/views/components/messaging-room-window.vue +++ b/src/client/app/desktop/views/components/messaging-room-window.vue @@ -1,5 +1,5 @@ <template> -<mk-window ref="window" width="500px" height="560px" :popout-url="popout" @closed="$destroy"> +<mk-window ref="window" width="500px" height="560px" :popout-url="popout" @closed="destroyDom"> <span slot="header" :class="$style.header">%fa:comments%%i18n:@title% {{ user | userName }}</span> <mk-messaging-room :user="user" :class="$style.content"/> </mk-window> diff --git a/src/client/app/desktop/views/components/messaging-window.vue b/src/client/app/desktop/views/components/messaging-window.vue index 9580c5061d..a8f0fc68b9 100644 --- a/src/client/app/desktop/views/components/messaging-window.vue +++ b/src/client/app/desktop/views/components/messaging-window.vue @@ -1,5 +1,5 @@ <template> -<mk-window ref="window" width="500px" height="560px" @closed="$destroy"> +<mk-window ref="window" width="500px" height="560px" @closed="destroyDom"> <span slot="header" :class="$style.header">%fa:comments%%i18n:@title%</span> <mk-messaging :class="$style.content" @navigate="navigate"/> </mk-window> diff --git a/src/client/app/desktop/views/components/note-detail.vue b/src/client/app/desktop/views/components/note-detail.vue index 1ba4a9a447..b119f23d7a 100644 --- a/src/client/app/desktop/views/components/note-detail.vue +++ b/src/client/app/desktop/views/components/note-detail.vue @@ -37,20 +37,26 @@ </router-link> </header> <div class="body"> - <div class="text"> - <span v-if="p.isHidden" style="opacity: 0.5">%i18n:@private%</span> - <span v-if="p.deletedAt" style="opacity: 0.5">%i18n:@deleted%</span> - <misskey-flavored-markdown v-if="p.text" :text="p.text" :i="$store.state.i"/> - </div> - <div class="media" v-if="p.media.length > 0"> - <mk-media-list :media-list="p.media" :raw="true"/> - </div> - <mk-poll v-if="p.poll" :note="p"/> - <mk-url-preview v-for="url in urls" :url="url" :key="url" :detail="true"/> - <a class="location" v-if="p.geo" :href="`https://maps.google.com/maps?q=${p.geo.coordinates[1]},${p.geo.coordinates[0]}`" target="_blank">%fa:map-marker-alt% %i18n:@location%</a> - <div class="map" v-if="p.geo" ref="map"></div> - <div class="renote" v-if="p.renote"> - <mk-note-preview :note="p.renote"/> + <p v-if="p.cw != null" class="cw"> + <span class="text" v-if="p.cw != ''">{{ p.cw }}</span> + <mk-cw-button v-model="showContent"/> + </p> + <div class="content" v-show="p.cw == null || showContent"> + <div class="text"> + <span v-if="p.isHidden" style="opacity: 0.5">%i18n:@private%</span> + <span v-if="p.deletedAt" style="opacity: 0.5">%i18n:@deleted%</span> + <misskey-flavored-markdown v-if="p.text" :text="p.text" :i="$store.state.i"/> + </div> + <div class="files" v-if="p.files.length > 0"> + <mk-media-list :media-list="p.files" :raw="true"/> + </div> + <mk-poll v-if="p.poll" :note="p"/> + <mk-url-preview v-for="url in urls" :url="url" :key="url" :detail="true"/> + <a class="location" v-if="p.geo" :href="`https://maps.google.com/maps?q=${p.geo.coordinates[1]},${p.geo.coordinates[0]}`" target="_blank">%fa:map-marker-alt% %i18n:@location%</a> + <div class="map" v-if="p.geo" ref="map"></div> + <div class="renote" v-if="p.renote"> + <mk-note-preview :note="p.renote"/> + </div> </div> </div> <footer> @@ -86,12 +92,16 @@ import MkRenoteFormWindow from './renote-form-window.vue'; import MkNoteMenu from '../../../common/views/components/note-menu.vue'; import MkReactionPicker from '../../../common/views/components/reaction-picker.vue'; import XSub from './notes.note.sub.vue'; +import { sum } from '../../../../../prelude/array'; +import noteSubscriber from '../../../common/scripts/note-subscriber'; export default Vue.extend({ components: { XSub }, + mixins: [noteSubscriber('note')], + props: { note: { type: Object, @@ -104,6 +114,7 @@ export default Vue.extend({ data() { return { + showContent: false, conversation: [], conversationFetching: false, replies: [] @@ -114,22 +125,24 @@ export default Vue.extend({ isRenote(): boolean { return (this.note.renote && this.note.text == null && - this.note.mediaIds.length == 0 && + this.note.fileIds.length == 0 && this.note.poll == null); }, + p(): any { return this.isRenote ? this.note.renote : this.note; }, + reactionsCount(): number { return this.p.reactionCounts - ? Object.keys(this.p.reactionCounts) - .map(key => this.p.reactionCounts[key]) - .reduce((a, b) => a + b) + ? sum(Object.values(this.p.reactionCounts)) : 0; }, + title(): string { return new Date(this.p.createdAt).toLocaleString(); }, + urls(): string[] { if (this.p.text) { const ast = parse(this.p.text); @@ -184,22 +197,26 @@ export default Vue.extend({ this.conversation = conversation.reverse(); }); }, + reply() { (this as any).os.new(MkPostFormWindow, { reply: this.p }); }, + renote() { (this as any).os.new(MkRenoteFormWindow, { note: this.p }); }, + react() { (this as any).os.new(MkReactionPicker, { source: this.$refs.reactButton, note: this.p }); }, + menu() { (this as any).os.new(MkNoteMenu, { source: this.$refs.menuButton, @@ -211,14 +228,12 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> -@import '~const.styl' - -root(isDark) +.mk-note-detail overflow hidden text-align left - background isDark ? #282C37 : #fff - border solid 1px rgba(#000, 0.1) - border-radius 8px + background var(--face) + box-shadow var(--shadow) + border-radius var(--round) > .read-more display block @@ -229,28 +244,28 @@ root(isDark) text-align center color #999 cursor pointer - background isDark ? #21242d : #fafafa + background var(--subNoteBg) outline none border none - border-bottom solid 1px isDark ? #1c2023 : #eef0f2 - border-radius 6px 6px 0 0 + border-bottom solid 1px var(--faceDivider) + border-radius var(--round) var(--round) 0 0 &:hover - background isDark ? #2e3440 : #f6f6f6 + box-shadow 0 0 0 100px inset rgba(0, 0, 0, 0.05) &:active - background isDark ? #21242b : #f0f0f0 + box-shadow 0 0 0 100px inset rgba(0, 0, 0, 0.1) &:disabled - color isDark ? #21242b : #ccc + cursor wait > .conversation > * - border-bottom 1px solid isDark ? #1c2023 : #eef0f2 + border-bottom 1px solid var(--faceDivider) > .renote - color #9dbb00 - background isDark ? linear-gradient(to bottom, #314027 0%, #282c37 100%) : linear-gradient(to bottom, #edfde2 0%, #fff 100%) + color var(--renoteText) + background linear-gradient(to bottom, var(--renoteGradient) 0%, var(--face) 100%) > p margin 0 @@ -273,7 +288,7 @@ root(isDark) padding-top 8px > .reply-to - border-bottom 1px solid isDark ? #1c2023 : #eef0f2 + border-bottom 1px solid var(--faceDivider) > article padding 28px 32px 18px 32px @@ -285,7 +300,7 @@ root(isDark) &:hover > footer > button - color isDark ? #707b97 : #888 + color var(--noteActionsHighlighted) > .avatar width 60px @@ -302,7 +317,7 @@ root(isDark) display inline-block margin 0 line-height 24px - color isDark ? #fff : #627079 + color var(--noteHeaderName) font-size 18px font-weight 700 text-align left @@ -315,49 +330,61 @@ root(isDark) display block text-align left margin 0 - color isDark ? #606984 : #ccc + color var(--noteHeaderAcct) > .time position absolute top 0 right 32px font-size 1em - color isDark ? #606984 : #c0c0c0 + color var(--noteHeaderInfo) > .body padding 8px 0 - > .text + > .cw cursor default display block margin 0 padding 0 overflow-wrap break-word - font-size 1.5em - color isDark ? #fff : #717171 + color var(--noteText) - > .renote - margin 8px 0 + > .text + margin-right 8px - > .mk-note-preview - padding 16px - border dashed 1px #c0dac6 - border-radius 8px + > .content + > .text + cursor default + display block + margin 0 + padding 0 + overflow-wrap break-word + font-size 1.5em + color var(--noteText) - > .location - margin 4px 0 - font-size 12px - color #ccc + > .renote + margin 8px 0 - > .map - width 100% - height 300px + > * + padding 16px + border dashed 1px var(--quoteBorder) + border-radius 8px - &:empty - display none + > .location + margin 4px 0 + font-size 12px + color #ccc - > .mk-url-preview - margin-top 8px + > .map + width 100% + height 300px + + &:empty + display none + + > .mk-url-preview + margin-top 8px > footer font-size 1.2em @@ -368,20 +395,20 @@ root(isDark) background transparent border none font-size 1em - color isDark ? #606984 : #ccc + color var(--noteActions) cursor pointer &:hover - color isDark ? #a1a8bf : #444 + color var(--noteActionsHover) &.replyButton:hover - color #0af + color var(--noteActionsReplyHover) &.renoteButton:hover - color #8d0 + color var(--noteActionsRenoteHover) &.reactionButton:hover - color #fa0 + color var(--noteActionsReactionHover) > .count display inline @@ -389,16 +416,10 @@ root(isDark) color #999 &.reacted, &.reacted:hover - color #fa0 + color var(--noteActionsReactionHover) > .replies > * - border-top 1px solid isDark ? #1c2023 : #eef0f2 - -.mk-note-detail[data-darkmode] - root(true) - -.mk-note-detail:not([data-darkmode]) - root(false) + border-top 1px solid var(--faceDivider) </style> diff --git a/src/client/app/desktop/views/components/note-preview.vue b/src/client/app/desktop/views/components/note-preview.vue index c723db98c0..4c1c7e7b2d 100644 --- a/src/client/app/desktop/views/components/note-preview.vue +++ b/src/client/app/desktop/views/components/note-preview.vue @@ -1,10 +1,16 @@ <template> -<div class="mk-note-preview" :title="title"> +<div class="qiziqtywpuaucsgarwajitwaakggnisj" :title="title"> <mk-avatar class="avatar" :user="note.user" v-if="!mini"/> <div class="main"> <mk-note-header class="header" :note="note" :mini="true"/> <div class="body"> - <mk-sub-note-content class="text" :note="note"/> + <p v-if="note.cw != null" class="cw"> + <span class="text" v-if="note.cw != ''">{{ note.cw }}</span> + <mk-cw-button v-model="showContent"/> + </p> + <div class="content" v-show="note.cw == null || showContent"> + <mk-sub-note-content class="text" :note="note"/> + </div> </div> </div> </div> @@ -25,6 +31,13 @@ export default Vue.extend({ default: false } }, + + data() { + return { + showContent: false + }; + }, + computed: { title(): string { return new Date(this.note.createdAt).toLocaleString(); @@ -34,7 +47,7 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> -root(isDark) +.qiziqtywpuaucsgarwajitwaakggnisj display flex font-size 0.9em @@ -52,16 +65,22 @@ root(isDark) > .body - > .text + > .cw cursor default + display block margin 0 padding 0 - color isDark ? #959ba7 : #717171 + overflow-wrap break-word + color var(--noteText) -.mk-note-preview[data-darkmode] - root(true) + > .text + margin-right 8px -.mk-note-preview:not([data-darkmode]) - root(false) + > .content + > .text + cursor default + margin 0 + padding 0 + color var(--subNoteText) </style> diff --git a/src/client/app/desktop/views/components/notes.note.sub.vue b/src/client/app/desktop/views/components/notes.note.sub.vue index fc851e83e9..ee52670f8f 100644 --- a/src/client/app/desktop/views/components/notes.note.sub.vue +++ b/src/client/app/desktop/views/components/notes.note.sub.vue @@ -1,10 +1,16 @@ <template> -<div class="sub" :title="title"> +<div class="tkfdzaxtkdeianobciwadajxzbddorql" :title="title"> <mk-avatar class="avatar" :user="note.user"/> <div class="main"> <mk-note-header class="header" :note="note"/> <div class="body"> - <mk-sub-note-content class="text" :note="note"/> + <p v-if="note.cw != null" class="cw"> + <span class="text" v-if="note.cw != ''">{{ note.cw }}</span> + <mk-cw-button v-model="showContent"/> + </p> + <div class="content" v-show="note.cw == null || showContent"> + <mk-sub-note-content class="text" :note="note"/> + </div> </div> </div> </div> @@ -14,7 +20,19 @@ import Vue from 'vue'; export default Vue.extend({ - props: ['note'], + props: { + note: { + type: Object, + required: true + } + }, + + data() { + return { + showContent: false + }; + }, + computed: { title(): string { return new Date(this.note.createdAt).toLocaleString(); @@ -24,12 +42,12 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> -root(isDark) +.tkfdzaxtkdeianobciwadajxzbddorql display flex margin 0 padding 16px 32px font-size 0.9em - background isDark ? #21242d : #fcfcfc + background var(--subNoteBg) > .avatar flex-shrink 0 @@ -48,20 +66,26 @@ root(isDark) > .body - > .text + > .cw cursor default + display block margin 0 padding 0 - color isDark ? #959ba7 : #717171 + overflow-wrap break-word + color var(--noteText) - pre - max-height 120px - font-size 80% + > .text + margin-right 8px -.sub[data-darkmode] - root(true) + > .content + > .text + cursor default + margin 0 + padding 0 + color var(--subNoteText) -.sub:not([data-darkmode]) - root(false) + pre + max-height 120px + font-size 80% </style> diff --git a/src/client/app/desktop/views/components/notes.note.vue b/src/client/app/desktop/views/components/notes.note.vue index 7592ae3905..2db1479823 100644 --- a/src/client/app/desktop/views/components/notes.note.vue +++ b/src/client/app/desktop/views/components/notes.note.vue @@ -1,5 +1,5 @@ <template> -<div class="note" tabindex="-1" :title="title" @keydown="onKeydown"> +<div class="note" tabindex="-1" v-hotkey="keymap" :title="title"> <div class="reply-to" v-if="p.reply && (!$store.getters.isSignedIn || $store.state.settings.showReplyTarget)"> <x-sub :note="p.reply"/> </div> @@ -18,7 +18,7 @@ <div class="body"> <p v-if="p.cw != null" class="cw"> <span class="text" v-if="p.cw != ''">{{ p.cw }}</span> - <span class="toggle" @click="showContent = !showContent">{{ showContent ? '%i18n:@hide%' : '%i18n:@see-more%' }}</span> + <mk-cw-button v-model="showContent"/> </p> <div class="content" v-show="p.cw == null || showContent"> <div class="text"> @@ -28,32 +28,30 @@ <misskey-flavored-markdown v-if="p.text" :text="p.text" :i="$store.state.i" :class="$style.text"/> <a class="rp" v-if="p.renote">RP:</a> </div> - <div class="media" v-if="p.media.length > 0"> - <mk-media-list :media-list="p.media"/> + <div class="files" v-if="p.files.length > 0"> + <mk-media-list :media-list="p.files"/> </div> <mk-poll v-if="p.poll" :note="p" ref="pollViewer"/> <a class="location" v-if="p.geo" :href="`https://maps.google.com/maps?q=${p.geo.coordinates[1]},${p.geo.coordinates[0]}`" target="_blank">%fa:map-marker-alt% 位置情報</a> <div class="map" v-if="p.geo" ref="map"></div> - <div class="renote" v-if="p.renote"> - <mk-note-preview :note="p.renote"/> - </div> + <div class="renote" v-if="p.renote"><mk-note-preview :note="p.renote"/></div> <mk-url-preview v-for="url in urls" :url="url" :key="url"/> </div> </div> - <footer> + <footer v-if="p.deletedAt == null"> <mk-reactions-viewer :note="p" ref="reactionsViewer"/> - <button class="replyButton" @click="reply" title="%i18n:@reply%"> + <button class="replyButton" @click="reply()" title="%i18n:@reply%"> <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 class="renoteButton" @click="renote" title="%i18n:@renote%"> + <button class="renoteButton" @click="renote()" title="%i18n:@renote%"> %fa:retweet%<p class="count" v-if="p.renoteCount > 0">{{ p.renoteCount }}</p> </button> - <button class="reactionButton" :class="{ reacted: p.myReaction != null }" @click="react" ref="reactButton" title="%i18n:@add-reaction%"> + <button class="reactionButton" :class="{ reacted: p.myReaction != null }" @click="react()" ref="reactButton" title="%i18n:@add-reaction%"> %fa:plus%<p class="count" v-if="p.reactions_count > 0">{{ p.reactions_count }}</p> </button> - <button @click="menu" ref="menuButton"> + <button @click="menu()" ref="menuButton"> %fa:ellipsis-h% </button> <!-- <button title="%i18n:@detail"> @@ -78,6 +76,8 @@ import MkRenoteFormWindow from './renote-form-window.vue'; import MkNoteMenu from '../../../common/views/components/note-menu.vue'; import MkReactionPicker from '../../../common/views/components/reaction-picker.vue'; import XSub from './notes.note.sub.vue'; +import { sum } from '../../../../../prelude/array'; +import noteSubscriber from '../../../common/scripts/note-subscriber'; function focus(el, fn) { const target = fn(el); @@ -95,22 +95,51 @@ export default Vue.extend({ XSub }, - props: ['note'], + mixins: [noteSubscriber('note')], + + props: { + note: { + type: Object, + required: true + } + }, data() { return { showContent: false, - isDetailOpened: false, - connection: null, - connectionId: null + isDetailOpened: false }; }, computed: { + keymap(): any { + return { + 'r|left': () => this.reply(true), + 'e|a|plus': () => this.react(true), + 'q|right': () => this.renote(true), + 'ctrl+q|ctrl+right': this.renoteDirectly, + 'up|k|shift+tab': this.focusBefore, + 'down|j|tab': this.focusAfter, + 'esc': this.blur, + 'm|o': () => this.menu(true), + 's': this.toggleShowContent, + '1': () => this.reactDirectly('like'), + '2': () => this.reactDirectly('love'), + '3': () => this.reactDirectly('laugh'), + '4': () => this.reactDirectly('hmm'), + '5': () => this.reactDirectly('surprise'), + '6': () => this.reactDirectly('congrats'), + '7': () => this.reactDirectly('angry'), + '8': () => this.reactDirectly('confused'), + '9': () => this.reactDirectly('rip'), + '0': () => this.reactDirectly('pudding'), + }; + }, + isRenote(): boolean { return (this.note.renote && this.note.text == null && - this.note.mediaIds.length == 0 && + this.note.fileIds.length == 0 && this.note.poll == null); }, @@ -120,9 +149,7 @@ export default Vue.extend({ reactionsCount(): number { return this.p.reactionCounts - ? Object.keys(this.p.reactionCounts) - .map(key => this.p.reactionCounts[key]) - .reduce((a, b) => a + b) + ? sum(Object.values(this.p.reactionCounts)) : 0; }, @@ -142,156 +169,81 @@ export default Vue.extend({ } }, - created() { - if (this.$store.getters.isSignedIn) { - this.connection = (this as any).os.stream.getConnection(); - this.connectionId = (this as any).os.stream.use(); - } - }, - - mounted() { - this.capture(true); - - if (this.$store.getters.isSignedIn) { - this.connection.on('_connected_', this.onStreamConnected); - } - - // Draw map - if (this.p.geo) { - const shouldShowMap = this.$store.getters.isSignedIn ? this.$store.state.settings.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]); - const map = new maps.Map(this.$refs.map, { - center: uluru, - zoom: 15 - }); - new maps.Marker({ - position: uluru, - map: map - }); - }); - } - } - }, - - beforeDestroy() { - this.decapture(true); - - if (this.$store.getters.isSignedIn) { - this.connection.off('_connected_', this.onStreamConnected); - (this as any).os.stream.dispose(this.connectionId); - } - }, - methods: { - capture(withHandler = false) { - if (this.$store.getters.isSignedIn) { - this.connection.send({ - type: 'capture', - id: this.p.id - }); - if (withHandler) this.connection.on('note-updated', this.onStreamNoteUpdated); - } - }, - - decapture(withHandler = false) { - if (this.$store.getters.isSignedIn) { - this.connection.send({ - type: 'decapture', - id: this.p.id - }); - if (withHandler) this.connection.off('note-updated', this.onStreamNoteUpdated); - } - }, - - onStreamConnected() { - this.capture(); - }, - - onStreamNoteUpdated(data) { - const note = data.note; - if (note.id == this.note.id) { - this.$emit('update:note', note); - } else if (note.id == this.note.renoteId) { - this.note.renote = note; - } - }, - - reply() { + reply(viaKeyboard = false) { (this as any).os.new(MkPostFormWindow, { - reply: this.p - }); + reply: this.p, + animation: !viaKeyboard + }).$once('closed', this.focus); }, - renote() { + renote(viaKeyboard = false) { (this as any).os.new(MkRenoteFormWindow, { - note: this.p + note: this.p, + animation: !viaKeyboard + }).$once('closed', this.focus); + }, + + renoteDirectly() { + (this as any).api('notes/create', { + renoteId: this.p.id }); }, - react() { + react(viaKeyboard = false) { + this.blur(); (this as any).os.new(MkReactionPicker, { source: this.$refs.reactButton, - note: this.p + note: this.p, + showFocus: viaKeyboard, + animation: !viaKeyboard + }).$once('closed', this.focus); + }, + + reactDirectly(reaction) { + (this as any).api('notes/reactions/create', { + noteId: this.p.id, + reaction: reaction }); }, - menu() { + menu(viaKeyboard = false) { (this as any).os.new(MkNoteMenu, { source: this.$refs.menuButton, - note: this.p - }); + note: this.p, + animation: !viaKeyboard + }).$once('closed', this.focus); }, - onKeydown(e) { - let shouldBeCancel = true; - - switch (true) { - case e.which == 38: // [↑] - case e.which == 74: // [j] - case e.which == 9 && e.shiftKey: // [Shift] + [Tab] - focus(this.$el, e => e.previousElementSibling); - break; - - case e.which == 40: // [↓] - case e.which == 75: // [k] - case e.which == 9: // [Tab] - focus(this.$el, e => e.nextElementSibling); - break; - - case e.which == 81: // [q] - case e.which == 69: // [e] - this.renote(); - break; + toggleShowContent() { + this.showContent = !this.showContent; + }, - case e.which == 70: // [f] - case e.which == 76: // [l] - //this.like(); - break; + focus() { + this.$el.focus(); + }, - case e.which == 82: // [r] - this.reply(); - break; + blur() { + this.$el.blur(); + }, - default: - shouldBeCancel = false; - } + focusBefore() { + focus(this.$el, e => e.previousElementSibling); + }, - if (shouldBeCancel) e.preventDefault(); + focusAfter() { + focus(this.$el, e => e.nextElementSibling); } } }); </script> <style lang="stylus" scoped> -@import '~const.styl' - -root(isDark) +.note margin 0 padding 0 - background isDark ? #282C37 : #fff - border-bottom solid 1px isDark ? #1c2023 : #eaeaea + background var(--face) + border-bottom solid 1px var(--faceDivider) &[data-round] &:first-child @@ -316,7 +268,7 @@ root(isDark) right 2px bottom 2px left 2px - border 2px solid rgba($theme-color, 0.3) + border 2px solid var(--primaryAlpha03) border-radius 4px > .renote @@ -325,8 +277,8 @@ root(isDark) padding 16px 32px 8px 32px line-height 28px white-space pre - color #9dbb00 - background isDark ? linear-gradient(to bottom, #314027 0%, #282c37 100%) : linear-gradient(to bottom, #edfde2 0%, #fff 100%) + color var(--renoteText) + background linear-gradient(to bottom, var(--renoteGradient) 0%, var(--face) 100%) .avatar display inline-block @@ -366,7 +318,7 @@ root(isDark) &:hover > .main > footer > button - color isDark ? #707b97 : #888 + color var(--noteActionsHighlighted) > .avatar flex-shrink 0 @@ -394,24 +346,11 @@ root(isDark) margin 0 padding 0 overflow-wrap break-word - color isDark ? #fff : #717171 + color var(--noteText) > .text margin-right 8px - > .toggle - display inline-block - padding 4px 8px - font-size 0.7em - color isDark ? #393f4f : #fff - background isDark ? #687390 : #b1b9c1 - border-radius 2px - cursor pointer - user-select none - - &:hover - background isDark ? #707b97 : #bbc4ce - > .content > .text @@ -420,7 +359,7 @@ root(isDark) margin 0 padding 0 overflow-wrap break-word - color isDark ? #fff : #717171 + color var(--noteText) >>> .title display block @@ -428,7 +367,7 @@ root(isDark) padding 4px font-size 90% text-align center - background isDark ? #2f3944 : #eef1f3 + background var(--mfmTitleBg) border-radius 4px >>> .code @@ -437,17 +376,17 @@ root(isDark) >>> .quote margin 8px padding 6px 12px - color isDark ? #6f808e : #aaa - border-left solid 3px isDark ? #637182 : #eee + color var(--mfmQuote) + border-left solid 3px var(--mfmQuoteLine) > .reply margin-right 8px - color isDark ? #99abbf : #717171 + color var(--text) > .rp margin-left 4px font-style oblique - color #a0bf46 + color var(--renoteText) > .location margin 4px 0 @@ -470,9 +409,9 @@ root(isDark) > .renote margin 8px 0 - > .mk-note-preview + > * padding 16px - border dashed 1px isDark ? #4e945e : #c0dac6 + border dashed 1px var(--quoteBorder) border-radius 8px > footer @@ -481,22 +420,22 @@ root(isDark) padding 0 8px line-height 32px font-size 1em - color isDark ? #606984 : #ddd + color var(--noteActions) background transparent border none cursor pointer &:hover - color isDark ? #a1a8bf : #444 + color var(--noteActionsHover) &.replyButton:hover - color #0af + color var(--noteActionsReplyHover) &.renoteButton:hover - color #8d0 + color var(--noteActionsRenoteHover) &.reactionButton:hover - color #fa0 + color var(--noteActionsReactionHover) > .count display inline @@ -504,18 +443,12 @@ root(isDark) color #999 &.reacted, &.reacted:hover - color #fa0 + color var(--noteActionsReactionHover) > .detail padding-top 4px background rgba(#000, 0.0125) -.note[data-darkmode] - root(true) - -.note:not([data-darkmode]) - root(false) - </style> <style lang="stylus" module> @@ -538,7 +471,7 @@ root(isDark) padding 0 4px margin-left 4px font-size 80% - color $theme-color-foreground - background $theme-color + color var(--primaryForeground) + background var(--primary) border-radius 4px </style> diff --git a/src/client/app/desktop/views/components/notes.vue b/src/client/app/desktop/views/components/notes.vue index a1c1207a7b..84b13ed84e 100644 --- a/src/client/app/desktop/views/components/notes.vue +++ b/src/client/app/desktop/views/components/notes.vue @@ -10,17 +10,15 @@ </div> <!-- トランジションを有効にするとなぜかメモリリークする --> - <!--<transition-group name="mk-notes" class="transition">--> - <div class="notes"> + <component :is="!$store.state.device.reduceMotion ? 'transition-group' : 'div'" name="mk-notes" class="notes transition" tag="div" ref="notes"> <template v-for="(note, i) in _notes"> - <x-note :note="note" :key="note.id" @update:note="onNoteUpdated(i, $event)"/> + <x-note :note="note" :key="note.id" @update:note="onNoteUpdated(i, $event)" ref="note"/> <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> - </div> - <!--</transition-group>--> + </component> <footer v-if="more"> <button @click="loadMore" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }"> @@ -91,7 +89,7 @@ export default Vue.extend({ }, focus() { - (this.$el as any).children[0].focus(); + (this.$refs.notes as any).children[0].focus ? (this.$refs.notes as any).children[0].focus() : (this.$refs.notes as any).$el.children[0].focus(); }, onNoteUpdated(i, note) { @@ -122,7 +120,7 @@ export default Vue.extend({ prepend(note, silent = false) { //#region 弾く const isMyNote = note.userId == this.$store.state.i.id; - const isPureRenote = note.renoteId != null && note.text == null && note.mediaIds.length == 0 && note.poll == null; + const isPureRenote = note.renoteId != null && note.text == null && note.fileIds.length == 0 && note.poll == null; if (this.$store.state.settings.showMyRenotes === false) { if (isMyNote && isPureRenote) { @@ -218,9 +216,7 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> -@import '~const.styl' - -root(isDark) +.mk-notes .transition .mk-notes-enter .mk-notes-leave-to @@ -237,9 +233,9 @@ root(isDark) line-height 32px font-size 14px text-align center - color isDark ? #666b79 : #aaa - background isDark ? #242731 : #fdfdfd - border-bottom solid 1px isDark ? #1c2023 : #eaeaea + color var(--dateDividerFg) + background var(--dateDividerBg) + border-bottom solid 1px var(--faceDivider) span margin 0 16px @@ -252,7 +248,7 @@ root(isDark) position sticky z-index 100 height 3px - background $theme-color + background var(--primary) > footer > button @@ -262,21 +258,15 @@ root(isDark) width 100% text-align center color #ccc - background isDark ? #282C37 : #fff - border-top solid 1px isDark ? #1c2023 : #eaeaea + background var(--face) + border-top solid 1px var(--faceDivider) border-bottom-left-radius 6px border-bottom-right-radius 6px &:hover - background isDark ? #2e3440 : #f5f5f5 + box-shadow 0 0 0 100px inset rgba(0, 0, 0, 0.05) &:active - background isDark ? #21242b : #eee - -.mk-notes[data-darkmode] - root(true) - -.mk-notes:not([data-darkmode]) - root(false) + box-shadow 0 0 0 100px inset rgba(0, 0, 0, 0.1) </style> diff --git a/src/client/app/desktop/views/components/notifications.vue b/src/client/app/desktop/views/components/notifications.vue index bfe71903e4..95b8e1355a 100644 --- a/src/client/app/desktop/views/components/notifications.vue +++ b/src/client/app/desktop/views/components/notifications.vue @@ -2,8 +2,7 @@ <div class="mk-notifications"> <div class="notifications" v-if="notifications.length != 0"> <!-- トランジションを有効にするとなぜかメモリリークする --> - <!-- <transition-group name="mk-notifications" class="transition"> --> - <div> + <component :is="!$store.state.device.reduceMotion ? 'transition-group' : 'div'" name="mk-notifications" class="transition" tag="div"> <template v-for="(notification, i) in _notifications"> <div class="notification" :class="notification.type" :key="notification.id"> <mk-time :time="notification.createdAt"/> @@ -97,8 +96,7 @@ <span>%fa:angle-down%{{ _notifications[i + 1]._datetext }}</span> </p> </template> - </div> - <!-- </transition-group> --> + </component> </div> <button class="more" :class="{ fetching: fetchingMoreNotifications }" v-if="moreNotifications" @click="fetchMoreNotifications" :disabled="fetchingMoreNotifications"> <template v-if="fetchingMoreNotifications">%fa:spinner .pulse .fw%</template>{{ fetchingMoreNotifications ? '%i18n:common.loading%' : '%i18n:@more%' }} @@ -120,10 +118,10 @@ export default Vue.extend({ notifications: [], moreNotifications: false, connection: null, - connectionId: null, getNoteSummary }; }, + computed: { _notifications(): any[] { return (this.notifications as any).map(notification => { @@ -135,9 +133,9 @@ export default Vue.extend({ }); } }, + mounted() { - this.connection = (this as any).os.stream.getConnection(); - this.connectionId = (this as any).os.stream.use(); + this.connection = (this as any).os.stream.useSharedConnection('main'); this.connection.on('notification', this.onNotification); @@ -155,10 +153,11 @@ export default Vue.extend({ this.fetching = false; }); }, + beforeDestroy() { - this.connection.off('notification', this.onNotification); - (this as any).os.stream.dispose(this.connectionId); + this.connection.dispose(); }, + methods: { fetchMoreNotifications() { this.fetchingMoreNotifications = true; @@ -179,10 +178,11 @@ export default Vue.extend({ this.fetchingMoreNotifications = false; }); }, + onNotification(notification) { // TODO: ユーザーが画面を見てないと思われるとき(ブラウザやタブがアクティブじゃないなど)は送信しない this.connection.send({ - type: 'read_notification', + type: 'readNotification', id: notification.id }); @@ -193,7 +193,7 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> -root(isDark) +.mk-notifications .transition .mk-notifications-enter .mk-notifications-leave-to @@ -210,7 +210,7 @@ root(isDark) padding 16px overflow-wrap break-word font-size 13px - border-bottom solid 1px isDark ? #1c2023 : rgba(#000, 0.05) + border-bottom solid 1px var(--faceDivider) &:last-child border-bottom none @@ -221,7 +221,7 @@ root(isDark) top 16px right 12px vertical-align top - color isDark ? #606984 : rgba(#000, 0.6) + color var(--noteHeaderInfo) font-size small &:after @@ -251,10 +251,10 @@ root(isDark) margin-right 4px .note-preview - color isDark ? #c2cad4 : rgba(#000, 0.7) + color var(--noteText) .note-ref - color isDark ? #c2cad4 : rgba(#000, 0.7) + color var(--noteText) [data-fa] font-size 1em @@ -285,9 +285,9 @@ root(isDark) 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) + color var(--dateDividerFg) + background var(--dateDividerBg) + border-bottom solid 1px var(--faceDivider) span margin 0 16px @@ -329,10 +329,4 @@ root(isDark) > [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-window.vue b/src/client/app/desktop/views/components/post-form-window.vue index 51a416e281..a5d191f2f3 100644 --- a/src/client/app/desktop/views/components/post-form-window.vue +++ b/src/client/app/desktop/views/components/post-form-window.vue @@ -1,10 +1,10 @@ <template> -<mk-window class="mk-post-form-window" ref="window" is-modal @closed="$destroy"> +<mk-window class="mk-post-form-window" ref="window" is-modal @closed="onWindowClosed" :animation="animation"> <span slot="header" class="mk-post-form-window--header"> <span class="icon" v-if="geo">%fa:map-marker-alt%</span> <span v-if="!reply">%i18n:@note%</span> <span v-if="reply">%i18n:@reply%</span> - <span class="count" v-if="media.length != 0">{{ '%i18n:@attaches%'.replace('{}', media.length) }}</span> + <span class="count" v-if="files.length != 0">{{ '%i18n:@attaches%'.replace('{}', files.length) }}</span> <span class="count" v-if="uploadings.length != 0">{{ '%i18n:@uploading-media%'.replace('{}', uploadings.length) }}<mk-ellipsis/></span> </span> @@ -14,7 +14,7 @@ :reply="reply" @posted="onPosted" @change-uploadings="onChangeUploadings" - @change-attached-media="onChangeMedia" + @change-attached-files="onChangeFiles" @geo-attached="onGeoAttached" @geo-dettached="onGeoDettached"/> </div> @@ -25,25 +25,39 @@ import Vue from 'vue'; export default Vue.extend({ - props: ['reply'], + props: { + reply: { + type: Object, + required: false + }, + + animation: { + type: Boolean, + required: false, + default: true + } + }, + data() { return { uploadings: [], - media: [], + files: [], geo: null }; }, + mounted() { this.$nextTick(() => { (this.$refs.form as any).focus(); }); }, + methods: { onChangeUploadings(files) { this.uploadings = files; }, - onChangeMedia(media) { - this.media = media; + onChangeFiles(files) { + this.files = files; }, onGeoAttached(geo) { this.geo = geo; @@ -53,13 +67,17 @@ export default Vue.extend({ }, onPosted() { (this.$refs.window as any).close(); + }, + onWindowClosed() { + this.$emit('closed'); + this.destroyDom(); } } }); </script> <style lang="stylus" scoped> -root(isDark) +.mk-post-form-window .mk-post-form-window--header .icon margin-right 8px @@ -76,15 +94,6 @@ root(isDark) .mk-post-form-window--body .notePreview - if isDark - margin 16px 22px 0 22px - else margin 16px 22px -.mk-post-form-window[data-darkmode] - root(true) - -.mk-post-form-window: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 bacaea65ee..e25cc33579 100644 --- a/src/client/app/desktop/views/components/post-form.vue +++ b/src/client/app/desktop/views/components/post-form.vue @@ -20,7 +20,7 @@ @keydown="onKeydown" @paste="onPaste" :placeholder="placeholder" v-autocomplete="'text'" ></textarea> - <div class="medias" :class="{ with: poll }" v-show="files.length != 0"> + <div class="files" :class="{ with: poll }" v-show="files.length != 0"> <x-draggable :list="files" :options="{ animation: 150 }"> <div v-for="file in files" :key="file.id"> <div class="img" :style="{ backgroundImage: `url(${file.thumbnailUrl})` }" :title="file.name"></div> @@ -35,7 +35,7 @@ <button class="upload" title="%i18n:@attach-media-from-local%" @click="chooseFile">%fa:upload%</button> <button class="drive" title="%i18n:@attach-media-from-drive%" @click="chooseFileFromDrive">%fa:cloud%</button> <button class="kao" title="%i18n:@insert-a-kao%" @click="kao">%fa:R smile%</button> - <button class="poll" title="%i18n:@create-poll%" @click="poll = true">%fa:chart-pie%</button> + <button class="poll" title="%i18n:@create-poll%" @click="poll = !poll">%fa:chart-pie%</button> <button class="poll" title="%i18n:@hide-contents%" @click="useCw = !useCw">%fa:eye-slash%</button> <button class="geo" title="%i18n:@attach-location-information%" @click="geo ? removeGeo() : setGeo()">%fa:map-marker-alt%</button> <button class="visibility" title="%i18n:@visibility%" @click="setVisibility" ref="visibilityButton"> @@ -45,11 +45,11 @@ <span v-if="visibility === 'specified'">%fa:envelope%</span> <span v-if="visibility === 'private'">%fa:lock%</span> </button> - <p class="text-count" :class="{ over: text.length > 1000 }">{{ 1000 - text.length }}</p> + <p class="text-count" :class="{ over: this.trimmedLength(text) > 1000 }">{{ 1000 - this.trimmedLength(text) }}</p> <button :class="{ posting }" class="submit" :disabled="!canPost" @click="post"> {{ posting ? '%i18n:@posting%' : submitText }}<mk-ellipsis v-if="posting"/> </button> - <input ref="file" type="file" accept="image/*" multiple="multiple" tabindex="-1" @change="onChangeFile"/> + <input ref="file" type="file" multiple="multiple" tabindex="-1" @change="onChangeFile"/> <div class="dropzone" v-if="draghover"></div> </div> </template> @@ -62,6 +62,9 @@ import getFace from '../../../common/scripts/get-face'; import MkVisibilityChooser from '../../../common/views/components/visibility-chooser.vue'; import parse from '../../../../../mfm/parse'; import { host } from '../../../config'; +import { erase, unique } from '../../../../../prelude/array'; +import { length } from 'stringz'; +import parseAcct from '../../../../../misc/acct/parse'; export default Vue.extend({ components: { @@ -99,7 +102,7 @@ export default Vue.extend({ useCw: false, cw: null, geo: null, - visibility: this.$store.state.device.visibility || 'public', + visibility: this.$store.state.settings.rememberNoteVisibility ? (this.$store.state.device.visibility || this.$store.state.settings.defaultNoteVisibility) : this.$store.state.settings.defaultNoteVisibility, visibleUsers: [], autocomplete: null, draghover: false, @@ -110,9 +113,9 @@ export default Vue.extend({ computed: { draftId(): string { return this.renote - ? 'renote:' + this.renote.id + ? `renote:${this.renote.id}` : this.reply - ? 'reply:' + this.reply.id + ? `reply:${this.reply.id}` : 'note'; }, @@ -145,7 +148,7 @@ export default Vue.extend({ canPost(): boolean { return !this.posting && (1 <= this.text.length || 1 <= this.files.length || this.poll || this.renote) && - (this.text.trim().length <= 1000); + (length(this.text.trim()) <= 1000); } }, @@ -175,6 +178,18 @@ export default Vue.extend({ }); } + // 公開以外へのリプライ時は元の公開範囲を引き継ぐ + if (this.reply && ['home', 'followers', 'specified', 'private'].includes(this.reply.visibility)) { + this.visibility = this.reply.visibility; + } + + // ダイレクトへのリプライはリプライ先ユーザーを初期設定 + if (this.reply && this.reply.visibility === 'specified') { + (this as any).api('users/show', { userId: this.reply.userId }).then(user => { + this.visibleUsers.push(user); + }); + } + this.$nextTick(() => { // 書きかけの投稿を復元 if (!this.instant) { @@ -188,7 +203,7 @@ export default Vue.extend({ (this.$refs.poll as any).set(draft.data.poll); }); } - this.$emit('change-attached-media', this.files); + this.$emit('change-attached-files', this.files); } } @@ -197,6 +212,10 @@ export default Vue.extend({ }, methods: { + trimmedLength(text: string) { + return length(text.trim()); + }, + addTag(tag: string) { insertTextAtCursor(this.$refs.text, ` #${tag} `); }, @@ -225,12 +244,12 @@ export default Vue.extend({ attachMedia(driveFile) { this.files.push(driveFile); - this.$emit('change-attached-media', this.files); + this.$emit('change-attached-files', this.files); }, detachMedia(id) { this.files = this.files.filter(x => x.id != id); - this.$emit('change-attached-media', this.files); + this.$emit('change-attached-files', this.files); }, onChangeFile() { @@ -249,7 +268,7 @@ export default Vue.extend({ this.text = ''; this.files = []; this.poll = false; - this.$emit('change-attached-media', this.files); + this.$emit('change-attached-files', this.files); }, onKeydown(e) { @@ -297,7 +316,7 @@ export default Vue.extend({ if (driveFile != null && driveFile != '') { const file = JSON.parse(driveFile); this.files.push(file); - this.$emit('change-attached-media', this.files); + this.$emit('change-attached-files', this.files); e.preventDefault(); } //#endregion @@ -313,7 +332,7 @@ export default Vue.extend({ this.geo = pos.coords; this.$emit('geo-attached', this.geo); }, err => { - alert('%i18n:@error%: ' + err.message); + alert(`%i18n:@error%: ${err.message}`); }, { enableHighAccuracy: true }); @@ -336,17 +355,16 @@ export default Vue.extend({ addVisibleUser() { (this as any).apis.input({ title: '%i18n:@enter-username%' - }).then(username => { - (this as any).api('users/show', { - username - }).then(user => { + }).then(acct => { + if (acct.startsWith('@')) acct = acct.substr(1); + (this as any).api('users/show', parseAcct(acct)).then(user => { this.visibleUsers.push(user); }); }); }, removeVisibleUser(user) { - this.visibleUsers = this.visibleUsers.filter(u => u != user); + this.visibleUsers = erase(user, this.visibleUsers); }, post() { @@ -354,7 +372,7 @@ export default Vue.extend({ (this as any).api('notes/create', { text: this.text == '' ? undefined : this.text, - mediaIds: this.files.length > 0 ? this.files.map(f => f.id) : undefined, + fileIds: this.files.length > 0 ? this.files.map(f => f.id) : undefined, replyId: this.reply ? this.reply.id : undefined, renoteId: this.renote ? this.renote.id : undefined, poll: this.poll ? (this.$refs.poll as any).get() : undefined, @@ -391,7 +409,7 @@ export default Vue.extend({ if (this.text && this.text != '') { const hashtags = parse(this.text).filter(x => x.type == 'hashtag').map(x => x.hashtag); const history = JSON.parse(localStorage.getItem('hashtags') || '[]') as string[]; - localStorage.setItem('hashtags', JSON.stringify(hashtags.concat(history).reduce((a, c) => a.includes(c) ? a : [...a, c], []))); + localStorage.setItem('hashtags', JSON.stringify(unique(hashtags.concat(history)))); } }, @@ -428,12 +446,11 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> -@import '~const.styl' - -root(isDark) +.mk-post-form display block padding 16px - background isDark ? #282C37 : lighten($theme-color, 95%) + background var(--desktopPostFormBg) + overflow hidden &:after content "" @@ -447,26 +464,26 @@ root(isDark) width 100% padding 12px font-size 16px - color isDark ? #fff : #333 - background isDark ? #191d23 : #fff + color var(--desktopPostFormTextareaFg) + background var(--desktopPostFormTextareaBg) outline none - border solid 1px rgba($theme-color, 0.1) + border solid 1px var(--primaryAlpha01) border-radius 4px transition border-color .2s ease &:hover - border-color rgba($theme-color, 0.2) + border-color var(--primaryAlpha02) transition border-color .1s ease &:focus - border-color rgba($theme-color, 0.5) + border-color var(--primaryAlpha05) transition border-color 0s ease &:disabled opacity 0.5 &::-webkit-input-placeholder - color rgba($theme-color, 0.3) + color var(--primaryAlpha03) > input margin-bottom 8px @@ -480,17 +497,17 @@ root(isDark) &:hover & + * & + * + * - border-color rgba($theme-color, 0.2) + border-color var(--primaryAlpha02) transition border-color .1s ease &:focus & + * & + * + * - border-color rgba($theme-color, 0.5) + border-color var(--primaryAlpha05) transition border-color 0s ease &.with - border-bottom solid 1px rgba($theme-color, 0.1) !important + border-bottom solid 1px var(--primaryAlpha01) !important border-radius 4px 4px 0 0 > .visibleUsers @@ -499,7 +516,7 @@ root(isDark) > span margin-right 16px - color isDark ? #fff : #666 + color var(--primary) > .hashtags margin 0 0 8px 0 @@ -508,23 +525,23 @@ root(isDark) font-size 14px > b - color isDark ? #9baec8 : darken($theme-color, 20%) + color var(--primary) > * margin-right 8px white-space nowrap - > .medias + > .files margin 0 padding 0 - background isDark ? #181b23 : lighten($theme-color, 98%) - border solid 1px rgba($theme-color, 0.1) + background var(--desktopPostFormTextareaBg) + border solid 1px var(--primaryAlpha01) border-top none border-radius 0 0 4px 4px transition border-color .3s ease &.with - border-bottom solid 1px rgba($theme-color, 0.1) !important + border-bottom solid 1px var(--primaryAlpha01) !important border-radius 0 > .remain @@ -534,7 +551,7 @@ root(isDark) right 8px margin 0 padding 0 - color rgba($theme-color, 0.4) + color var(--primaryAlpha04) > div padding 4px @@ -568,8 +585,8 @@ root(isDark) cursor pointer > .mk-poll-editor - background isDark ? #181b23 : lighten($theme-color, 98%) - border solid 1px rgba($theme-color, 0.1) + background var(--desktopPostFormTextareaBg) + border solid 1px var(--primaryAlpha01) border-top none border-radius 0 0 4px 4px transition border-color .3s ease @@ -577,7 +594,7 @@ root(isDark) > .mk-uploader margin 8px 0 0 0 padding 8px - border solid 1px rgba($theme-color, 0.2) + border solid 1px var(--primaryAlpha02) border-radius 4px input[type='file'] @@ -594,22 +611,20 @@ root(isDark) width 110px height 40px font-size 1em - color $theme-color-foreground - background linear-gradient(to bottom, lighten($theme-color, 25%) 0%, lighten($theme-color, 10%) 100%) + color var(--primaryForeground) + background var(--primary) outline none - border solid 1px lighten($theme-color, 15%) + border none border-radius 4px &:not(:disabled) font-weight bold &:hover:not(:disabled) - background linear-gradient(to bottom, lighten($theme-color, 8%) 0%, darken($theme-color, 8%) 100%) - border-color $theme-color + background var(--primaryLighten5) &:active:not(:disabled) - background $theme-color - border-color $theme-color + background var(--primaryDarken5) &:focus &:after @@ -620,7 +635,7 @@ root(isDark) right -5px bottom -5px left -5px - border 2px solid rgba($theme-color, 0.3) + border 2px solid var(--primaryAlpha03) border-radius 8px &:disabled @@ -630,13 +645,13 @@ root(isDark) &.wait background linear-gradient( 45deg, - darken($theme-color, 10%) 25%, - $theme-color 25%, - $theme-color 50%, - darken($theme-color, 10%) 50%, - darken($theme-color, 10%) 75%, - $theme-color 75%, - $theme-color + var(--primaryDarken10) 25%, + var(--primary) 25%, + var(--primary) 50%, + var(--primaryDarken10) 50%, + var(--primaryDarken10) 75%, + var(--primary) 75%, + var(--primary) ) background-size 32px 32px animation stripe-bg 1.5s linear infinite @@ -655,7 +670,7 @@ root(isDark) right 138px margin 0 line-height 40px - color rgba($theme-color, 0.5) + color var(--primaryAlpha05) &.over color #ec3828 @@ -673,7 +688,7 @@ root(isDark) width 40px height 40px font-size 1em - color isDark ? $theme-color : rgba($theme-color, 0.5) + color var(--desktopPostFormTransparentButtonFg) background transparent outline none border solid 1px transparent @@ -681,12 +696,12 @@ root(isDark) &:hover background transparent - border-color isDark ? rgba($theme-color, 0.5) : rgba($theme-color, 0.3) + border-color var(--primaryAlpha03) &:active - color rgba($theme-color, 0.6) - background isDark ? transparent : linear-gradient(to bottom, lighten($theme-color, 80%) 0%, lighten($theme-color, 90%) 100%) - border-color rgba($theme-color, 0.5) + color var(--primaryAlpha06) + background linear-gradient(to bottom, var(--desktopPostFormTransparentButtonActiveGradientStart) 0%, var(--desktopPostFormTransparentButtonActiveGradientEnd) 100%) + border-color var(--primaryAlpha05) box-shadow 0 2px 4px rgba(#000, 0.15) inset &:focus @@ -698,7 +713,7 @@ root(isDark) right -5px bottom -5px left -5px - border 2px solid rgba($theme-color, 0.3) + border 2px solid var(--primaryAlpha03) border-radius 8px > .dropzone @@ -707,13 +722,7 @@ root(isDark) top 0 width 100% height 100% - border dashed 2px rgba($theme-color, 0.5) + border dashed 2px var(--primaryAlpha05) 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/progress-dialog.vue b/src/client/app/desktop/views/components/progress-dialog.vue index 2f59733d99..feda6050bc 100644 --- a/src/client/app/desktop/views/components/progress-dialog.vue +++ b/src/client/app/desktop/views/components/progress-dialog.vue @@ -1,5 +1,5 @@ <template> -<mk-window ref="window" :is-modal="false" :can-close="false" width="500px" @closed="$destroy"> +<mk-window ref="window" :is-modal="false" :can-close="false" width="500px" @closed="destroyDom"> <span slot="header">{{ title }}<mk-ellipsis/></span> <div :class="$style.body"> <p :class="$style.init" v-if="isNaN(value)">%i18n:@waiting%<mk-ellipsis/></p> @@ -37,7 +37,7 @@ export default Vue.extend({ </script> <style lang="stylus" module> -@import '~const.styl' + .body padding 18px 24px 24px 24px @@ -53,7 +53,7 @@ export default Vue.extend({ margin 0 0 4px 0 text-align center line-height 16px - color rgba($theme-color, 0.7) + color var(--primaryAlpha07) &:after content '%' @@ -69,21 +69,21 @@ export default Vue.extend({ overflow hidden &::-webkit-progress-value - background $theme-color + background var(--primary) &::-webkit-progress-bar - background rgba($theme-color, 0.1) + background var(--primaryAlpha01) .waiting background linear-gradient( 45deg, - lighten($theme-color, 30%) 25%, - $theme-color 25%, - $theme-color 50%, - lighten($theme-color, 30%) 50%, - lighten($theme-color, 30%) 75%, - $theme-color 75%, - $theme-color + var(--primaryLighten30) 25%, + var(--primary) 25%, + var(--primary) 50%, + var(--primaryLighten30) 50%, + var(--primaryLighten30) 75%, + var(--primary) 75%, + var(--primary) ) background-size 32px 32px animation progress-dialog-tag-progress-waiting 1.5s linear infinite diff --git a/src/client/app/desktop/views/components/received-follow-requests-window.vue b/src/client/app/desktop/views/components/received-follow-requests-window.vue index 26b7ec2590..3df1329c48 100644 --- a/src/client/app/desktop/views/components/received-follow-requests-window.vue +++ b/src/client/app/desktop/views/components/received-follow-requests-window.vue @@ -1,8 +1,8 @@ <template> -<mk-window ref="window" is-modal width="450px" height="500px" @closed="$destroy"> +<mk-window ref="window" is-modal width="450px" height="500px" @closed="destroyDom"> <span slot="header">%fa:envelope R% %i18n:@title%</span> - <div class="slpqaxdoxhvglersgjukmvizkqbmbokc" :data-darkmode="$store.state.device.darkmode"> + <div class="slpqaxdoxhvglersgjukmvizkqbmbokc"> <div v-for="req in requests"> <router-link :key="req.id" :to="req.follower | userPage">{{ req.follower | userName }}</router-link> <span> @@ -47,8 +47,7 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> - -root(isDark) +.slpqaxdoxhvglersgjukmvizkqbmbokc padding 16px > button @@ -57,16 +56,10 @@ root(isDark) > div display flex padding 16px - border solid 1px isDark ? #1c2023 : #eee + border solid 1px var(--faceDivider) border-radius 4px > span margin 0 0 0 auto -.slpqaxdoxhvglersgjukmvizkqbmbokc[data-darkmode] - root(true) - -.slpqaxdoxhvglersgjukmvizkqbmbokc:not([data-darkmode]) - root(false) - </style> diff --git a/src/client/app/desktop/views/components/renote-form-window.vue b/src/client/app/desktop/views/components/renote-form-window.vue index df9d2f7fc7..b9760fcbe9 100644 --- a/src/client/app/desktop/views/components/renote-form-window.vue +++ b/src/client/app/desktop/views/components/renote-form-window.vue @@ -1,7 +1,7 @@ <template> -<mk-window ref="window" is-modal @closed="$destroy"> +<mk-window ref="window" is-modal @closed="onWindowClosed" :animation="animation"> <span slot="header" :class="$style.header">%fa:retweet%%i18n:@title%</span> - <mk-renote-form ref="form" :note="note" @posted="onPosted" @canceled="onCanceled"/> + <mk-renote-form ref="form" :note="note" @posted="onPosted" @canceled="onCanceled" v-hotkey.global="keymap"/> </mk-window> </template> @@ -9,26 +9,48 @@ import Vue from 'vue'; export default Vue.extend({ - props: ['note'], - mounted() { - document.addEventListener('keydown', this.onDocumentKeydown); + props: { + note: { + type: Object, + required: true + }, + + animation: { + type: Boolean, + required: false, + default: true + } }, - beforeDestroy() { - document.removeEventListener('keydown', this.onDocumentKeydown); + + computed: { + keymap(): any { + return { + 'esc': this.close, + 'enter': this.post, + 'q': this.quote, + }; + } }, + methods: { - onDocumentKeydown(e) { - if (e.target.tagName != 'INPUT' && e.target.tagName != 'TEXTAREA') { - if (e.which == 27) { // Esc - (this.$refs.window as any).close(); - } - } + post() { + (this.$refs.form as any).ok(); + }, + quote() { + (this.$refs.form as any).onQuote(); + }, + close() { + (this.$refs.window as any).close(); }, onPosted() { (this.$refs.window as any).close(); }, onCanceled() { (this.$refs.window as any).close(); + }, + onWindowClosed() { + this.$emit('closed'); + this.destroyDom(); } } }); diff --git a/src/client/app/desktop/views/components/renote-form.vue b/src/client/app/desktop/views/components/renote-form.vue index 38eab3362f..68d485bada 100644 --- a/src/client/app/desktop/views/components/renote-form.vue +++ b/src/client/app/desktop/views/components/renote-form.vue @@ -1,11 +1,11 @@ <template> <div class="mk-renote-form"> - <mk-note-preview :note="note"/> + <mk-note-preview class="preview" :note="note"/> <template v-if="!quote"> <footer> <a class="quote" v-if="!quote" @click="onQuote">%i18n:@quote%</a> - <button class="ui cancel" @click="cancel">%i18n:@cancel%</button> - <button class="ui primary ok" @click="ok" :disabled="wait">{{ wait ? '%i18n:@reposting%' : '%i18n:@renote%' }}</button> + <ui-button class="button cancel" inline @click="cancel">%i18n:@cancel%</ui-button> + <ui-button class="button ok" inline primary @click="ok" :disabled="wait">{{ wait ? '%i18n:@reposting%' : '%i18n:@renote%' }}</ui-button> </footer> </template> <template v-if="quote"> @@ -57,16 +57,13 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> -@import '~const.styl' - -root(isDark) - - > .mk-note-preview +.mk-renote-form + > .preview margin 16px 22px > footer height 72px - background isDark ? #313543 : lighten($theme-color, 95%) + background var(--desktopRenoteFormFooter) > .quote position absolute @@ -74,7 +71,7 @@ root(isDark) left 28px line-height 40px - button + > .button display block position absolute bottom 16px @@ -87,10 +84,4 @@ root(isDark) &.ok right 16px -.mk-renote-form[data-darkmode] - root(true) - -.mk-renote-form:not([data-darkmode]) - root(false) - </style> diff --git a/src/client/app/desktop/views/components/settings-window.vue b/src/client/app/desktop/views/components/settings-window.vue index deb865b102..4247717748 100644 --- a/src/client/app/desktop/views/components/settings-window.vue +++ b/src/client/app/desktop/views/components/settings-window.vue @@ -1,13 +1,19 @@ <template> -<mk-window ref="window" is-modal width="700px" height="550px" @closed="$destroy"> +<mk-window ref="window" is-modal width="700px" height="550px" @closed="destroyDom"> <span slot="header" :class="$style.header">%fa:cog%%i18n:@settings%</span> - <mk-settings @done="close"/> + <mk-settings :initial-page="initialPage" @done="close"/> </mk-window> </template> <script lang="ts"> import Vue from 'vue'; export default Vue.extend({ + props: { + initialPage: { + type: String, + required: false + } + }, methods: { close() { (this as any).$refs.window.close(); diff --git a/src/client/app/desktop/views/components/settings.drive.vue b/src/client/app/desktop/views/components/settings.drive.vue index e8a3cc9685..d254b27110 100644 --- a/src/client/app/desktop/views/components/settings.drive.vue +++ b/src/client/app/desktop/views/components/settings.drive.vue @@ -1,7 +1,6 @@ <template> <div class="root"> <template v-if="!fetching"> - <el-progress :text-inside="true" :stroke-width="18" :percentage="Math.floor((usage / capacity) * 100)"/> <p><b>{{ capacity | bytes }}</b>%i18n:max%<b>{{ usage | bytes }}</b>%i18n:in-use%</p> </template> </div> diff --git a/src/client/app/desktop/views/components/settings.profile.vue b/src/client/app/desktop/views/components/settings.profile.vue index 262583b640..5f465a52bb 100644 --- a/src/client/app/desktop/views/components/settings.profile.vue +++ b/src/client/app/desktop/views/components/settings.profile.vue @@ -6,30 +6,28 @@ <button class="ui" @click="updateAvatar">%i18n:@choice-avatar%</button> </label> <label class="ui from group"> - <p>%i18n:@name%</p> - <input v-model="name" type="text" class="ui"/> + <ui-input v-model="name" type="text">%i18n:@name%</ui-input> </label> <label class="ui from group"> - <p>%i18n:@location%</p> - <input v-model="location" type="text" class="ui"/> + <ui-input v-model="location" type="text">%i18n:@location%</ui-input> </label> <label class="ui from group"> - <p>%i18n:@description%</p> - <textarea v-model="description" class="ui"></textarea> + <ui-textarea v-model="description">%i18n:@description%</ui-textarea> </label> <label class="ui from group"> <p>%i18n:@birthday%</p> - <el-date-picker v-model="birthday" type="date" value-format="yyyy-MM-dd"/> + <input type="date" v-model="birthday"/> </label> - <button class="ui primary" @click="save">%i18n:@save%</button> + <ui-button primary @click="save">%i18n:@save%</ui-button> <section> <h2>%i18n:@locked-account%</h2> - <mk-switch v-model="$store.state.i.isLocked" @change="onChangeIsLocked" text="%i18n:@is-locked%"/> + <ui-switch v-model="$store.state.i.isLocked" @change="onChangeIsLocked">%i18n:@is-locked%</ui-switch> </section> <section> <h2>%i18n:@other%</h2> - <mk-switch v-model="$store.state.i.isBot" @change="onChangeIsBot" text="%i18n:@is-bot%"/> - <mk-switch v-model="$store.state.i.isCat" @change="onChangeIsCat" text="%i18n:@is-cat%"/> + <ui-switch v-model="$store.state.i.isBot" @change="onChangeIsBot">%i18n:@is-bot%</ui-switch> + <ui-switch v-model="$store.state.i.isCat" @change="onChangeIsCat">%i18n:@is-cat%</ui-switch> + <ui-switch v-model="alwaysMarkNsfw">%i18n:common.always-mark-nsfw%</ui-switch> </section> </div> </template> @@ -46,6 +44,12 @@ export default Vue.extend({ birthday: null, }; }, + computed: { + alwaysMarkNsfw: { + get() { return this.$store.state.i.settings.alwaysMarkNsfw; }, + set(value) { (this as any).api('i/update', { alwaysMarkNsfw: value }); } + }, + }, created() { this.name = this.$store.state.i.name || ''; this.location = this.$store.state.i.profile.location; diff --git a/src/client/app/desktop/views/components/settings.signins.vue b/src/client/app/desktop/views/components/settings.signins.vue index a414c95c27..7d1bb4f4e7 100644 --- a/src/client/app/desktop/views/components/settings.signins.vue +++ b/src/client/app/desktop/views/components/settings.signins.vue @@ -23,25 +23,25 @@ export default Vue.extend({ return { fetching: true, signins: [], - connection: null, - connectionId: null + connection: null }; }, + mounted() { (this as any).api('i/signin_history').then(signins => { this.signins = signins; this.fetching = false; }); - this.connection = (this as any).os.stream.getConnection(); - this.connectionId = (this as any).os.stream.use(); + this.connection = (this as any).os.stream.useSharedConnection('main'); this.connection.on('signin', this.onSignin); }, + beforeDestroy() { - this.connection.off('signin', this.onSignin); - (this as any).os.stream.dispose(this.connectionId); + this.connection.dispose(); }, + methods: { onSignin(signin) { this.signins.unshift(signin); diff --git a/src/client/app/desktop/views/components/settings.tags.vue b/src/client/app/desktop/views/components/settings.tags.vue new file mode 100644 index 0000000000..dfc69a387e --- /dev/null +++ b/src/client/app/desktop/views/components/settings.tags.vue @@ -0,0 +1,58 @@ +<template> +<div class="vfcitkilproprqtbnpoertpsziierwzi"> + <div v-for="timeline in timelines" class="timeline"> + <ui-input v-model="timeline.title" @change="save"> + <span>%i18n:@title%</span> + </ui-input> + <ui-textarea :value="timeline.query ? timeline.query.map(tags => tags.join(' ')).join('\n') : ''" @input="onQueryChange(timeline, $event)"> + <span>%i18n:@query%</span> + </ui-textarea> + <ui-button class="save" @click="save">%i18n:@save%</ui-button> + </div> + <ui-button class="add" @click="add">%i18n:@add%</ui-button> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import * as uuid from 'uuid'; + +export default Vue.extend({ + data() { + return { + timelines: this.$store.state.settings.tagTimelines + }; + }, + + methods: { + add() { + this.timelines.push({ + id: uuid(), + title: '', + query: '' + }); + + this.save(); + }, + + save() { + this.$store.dispatch('settings/set', { key: 'tagTimelines', value: this.timelines }); + }, + + onQueryChange(timeline, value) { + timeline.query = value.split('\n').map(tags => tags.split(' ')); + } + } +}); +</script> + +<style lang="stylus" scoped> +.vfcitkilproprqtbnpoertpsziierwzi + > .timeline + padding-bottom 16px + border-bottom solid 1px rgba(#000, 0.1) + + > .add + margin-top 16px + +</style> diff --git a/src/client/app/desktop/views/components/settings.vue b/src/client/app/desktop/views/components/settings.vue index 7d6f1d55fb..1cb8d4d4c8 100644 --- a/src/client/app/desktop/views/components/settings.vue +++ b/src/client/app/desktop/views/components/settings.vue @@ -5,6 +5,7 @@ <p :class="{ active: page == 'web' }" @mousedown="page = 'web'">%fa:desktop .fw%Web</p> <p :class="{ active: page == 'notification' }" @mousedown="page = 'notification'">%fa:R bell .fw%%i18n:@notification%</p> <p :class="{ active: page == 'drive' }" @mousedown="page = 'drive'">%fa:cloud .fw%%i18n:@drive%</p> + <p :class="{ active: page == 'hashtags' }" @mousedown="page = 'hashtags'">%fa:hashtag .fw%%i18n:@tags%</p> <p :class="{ active: page == 'mute' }" @mousedown="page = 'mute'">%fa:ban .fw%%i18n:@mute%</p> <p :class="{ active: page == 'apps' }" @mousedown="page = 'apps'">%fa:puzzle-piece .fw%%i18n:@apps%</p> <p :class="{ active: page == 'twitter' }" @mousedown="page = 'twitter'">%fa:B twitter .fw%Twitter</p> @@ -19,18 +20,42 @@ </section> <section class="web" v-show="page == 'web'"> + <h1>%i18n:@theme%</h1> + <mk-theme/> + </section> + + <section class="web" v-show="page == 'web'"> <h1>%i18n:@behaviour%</h1> - <mk-switch v-model="$store.state.settings.fetchOnScroll" @change="onChangeFetchOnScroll" text="%i18n:@fetch-on-scroll%"> - <span>%i18n:@fetch-on-scroll-desc%</span> - </mk-switch> - <mk-switch v-model="autoPopout" text="%i18n:@auto-popout%"> - <span>%i18n:@auto-popout-desc%</span> - </mk-switch> + <ui-switch v-model="fetchOnScroll"> + %i18n:@fetch-on-scroll% + <span slot="desc">%i18n:@fetch-on-scroll-desc%</span> + </ui-switch> + <ui-switch v-model="autoPopout"> + %i18n:@auto-popout% + <span slot="desc">%i18n:@auto-popout-desc%</span> + </ui-switch> + + <section> + <header>%i18n:@note-visibility%</header> + <ui-switch v-model="rememberNoteVisibility">%i18n:@remember-note-visibility%</ui-switch> + <section> + <header>%i18n:@default-note-visibility%</header> + <ui-select v-model="defaultNoteVisibility"> + <option value="public">%i18n:common.note-visibility.public%</option> + <option value="home">%i18n:common.note-visibility.home%</option> + <option value="followers">%i18n:common.note-visibility.followers%</option> + <option value="specified">%i18n:common.note-visibility.specified%</option> + <option value="private">%i18n:common.note-visibility.private%</option> + </ui-select> + </section> + </section> + <details> <summary>%i18n:@advanced%</summary> - <mk-switch v-model="apiViaStream" text="%i18n:@api-via-stream%"> - <span>%i18n:@api-via-stream-desc%</span> - </mk-switch> + <ui-switch v-model="apiViaStream"> + %i18n:@api-via-stream% + <span slot="desc">%i18n:@api-via-stream-desc%</span> + </ui-switch> </details> </section> @@ -42,58 +67,61 @@ <div class="div"> <button class="ui" @click="updateWallpaper">%i18n:@choose-wallpaper%</button> <button class="ui" @click="deleteWallpaper">%i18n:@delete-wallpaper%</button> - <mk-switch v-model="darkmode" text="%i18n:@dark-mode%"/> - <mk-switch v-model="$store.state.settings.circleIcons" @change="onChangeCircleIcons" text="%i18n:@circle-icons%"/> - <mk-switch v-model="$store.state.settings.gradientWindowHeader" @change="onChangeGradientWindowHeader" text="%i18n:@gradient-window-header%"/> - <mk-switch v-model="$store.state.settings.iLikeSushi" @change="onChangeILikeSushi" text="%i18n:common.i-like-sushi%"/> + <ui-switch v-model="darkmode">%i18n:@dark-mode%</ui-switch> + <ui-switch v-model="useShadow">%i18n:@use-shadow%</ui-switch> + <ui-switch v-model="roundedCorners">%i18n:@rounded-corners%</ui-switch> + <ui-switch v-model="circleIcons">%i18n:@circle-icons%</ui-switch> + <ui-switch v-model="reduceMotion">%i18n:common.reduce-motion%</ui-switch> + <ui-switch v-model="contrastedAcct">%i18n:@contrasted-acct%</ui-switch> + <ui-switch v-model="showFullAcct">%i18n:common.show-full-acct%</ui-switch> + <ui-switch v-model="iLikeSushi">%i18n:common.i-like-sushi%</ui-switch> </div> - <mk-switch v-model="$store.state.settings.showPostFormOnTopOfTl" @change="onChangeShowPostFormOnTopOfTl" text="%i18n:@post-form-on-timeline%"/> - <mk-switch v-model="$store.state.settings.suggestRecentHashtags" @change="onChangeSuggestRecentHashtags" text="%i18n:@suggest-recent-hashtags%"/> - <mk-switch v-model="$store.state.settings.showClockOnHeader" @change="onChangeShowClockOnHeader" text="%i18n:@show-clock-on-header%"/> - <mk-switch v-model="$store.state.settings.showReplyTarget" @change="onChangeShowReplyTarget" text="%i18n:@show-reply-target%"/> - <mk-switch v-model="$store.state.settings.showMyRenotes" @change="onChangeShowMyRenotes" text="%i18n:@show-my-renotes%"/> - <mk-switch v-model="$store.state.settings.showRenotedMyNotes" @change="onChangeShowRenotedMyNotes" text="%i18n:@show-renoted-my-notes%"/> - <mk-switch v-model="$store.state.settings.showLocalRenotes" @change="onChangeShowLocalRenotes" text="%i18n:@show-local-renotes%"/> - <mk-switch v-model="$store.state.settings.showMaps" @change="onChangeShowMaps" text="%i18n:@show-maps%"> - <span>%i18n:@show-maps-desc%</span> - </mk-switch> - <mk-switch v-model="$store.state.settings.disableAnimatedMfm" @change="onChangeDisableAnimatedMfm" text="%i18n:common.disable-animated-mfm%"/> - <mk-switch v-model="$store.state.settings.games.reversi.showBoardLabels" @change="onChangeReversiBoardLabels" text="%i18n:common.show-reversi-board-labels%"/> - <mk-switch v-model="$store.state.settings.games.reversi.useContrastStones" @change="onChangeUseContrastReversiStones" text="%i18n:common.use-contrast-reversi-stones%"/> + <ui-switch v-model="showPostFormOnTopOfTl">%i18n:@post-form-on-timeline%</ui-switch> + <ui-switch v-model="suggestRecentHashtags">%i18n:@suggest-recent-hashtags%</ui-switch> + <ui-switch v-model="showClockOnHeader">%i18n:@show-clock-on-header%</ui-switch> + <ui-switch v-model="alwaysShowNsfw">%i18n:common.always-show-nsfw%</ui-switch> + <ui-switch v-model="showReplyTarget">%i18n:@show-reply-target%</ui-switch> + <ui-switch v-model="showMyRenotes">%i18n:@show-my-renotes%</ui-switch> + <ui-switch v-model="showRenotedMyNotes">%i18n:@show-renoted-my-notes%</ui-switch> + <ui-switch v-model="showLocalRenotes">%i18n:@show-local-renotes%</ui-switch> + <ui-switch v-model="showMaps">%i18n:@show-maps%</ui-switch> + <ui-switch v-model="disableAnimatedMfm">%i18n:common.disable-animated-mfm%</ui-switch> + <ui-switch v-model="games_reversi_showBoardLabels">%i18n:common.show-reversi-board-labels%</ui-switch> + <ui-switch v-model="games_reversi_useContrastStones">%i18n:common.use-contrast-reversi-stones%</ui-switch> </section> <section class="web" v-show="page == 'web'"> <h1>%i18n:@sound%</h1> - <mk-switch v-model="enableSounds" text="%i18n:@enable-sounds%"> - <span>%i18n:@enable-sounds-desc%</span> - </mk-switch> + <ui-switch v-model="enableSounds"> + %i18n:@enable-sounds% + <span slot="desc">%i18n:@enable-sounds-desc%</span> + </ui-switch> <label>%i18n:@volume%</label> - <el-slider + <input type="range" v-model="soundVolume" - :show-input="true" - :format-tooltip="v => `${v * 100}%`" :disabled="!enableSounds" - :max="1" - :step="0.1" + max="1" + step="0.1" /> <button class="ui button" @click="soundTest">%fa:volume-up% %i18n:@test%</button> </section> <section class="web" v-show="page == 'web'"> <h1>%i18n:@mobile%</h1> - <mk-switch v-model="$store.state.settings.disableViaMobile" @change="onChangeDisableViaMobile" text="%i18n:@disable-via-mobile%"/> + <ui-switch v-model="disableViaMobile">%i18n:@disable-via-mobile%</ui-switch> </section> <section class="web" v-show="page == 'web'"> <h1>%i18n:@language%</h1> - <el-select v-model="lang" placeholder="%i18n:@pick-language%"> - <el-option-group label="%i18n:@recommended%"> - <el-option label="%i18n:@auto%" :value="null"/> - </el-option-group> - <el-option-group label="%i18n:@specify-language%"> - <el-option v-for="x in langs" :label="x[1]" :value="x[0]" :key="x[0]"/> - </el-option-group> - </el-select> + <select v-model="lang" placeholder="%i18n:@pick-language%"> + <optgroup label="%i18n:@recommended%"> + <option value="">%i18n:@auto%</option> + </optgroup> + + <optgroup label="%i18n:@specify-language%"> + <option v-for="x in langs" :value="x[0]" :key="x[0]">{{ x[1] }}</option> + </optgroup> + </select> <div class="none ui info"> <p>%fa:info-circle%%i18n:@language-desc%</p> </div> @@ -109,9 +137,10 @@ <section class="notification" v-show="page == 'notification'"> <h1>%i18n:@notification%</h1> - <mk-switch v-model="$store.state.i.settings.autoWatch" @change="onChangeAutoWatch" text="%i18n:@auto-watch%"> - <span>%i18n:@auto-watch-desc%</span> - </mk-switch> + <ui-switch v-model="$store.state.i.settings.autoWatch" @change="onChangeAutoWatch"> + %i18n:@auto-watch% + <span slot="desc">%i18n:@auto-watch-desc%</span> + </ui-switch> </section> <section class="drive" v-show="page == 'drive'"> @@ -119,6 +148,11 @@ <x-drive/> </section> + <section class="hashtags" v-show="page == 'hashtags'"> + <h1>%i18n:@tags%</h1> + <x-tags/> + </section> + <section class="mute" v-show="page == 'mute'"> <h1>%i18n:@mute%</h1> <x-mute/> @@ -174,24 +208,23 @@ </button> <details> <summary>%i18n:@update-settings%</summary> - <mk-switch v-model="preventUpdate" text="%i18n:@prevent-update%"> - <span>%i18n:@prevent-update-desc%</span> - </mk-switch> + <ui-switch v-model="preventUpdate"> + %i18n:@prevent-update% + <span slot="desc">%i18n:@prevent-update-desc%</span> + </ui-switch> </details> </section> <section class="other" v-show="page == 'other'"> <h1>%i18n:@advanced-settings%</h1> - <mk-switch v-model="debug" text="%i18n:@debug-mode%"> - <span>%i18n:@debug-mode-desc%</span> - </mk-switch> - <mk-switch v-model="enableExperimentalFeatures" text="%i18n:@experimental%"> - <span>%i18n:@experimental-desc%</span> - </mk-switch> - <details v-if="debug"> - <summary>%i18n:@tools%</summary> - <button class="ui button block" @click="taskmngr">%i18n:@task-manager%</button> - </details> + <ui-switch v-model="debug"> + %i18n:@debug-mode% + <span slot="desc">%i18n:@debug-mode-desc%</span> + </ui-switch> + <ui-switch v-model="enableExperimentalFeatures"> + %i18n:@experimental% + <span slot="desc">%i18n:@experimental-desc%</span> + </ui-switch> </section> </div> </div> @@ -207,9 +240,9 @@ import XApi from './settings.api.vue'; import XApps from './settings.apps.vue'; import XSignins from './settings.signins.vue'; import XDrive from './settings.drive.vue'; +import XTags from './settings.tags.vue'; import { url, langs, version } from '../../../config'; import checkForUpdate from '../../../common/scripts/check-for-update'; -import MkTaskManager from './taskmanager.vue'; export default Vue.extend({ components: { @@ -220,11 +253,18 @@ export default Vue.extend({ XApi, XApps, XSignins, - XDrive + XDrive, + XTags + }, + props: { + initialPage: { + type: String, + required: false + } }, data() { return { - page: 'profile', + page: this.initialPage || 'profile', meta: null, version, langs, @@ -233,6 +273,11 @@ export default Vue.extend({ }; }, computed: { + reduceMotion: { + get() { return this.$store.state.device.reduceMotion; }, + set(value) { this.$store.commit('device/set', { key: 'reduceMotion', value }); } + }, + apiViaStream: { get() { return this.$store.state.device.apiViaStream; }, set(value) { this.$store.commit('device/set', { key: 'apiViaStream', value }); } @@ -276,6 +321,116 @@ export default Vue.extend({ enableExperimentalFeatures: { get() { return this.$store.state.device.enableExperimentalFeatures; }, set(value) { this.$store.commit('device/set', { key: 'enableExperimentalFeatures', value }); } + }, + + alwaysShowNsfw: { + get() { return this.$store.state.device.alwaysShowNsfw; }, + set(value) { this.$store.commit('device/set', { key: 'alwaysShowNsfw', value }); } + }, + + useShadow: { + get() { return this.$store.state.settings.useShadow; }, + set(value) { this.$store.dispatch('settings/set', { key: 'useShadow', value }); } + }, + + roundedCorners: { + get() { return this.$store.state.settings.roundedCorners; }, + set(value) { this.$store.dispatch('settings/set', { key: 'roundedCorners', value }); } + }, + + fetchOnScroll: { + get() { return this.$store.state.settings.fetchOnScroll; }, + set(value) { this.$store.dispatch('settings/set', { key: 'fetchOnScroll', value }); } + }, + + rememberNoteVisibility: { + get() { return this.$store.state.settings.rememberNoteVisibility; }, + set(value) { this.$store.dispatch('settings/set', { key: 'rememberNoteVisibility', value }); } + }, + + defaultNoteVisibility: { + get() { return this.$store.state.settings.defaultNoteVisibility; }, + set(value) { this.$store.dispatch('settings/set', { key: 'defaultNoteVisibility', value }); } + }, + + showReplyTarget: { + get() { return this.$store.state.settings.showReplyTarget; }, + set(value) { this.$store.dispatch('settings/set', { key: 'showReplyTarget', value }); } + }, + + showMyRenotes: { + get() { return this.$store.state.settings.showMyRenotes; }, + set(value) { this.$store.dispatch('settings/set', { key: 'showMyRenotes', value }); } + }, + + showRenotedMyNotes: { + get() { return this.$store.state.settings.showRenotedMyNotes; }, + set(value) { this.$store.dispatch('settings/set', { key: 'showRenotedMyNotes', value }); } + }, + + showLocalRenotes: { + get() { return this.$store.state.settings.showLocalRenotes; }, + set(value) { this.$store.dispatch('settings/set', { key: 'showLocalRenotes', value }); } + }, + + showPostFormOnTopOfTl: { + get() { return this.$store.state.settings.showPostFormOnTopOfTl; }, + set(value) { this.$store.dispatch('settings/set', { key: 'showPostFormOnTopOfTl', value }); } + }, + + suggestRecentHashtags: { + get() { return this.$store.state.settings.suggestRecentHashtags; }, + set(value) { this.$store.dispatch('settings/set', { key: 'suggestRecentHashtags', value }); } + }, + + showClockOnHeader: { + get() { return this.$store.state.settings.showClockOnHeader; }, + set(value) { this.$store.dispatch('settings/set', { key: 'showClockOnHeader', value }); } + }, + + showMaps: { + get() { return this.$store.state.settings.showMaps; }, + set(value) { this.$store.dispatch('settings/set', { key: 'showMaps', value }); } + }, + + circleIcons: { + get() { return this.$store.state.settings.circleIcons; }, + set(value) { this.$store.dispatch('settings/set', { key: 'circleIcons', value }); } + }, + + contrastedAcct: { + get() { return this.$store.state.settings.contrastedAcct; }, + set(value) { this.$store.dispatch('settings/set', { key: 'contrastedAcct', value }); } + }, + + showFullAcct: { + get() { return this.$store.state.settings.showFullAcct; }, + set(value) { this.$store.dispatch('settings/set', { key: 'showFullAcct', value }); } + }, + + iLikeSushi: { + get() { return this.$store.state.settings.iLikeSushi; }, + set(value) { this.$store.dispatch('settings/set', { key: 'iLikeSushi', value }); } + }, + + games_reversi_showBoardLabels: { + get() { return this.$store.state.settings.games.reversi.showBoardLabels; }, + set(value) { this.$store.dispatch('settings/set', { key: 'games.reversi.showBoardLabels', value }); } + }, + + games_reversi_useContrastStones: { + get() { return this.$store.state.settings.games.reversi.useContrastStones; }, + set(value) { this.$store.dispatch('settings/set', { key: 'games.reversi.useContrastStones', value }); } + }, + + disableAnimatedMfm: { + get() { return this.$store.state.settings.disableAnimatedMfm; }, + set(value) { this.$store.dispatch('settings/set', { key: 'disableAnimatedMfm', value }); } + }, + + disableViaMobile: { + get() { return this.$store.state.settings.disableViaMobile; }, + set(value) { this.$store.dispatch('settings/set', { key: 'disableViaMobile', value }); } } }, created() { @@ -284,9 +439,6 @@ export default Vue.extend({ }); }, methods: { - taskmngr() { - (this as any).os.new(MkTaskManager); - }, customizeHome() { this.$router.push('/i/customize-home'); this.$emit('done'); @@ -305,113 +457,11 @@ export default Vue.extend({ wallpaperId: null }); }, - onChangeFetchOnScroll(v) { - this.$store.dispatch('settings/set', { - key: 'fetchOnScroll', - value: v - }); - }, onChangeAutoWatch(v) { (this as any).api('i/update', { autoWatch: v }); }, - onChangeDark(v) { - this.$store.dispatch('settings/set', { - key: 'dark', - value: v - }); - }, - onChangeShowPostFormOnTopOfTl(v) { - this.$store.dispatch('settings/set', { - key: 'showPostFormOnTopOfTl', - value: v - }); - }, - onChangeSuggestRecentHashtags(v) { - this.$store.dispatch('settings/set', { - key: 'suggestRecentHashtags', - value: v - }); - }, - onChangeShowClockOnHeader(v) { - this.$store.dispatch('settings/set', { - key: 'showClockOnHeader', - value: v - }); - }, - onChangeShowReplyTarget(v) { - this.$store.dispatch('settings/set', { - key: 'showReplyTarget', - value: v - }); - }, - onChangeShowMyRenotes(v) { - this.$store.dispatch('settings/set', { - key: 'showMyRenotes', - value: v - }); - }, - onChangeShowRenotedMyNotes(v) { - this.$store.dispatch('settings/set', { - key: 'showRenotedMyNotes', - value: v - }); - }, - onChangeShowLocalRenotes(v) { - this.$store.dispatch('settings/set', { - key: 'showLocalRenotes', - value: v - }); - }, - onChangeShowMaps(v) { - this.$store.dispatch('settings/set', { - key: 'showMaps', - value: v - }); - }, - onChangeCircleIcons(v) { - this.$store.dispatch('settings/set', { - key: 'circleIcons', - value: v - }); - }, - onChangeILikeSushi(v) { - this.$store.dispatch('settings/set', { - key: 'iLikeSushi', - value: v - }); - }, - onChangeReversiBoardLabels(v) { - this.$store.dispatch('settings/set', { - key: 'games.reversi.showBoardLabels', - value: v - }); - }, - onChangeUseContrastReversiStones(v) { - this.$store.dispatch('settings/set', { - key: 'games.reversi.useContrastStones', - value: v - }); - }, - onChangeDisableAnimatedMfm(v) { - this.$store.dispatch('settings/set', { - key: 'disableAnimatedMfm', - value: v - }); - }, - onChangeGradientWindowHeader(v) { - this.$store.dispatch('settings/set', { - key: 'gradientWindowHeader', - value: v - }); - }, - onChangeDisableViaMobile(v) { - this.$store.dispatch('settings/set', { - key: 'disableViaMobile', - value: v - }); - }, checkForUpdate() { this.checkingForUpdate = true; checkForUpdate((this as any).os, true, true).then(newer => { @@ -447,9 +497,7 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> -@import '~const.styl' - -root(isDark) +.mk-settings display flex width 100% height 100% @@ -460,13 +508,13 @@ root(isDark) height 100% padding 16px 0 0 0 overflow auto - border-right solid 1px isDark ? #1c2023 : #ddd + border-right solid 1px var(--faceDivider) > p display block padding 10px 16px margin 0 - color isDark ? #9aa2a7 : #666 + color var(--desktopSettingsNavItem) cursor pointer user-select none transition margin-left 0.2s ease @@ -475,11 +523,11 @@ root(isDark) margin-right 4px &:hover - color isDark ? #fff : #555 + color var(--desktopSettingsNavItemHover) &.active margin-left 8px - color $theme-color !important + color var(--primary) !important > .pages width 100% @@ -489,14 +537,13 @@ root(isDark) > section margin 32px - color isDark ? #c4ccd2 : #4a535a + color var(--text) > h1 margin 0 0 1em 0 padding 0 0 8px 0 font-size 1em - color isDark ? #e3e7ea : #555 - border-bottom solid 1px isDark ? #1c2023 : #eee + border-bottom solid 1px var(--faceDivider) &, >>> * .ui.button.block @@ -509,18 +556,12 @@ root(isDark) margin 0 0 1em 0 padding 0 0 8px 0 font-size 1em - color isDark ? #e3e7ea : #555 - border-bottom solid 1px isDark ? #1c2023 : #eee + color var(--text) + border-bottom solid 1px var(--faceDivider) > .web > .div - border-bottom solid 1px isDark ? #1c2023 : #eee + border-bottom solid 1px var(--faceDivider) 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 cb0374b910..fd8e658056 100644 --- a/src/client/app/desktop/views/components/sub-note-content.vue +++ b/src/client/app/desktop/views/components/sub-note-content.vue @@ -7,9 +7,9 @@ <misskey-flavored-markdown v-if="note.text" :text="note.text" :i="$store.state.i"/> <a class="rp" v-if="note.renoteId" :href="`/notes/${note.renoteId}`">RP: ...</a> </div> - <details v-if="note.media.length > 0"> - <summary>({{ '%i18n:@media-count%'.replace('{}', note.media.length) }})</summary> - <mk-media-list :media-list="note.media"/> + <details v-if="note.files.length > 0"> + <summary>({{ '%i18n:@media-count%'.replace('{}', note.files.length) }})</summary> + <mk-media-list :media-list="note.files"/> </details> <details v-if="note.poll"> <summary>%i18n:@poll%</summary> @@ -38,7 +38,7 @@ export default Vue.extend({ > .rp margin-left 4px font-style oblique - color #a0bf46 + color var(--renoteText) mk-poll font-size 80% diff --git a/src/client/app/desktop/views/components/taskmanager.vue b/src/client/app/desktop/views/components/taskmanager.vue deleted file mode 100644 index 1f1385add8..0000000000 --- a/src/client/app/desktop/views/components/taskmanager.vue +++ /dev/null @@ -1,219 +0,0 @@ -<template> -<mk-window ref="window" width="750px" height="500px" @closed="$destroy" name="TaskManager"> - <span slot="header" :class="$style.header">%fa:stethoscope%%i18n:@title%</span> - <el-tabs :class="$style.content"> - <el-tab-pane label="Requests"> - <el-table - :data="os.requests" - style="width: 100%" - :default-sort="{prop: 'date', order: 'descending'}" - > - <el-table-column type="expand"> - <template slot-scope="props"> - <pre>{{ props.row.data }}</pre> - <pre>{{ props.row.res }}</pre> - </template> - </el-table-column> - - <el-table-column - label="Requested at" - prop="date" - sortable - > - <template slot-scope="scope"> - <b style="margin-right: 8px">{{ scope.row.date.getTime() }}</b> - <span>(<mk-time :time="scope.row.date"/>)</span> - </template> - </el-table-column> - - <el-table-column - label="Name" - > - <template slot-scope="scope"> - <b>{{ scope.row.name }}</b> - </template> - </el-table-column> - - <el-table-column - label="Status" - > - <template slot-scope="scope"> - <span>{{ scope.row.status || '(pending)' }}</span> - </template> - </el-table-column> - </el-table> - </el-tab-pane> - - <el-tab-pane label="Streams"> - <el-table - :data="os.connections" - style="width: 100%" - > - <el-table-column - label="Uptime" - > - <template slot-scope="scope"> - <mk-timer v-if="scope.row.connectedAt" :time="scope.row.connectedAt"/> - <span v-else>-</span> - </template> - </el-table-column> - - <el-table-column - label="Name" - > - <template slot-scope="scope"> - <b>{{ scope.row.name == '' ? '[Home]' : scope.row.name }}</b> - </template> - </el-table-column> - - <el-table-column - label="User" - > - <template slot-scope="scope"> - <span>{{ scope.row.user || '(anonymous)' }}</span> - </template> - </el-table-column> - - <el-table-column - prop="state" - label="State" - /> - - <el-table-column - prop="in" - label="In" - /> - - <el-table-column - prop="out" - label="Out" - /> - </el-table> - </el-tab-pane> - - <el-tab-pane label="Streams (Inspect)"> - <el-tabs type="card" style="height:50%"> - <el-tab-pane v-for="c in os.connections" :label="c.name == '' ? '[Home]' : c.name" :key="c.id" :name="c.id" ref="connectionsTab"> - <div style="padding: 12px 0 0 12px"> - <el-button size="mini" @click="send(c)">Send</el-button> - <el-button size="mini" type="warning" @click="c.isSuspended = true" v-if="!c.isSuspended">Suspend</el-button> - <el-button size="mini" type="success" @click="c.isSuspended = false" v-else>Resume</el-button> - <el-button size="mini" type="danger" @click="c.close">Disconnect</el-button> - </div> - - <el-table - :data="c.inout" - style="width: 100%" - :default-sort="{prop: 'at', order: 'descending'}" - > - <el-table-column type="expand"> - <template slot-scope="props"> - <pre>{{ props.row.data }}</pre> - </template> - </el-table-column> - - <el-table-column - label="Date" - prop="at" - sortable - > - <template slot-scope="scope"> - <b style="margin-right: 8px">{{ scope.row.at.getTime() }}</b> - <span>(<mk-time :time="scope.row.at"/>)</span> - </template> - </el-table-column> - - <el-table-column - label="Type" - > - <template slot-scope="scope"> - <span>{{ getMessageType(scope.row.data) }}</span> - </template> - </el-table-column> - - <el-table-column - label="Incoming / Outgoing" - prop="type" - /> - </el-table> - </el-tab-pane> - </el-tabs> - </el-tab-pane> - - <el-tab-pane label="Windows"> - <el-table - :data="Array.from(os.windows.windows)" - style="width: 100%" - > - <el-table-column - label="Name" - > - <template slot-scope="scope"> - <b>{{ scope.row.name || '(unknown)' }}</b> - </template> - </el-table-column> - - <el-table-column - label="Operations" - > - <template slot-scope="scope"> - <el-button size="mini" type="danger" @click="scope.row.close">Close</el-button> - </template> - </el-table-column> - </el-table> - </el-tab-pane> - </el-tabs> -</mk-window> -</template> - -<script lang="ts"> -import Vue from 'vue'; - -export default Vue.extend({ - mounted() { - (this as any).os.windows.on('added', this.onWindowsChanged); - (this as any).os.windows.on('removed', this.onWindowsChanged); - }, - beforeDestroy() { - (this as any).os.windows.off('added', this.onWindowsChanged); - (this as any).os.windows.off('removed', this.onWindowsChanged); - }, - methods: { - getMessageType(data): string { - return data.type ? data.type : '-'; - }, - onWindowsChanged() { - this.$forceUpdate(); - }, - send(c) { - (this as any).apis.input({ - title: 'Send a JSON message', - allowEmpty: false - }).then(json => { - c.send(JSON.parse(json)); - }); - } - } -}); -</script> - -<style lang="stylus" module> -.header - > [data-fa] - margin-right 4px - -.content - height 100% - overflow auto - -</style> - -<style> -.el-tabs__header { - margin-bottom: 0 !important; -} - -.el-tabs__item { - padding: 0 20px !important; -} -</style> diff --git a/src/client/app/desktop/views/components/timeline.core.vue b/src/client/app/desktop/views/components/timeline.core.vue index 25fd5d36ac..2c17e936eb 100644 --- a/src/client/app/desktop/views/components/timeline.core.vue +++ b/src/client/app/desktop/views/components/timeline.core.vue @@ -23,6 +23,9 @@ export default Vue.extend({ src: { type: String, required: true + }, + tagTl: { + required: false } }, @@ -32,8 +35,14 @@ export default Vue.extend({ moreFetching: false, existMore: false, connection: null, - connectionId: null, - date: null + date: null, + baseQuery: { + includeMyRenotes: this.$store.state.settings.showMyRenotes, + includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes, + includeLocalRenotes: this.$store.state.settings.showLocalRenotes + }, + query: {}, + endpoint: null }; }, @@ -42,53 +51,67 @@ export default Vue.extend({ return this.$store.state.i.followingCount == 0; }, - stream(): any { - switch (this.src) { - case 'home': return (this as any).os.stream; - case 'local': return (this as any).os.streams.localTimelineStream; - case 'hybrid': return (this as any).os.streams.hybridTimelineStream; - case 'global': return (this as any).os.streams.globalTimelineStream; - } - }, - - endpoint(): string { - switch (this.src) { - case 'home': return 'notes/timeline'; - case 'local': return 'notes/local-timeline'; - case 'hybrid': return 'notes/hybrid-timeline'; - case 'global': return 'notes/global-timeline'; - } - }, - canFetchMore(): boolean { return !this.moreFetching && !this.fetching && this.existMore; } }, mounted() { - this.connection = this.stream.getConnection(); - this.connectionId = this.stream.use(); + const prepend = note => { + (this.$refs.timeline as any).prepend(note); + }; - this.connection.on('note', this.onNote); - if (this.src == 'home') { - this.connection.on('follow', this.onChangeFollowing); - this.connection.on('unfollow', this.onChangeFollowing); + if (this.src == 'tag') { + this.endpoint = 'notes/search_by_tag'; + this.query = { + query: this.tagTl.query + }; + this.connection = (this as any).os.stream.connectToChannel('hashtag', { q: this.tagTl.query }); + this.connection.on('note', prepend); + } else if (this.src == 'home') { + this.endpoint = 'notes/timeline'; + const onChangeFollowing = () => { + this.fetch(); + }; + this.connection = (this as any).os.stream.useSharedConnection('homeTimeline'); + this.connection.on('note', prepend); + this.connection.on('follow', onChangeFollowing); + this.connection.on('unfollow', onChangeFollowing); + } else if (this.src == 'local') { + this.endpoint = 'notes/local-timeline'; + this.connection = (this as any).os.stream.useSharedConnection('localTimeline'); + this.connection.on('note', prepend); + } else if (this.src == 'hybrid') { + this.endpoint = 'notes/hybrid-timeline'; + this.connection = (this as any).os.stream.useSharedConnection('hybridTimeline'); + this.connection.on('note', prepend); + } else if (this.src == 'global') { + this.endpoint = 'notes/global-timeline'; + this.connection = (this as any).os.stream.useSharedConnection('globalTimeline'); + this.connection.on('note', prepend); + } else if (this.src == 'mentions') { + this.endpoint = 'notes/mentions'; + this.connection = (this as any).os.stream.useSharedConnection('main'); + this.connection.on('mention', prepend); + } else if (this.src == 'messages') { + this.endpoint = 'notes/mentions'; + this.query = { + visibility: 'specified' + }; + const onNote = note => { + if (note.visibility == 'specified') { + prepend(note); + } + }; + this.connection = (this as any).os.stream.useSharedConnection('main'); + this.connection.on('mention', onNote); } - document.addEventListener('keydown', this.onKeydown); - 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); - - document.removeEventListener('keydown', this.onKeydown); + this.connection.dispose(); }, methods: { @@ -96,13 +119,10 @@ export default Vue.extend({ this.fetching = true; (this.$refs.timeline as any).init(() => new Promise((res, rej) => { - (this as any).api(this.endpoint, { + (this as any).api(this.endpoint, Object.assign({ limit: fetchLimit + 1, - untilDate: this.date ? this.date.getTime() : undefined, - includeMyRenotes: this.$store.state.settings.showMyRenotes, - includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes, - includeLocalRenotes: this.$store.state.settings.showLocalRenotes - }).then(notes => { + untilDate: this.date ? this.date.getTime() : undefined + }, this.baseQuery, this.query)).then(notes => { if (notes.length == fetchLimit + 1) { notes.pop(); this.existMore = true; @@ -119,13 +139,10 @@ export default Vue.extend({ this.moreFetching = true; - const promise = (this as any).api(this.endpoint, { + const promise = (this as any).api(this.endpoint, Object.assign({ limit: fetchLimit + 1, - untilId: (this.$refs.timeline as any).tail().id, - includeMyRenotes: this.$store.state.settings.showMyRenotes, - includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes, - includeLocalRenotes: this.$store.state.settings.showLocalRenotes - }); + untilId: (this.$refs.timeline as any).tail().id + }, this.baseQuery, this.query)); promise.then(notes => { if (notes.length == fetchLimit + 1) { @@ -140,15 +157,6 @@ export default Vue.extend({ return promise; }, - onNote(note) { - // Prepend a note - (this.$refs.timeline as any).prepend(note); - }, - - onChangeFollowing() { - this.fetch(); - }, - focus() { (this.$refs.timeline as any).focus(); }, @@ -156,21 +164,13 @@ export default Vue.extend({ warp(date) { this.date = date; this.fetch(); - }, - - onKeydown(e) { - if (e.target.tagName != 'INPUT' && e.target.tagName != 'TEXTAREA') { - if (e.which == 84) { // t - this.focus(); - } - } } } }); </script> <style lang="stylus" scoped> -@import '~const.styl' + .mk-timeline-core > .mk-friends-maker diff --git a/src/client/app/desktop/views/components/timeline.vue b/src/client/app/desktop/views/components/timeline.vue index 52a7753438..3e4c45d228 100644 --- a/src/client/app/desktop/views/components/timeline.vue +++ b/src/client/app/desktop/views/components/timeline.vue @@ -2,16 +2,25 @@ <div class="mk-timeline"> <header> <span :data-active="src == 'home'" @click="src = 'home'">%fa:home% %i18n:@home%</span> - <span :data-active="src == 'local'" @click="src = 'local'">%fa:R comments% %i18n:@local%</span> - <span :data-active="src == 'hybrid'" @click="src = 'hybrid'">%fa:share-alt% %i18n:@hybrid%</span> + <span :data-active="src == 'local'" @click="src = 'local'" v-if="enableLocalTimeline">%fa:R comments% %i18n:@local%</span> + <span :data-active="src == 'hybrid'" @click="src = 'hybrid'" v-if="enableLocalTimeline">%fa:share-alt% %i18n:@hybrid%</span> <span :data-active="src == 'global'" @click="src = 'global'">%fa:globe% %i18n:@global%</span> + <span :data-active="src == 'tag'" @click="src = 'tag'" v-if="tagTl">%fa:hashtag% {{ tagTl.title }}</span> <span :data-active="src == 'list'" @click="src = 'list'" v-if="list">%fa:list% {{ list.title }}</span> - <button @click="chooseList" title="%i18n:@list%">%fa:list%</button> + <div class="buttons"> + <button :data-active="src == 'mentions'" @click="src = 'mentions'" title="%i18n:@mentions%">%fa:at%<i class="badge" v-if="$store.state.i.hasUnreadMentions">%fa:circle%</i></button> + <button :data-active="src == 'messages'" @click="src = 'messages'" title="%i18n:@messages%">%fa:envelope R%<i class="badge" v-if="$store.state.i.hasUnreadSpecifiedNotes">%fa:circle%</i></button> + <button @click="chooseTag" title="%i18n:@hashtag%" ref="tagButton">%fa:hashtag%</button> + <button @click="chooseList" title="%i18n:@list%" ref="listButton">%fa:list%</button> + </div> </header> <x-core v-if="src == 'home'" ref="tl" key="home" src="home"/> <x-core v-if="src == 'local'" ref="tl" key="local" src="local"/> <x-core v-if="src == 'hybrid'" ref="tl" key="hybrid" src="hybrid"/> <x-core v-if="src == 'global'" ref="tl" key="global" src="global"/> + <x-core v-if="src == 'mentions'" ref="tl" key="mentions" src="mentions"/> + <x-core v-if="src == 'messages'" ref="tl" key="messages" src="messages"/> + <x-core v-if="src == 'tag'" ref="tl" key="tag" src="tag" :tag-tl="tagTl"/> <mk-user-list-timeline v-if="src == 'list'" ref="tl" :key="list.id" :list="list"/> </div> </template> @@ -19,7 +28,8 @@ <script lang="ts"> import Vue from 'vue'; import XCore from './timeline.core.vue'; -import MkUserListsWindow from './user-lists-window.vue'; +import Menu from '../../../common/views/components/menu.vue'; +import MkSettingsWindow from './settings-window.vue'; export default Vue.extend({ components: { @@ -29,7 +39,9 @@ export default Vue.extend({ data() { return { src: 'home', - list: null + list: null, + tagTl: null, + enableLocalTimeline: false }; }, @@ -38,16 +50,28 @@ export default Vue.extend({ this.saveSrc(); }, - list() { + list(x) { this.saveSrc(); + if (x != null) this.tagTl = null; + }, + + tagTl(x) { + this.saveSrc(); + if (x != null) this.list = null; } }, created() { + (this as any).os.getMeta().then(meta => { + this.enableLocalTimeline = !meta.disableLocalTimeline; + }); + if (this.$store.state.device.tl) { this.src = this.$store.state.device.tl.src; if (this.src == 'list') { this.list = this.$store.state.device.tl.arg; + } else if (this.src == 'tag') { + this.tagTl = this.$store.state.device.tl.arg; } } else if (this.$store.state.i.followingCount == 0) { this.src = 'hybrid'; @@ -64,20 +88,86 @@ export default Vue.extend({ saveSrc() { this.$store.commit('device/setTl', { src: this.src, - arg: this.list + arg: this.src == 'list' ? this.list : this.tagTl }); }, + focus() { + (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(); + async chooseList() { + const lists = await (this as any).api('users/lists/list'); + + let menu = [{ + icon: '%fa:plus%', + text: '%i18n:@add-list%', + action: () => { + (this as any).apis.input({ + title: '%i18n:@list-name%', + }).then(async title => { + const list = await (this as any).api('users/lists/create', { + title + }); + + this.list = list; + this.src = 'list'; + }); + } + }]; + + if (lists.length > 0) { + menu.push(null); + } + + menu = menu.concat(lists.map(list => ({ + icon: '%fa:list%', + text: list.title, + action: () => { + this.list = list; + this.src = 'list'; + } + }))); + + this.os.new(Menu, { + source: this.$refs.listButton, + compact: false, + items: menu + }); + }, + + chooseTag() { + let menu = [{ + icon: '%fa:plus%', + text: '%i18n:@add-tag-timeline%', + action: () => { + (this as any).os.new(MkSettingsWindow, { + initialPage: 'hashtags' + }); + } + }]; + + if (this.$store.state.settings.tagTimelines.length > 0) { + menu.push(null); + } + + menu = menu.concat(this.$store.state.settings.tagTimelines.map(t => ({ + icon: '%fa:hashtag%', + text: t.title, + action: () => { + this.tagTl = t; + this.src = 'tag'; + } + }))); + + this.os.new(Menu, { + source: this.$refs.tagButton, + compact: false, + items: menu }); } } @@ -85,36 +175,54 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> -@import '~const.styl' - -root(isDark) - background isDark ? #282C37 : #fff - border solid 1px rgba(#000, 0.075) - border-radius 6px +.mk-timeline + background var(--face) + box-shadow var(--shadow) + border-radius var(--round) + overflow hidden > header 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) + background var(--faceHeader) + box-shadow 0 1px var(--desktopTimelineHeaderShadow) - > button + > .buttons position absolute z-index 2 top 0 right 0 - padding 0 - width 42px - font-size 0.9em - line-height 42px - color isDark ? #9baec8 : #ccc + padding-right 8px - &:hover - color isDark ? #b2c1d5 : #aaa + > button + padding 0 8px + font-size 0.9em + line-height 42px + color var(--faceTextButton) - &:active - color isDark ? #b2c1d5 : #999 + > .badge + position absolute + top -4px + right 4px + font-size 10px + color var(--primary) + + &:hover + color var(--faceTextButtonHover) + + &[data-active] + color var(--primary) + cursor default + + &:before + content "" + display block + position absolute + bottom 0 + left 0 + width 100% + height 2px + background var(--primary) > span display inline-block @@ -124,7 +232,7 @@ root(isDark) user-select none &[data-active] - color $theme-color + color var(--primary) cursor default font-weight bold @@ -136,19 +244,13 @@ root(isDark) left -8px width calc(100% + 16px) height 2px - background $theme-color + background var(--primary) &:not([data-active]) - color isDark ? #9aa2a7 : #6f7477 + color var(--desktopTimelineSrc) cursor pointer &:hover - color isDark ? #d9dcde : #525a5f - -.mk-timeline[data-darkmode] - root(true) - -.mk-timeline:not([data-darkmode]) - root(false) + color var(--desktopTimelineSrcHover) </style> diff --git a/src/client/app/desktop/views/components/ui-notification.vue b/src/client/app/desktop/views/components/ui-notification.vue index 68413914c0..dafede4c36 100644 --- a/src/client/app/desktop/views/components/ui-notification.vue +++ b/src/client/app/desktop/views/components/ui-notification.vue @@ -27,7 +27,7 @@ export default Vue.extend({ translateY: -64, duration: 500, easing: 'easeInElastic', - complete: () => this.$destroy() + complete: () => this.destroyDom() }); }, 6000); }); @@ -36,7 +36,7 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> -root(isDark) +.mk-ui-notification display block position fixed z-index 10000 @@ -46,10 +46,10 @@ root(isDark) margin 0 auto padding 128px 0 0 0 width 500px - color rgba(isDark ? #fff : #000, 0.6) - background rgba(isDark ? #282C37 : #fff, 0.9) + color var(--desktopNotificationFg) + background var(--desktopNotificationBg) border-radius 0 0 8px 8px - box-shadow 0 2px 4px rgba(#000, isDark ? 0.4 : 0.2) + box-shadow 0 2px 4px var(--desktopNotificationShadow) transform translateY(-64px) opacity 0 @@ -58,10 +58,4 @@ root(isDark) line-height 64px text-align center -.mk-ui-notification[data-darkmode] - root(true) - -.mk-ui-notification: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 5e26389d89..a541dea121 100644 --- a/src/client/app/desktop/views/components/ui.header.account.vue +++ b/src/client/app/desktop/views/components/ui.header.account.vue @@ -1,5 +1,5 @@ <template> -<div class="account"> +<div class="account" v-hotkey.global="keymap"> <button class="header" :data-active="isOpen" @click="toggle"> <span class="username">{{ $store.state.i.username }}<template v-if="!isOpen">%fa:angle-down%</template><template v-if="isOpen">%fa:angle-up%</template></span> <mk-avatar class="avatar" :user="$store.state.i"/> @@ -63,6 +63,13 @@ export default Vue.extend({ isOpen: false }; }, + computed: { + keymap(): any { + return { + 'a|m': this.toggle + }; + } + }, beforeDestroy() { this.close(); }, @@ -120,14 +127,12 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> -@import '~const.styl' - -root(isDark) +.account > .header display block margin 0 padding 0 - color #9eaba8 + color var(--desktopHeaderFg) border none background transparent cursor pointer @@ -137,14 +142,11 @@ root(isDark) &:hover &[data-active='true'] - color isDark ? #fff : darken(#9eaba8, 20%) + color var(--desktopHeaderHoverFg) > .avatar filter saturate(150%) - &:active - color isDark ? #fff : darken(#9eaba8, 30%) - > .username display block float left @@ -170,7 +172,7 @@ root(isDark) transition filter 100ms ease > .menu - $bgcolor = isDark ? #282c37 : #fff + $bgcolor = var(--face) display block position absolute top 56px @@ -213,7 +215,7 @@ root(isDark) & + ul padding-top 10px - border-top solid 1px isDark ? #1c2023 : #eee + border-top solid 1px var(--faceDivider) > li display block @@ -227,7 +229,7 @@ root(isDark) padding 0 28px margin 0 line-height 40px - color isDark ? #c8cece : #868C8C + color var(--text) cursor pointer * @@ -242,8 +244,8 @@ root(isDark) padding 2px 8px font-size 90% font-style normal - background $theme-color - color $theme-color-foreground + background var(--primary) + color var(--primaryForeground) border-radius 8px > [data-fa]:first-child @@ -262,11 +264,11 @@ root(isDark) &:hover, &:active text-decoration none - background $theme-color - color $theme-color-foreground + background var(--primary) + color var(--primaryForeground) &:active - background darken($theme-color, 10%) + background var(--primaryDarken10) &.signout $color = #e64137 @@ -283,10 +285,4 @@ root(isDark) 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.clock.vue b/src/client/app/desktop/views/components/ui.header.clock.vue index 1c3f12f2f2..b8b638bc41 100644 --- a/src/client/app/desktop/views/components/ui.header.clock.vue +++ b/src/client/app/desktop/views/components/ui.header.clock.vue @@ -89,7 +89,7 @@ export default Vue.extend({ display table-cell vertical-align middle height 48px - color #9eaba8 + color var(--desktopHeaderFg) > .yyyymmdd opacity 0.7 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 6292b764c6..122570a696 100644 --- a/src/client/app/desktop/views/components/ui.header.nav.vue +++ b/src/client/app/desktop/views/components/ui.header.nav.vue @@ -42,8 +42,7 @@ export default Vue.extend({ data() { return { hasGameInvitations: false, - connection: null, - connectionId: null + connection: null }; }, computed: { @@ -53,18 +52,15 @@ export default Vue.extend({ }, mounted() { if (this.$store.getters.isSignedIn) { - this.connection = (this as any).os.stream.getConnection(); - this.connectionId = (this as any).os.stream.use(); + this.connection = (this as any).os.stream.useSharedConnection('main'); - this.connection.on('reversi_invited', this.onReversiInvited); + this.connection.on('reversiInvited', this.onReversiInvited); this.connection.on('reversi_no_invites', this.onReversiNoInvites); } }, beforeDestroy() { if (this.$store.getters.isSignedIn) { - this.connection.off('reversi_invited', this.onReversiInvited); - this.connection.off('reversi_no_invites', this.onReversiNoInvites); - (this as any).os.stream.dispose(this.connectionId); + this.connection.dispose(); } }, methods: { @@ -95,9 +91,7 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> -@import '~const.styl' - -root(isDark) +.nav display inline-block margin 0 padding 0 @@ -120,7 +114,7 @@ root(isDark) &.active > a - border-bottom solid 3px $theme-color + border-bottom solid 3px var(--primary) > a display inline-block @@ -129,7 +123,7 @@ root(isDark) padding 0 24px font-size 13px font-variant small-caps - color isDark ? #b8c5ca : #9eaba8 + color var(--desktopHeaderFg) text-decoration none transition none cursor pointer @@ -138,7 +132,7 @@ root(isDark) pointer-events none &:hover - color isDark ? #fff : darken(#9eaba8, 20%) + color var(--desktopHeaderHoverFg) text-decoration none > [data-fa]:first-child @@ -147,7 +141,7 @@ root(isDark) > [data-fa]:last-child margin-left 5px font-size 10px - color $theme-color + color var(--primary) @media (max-width 1100px) margin-left -5px @@ -162,10 +156,4 @@ root(isDark) @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 59a16df9ec..c59a49556d 100644 --- a/src/client/app/desktop/views/components/ui.header.notifications.vue +++ b/src/client/app/desktop/views/components/ui.header.notifications.vue @@ -1,5 +1,5 @@ <template> -<div class="notifications"> +<div class="notifications" v-hotkey.global="keymap"> <button :data-active="isOpen" @click="toggle" title="%i18n:@title%"> %fa:R bell%<template v-if="hasUnreadNotification">%fa:circle%</template> </button> @@ -19,11 +19,19 @@ export default Vue.extend({ isOpen: false }; }, + computed: { hasUnreadNotification(): boolean { return this.$store.getters.isSignedIn && this.$store.state.i.hasUnreadNotification; + }, + + keymap(): any { + return { + 'shift+n': this.toggle + }; } }, + methods: { toggle() { this.isOpen ? this.close() : this.open(); @@ -53,16 +61,13 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> -@import '~const.styl' - -root(isDark) - +.notifications > button display block margin 0 padding 0 width 32px - color #9eaba8 + color var(--desktopHeaderFg) border none background transparent cursor pointer @@ -72,10 +77,7 @@ root(isDark) &:hover &[data-active='true'] - color isDark ? #fff : darken(#9eaba8, 20%) - - &:active - color isDark ? #fff : darken(#9eaba8, 30%) + color var(--desktopHeaderHoverFg) > [data-fa].bell font-size 1.2em @@ -85,10 +87,10 @@ root(isDark) margin-left -5px vertical-align super font-size 10px - color $theme-color + color var(--primary) > .pop - $bgcolor = isDark ? #282c37 : #fff + $bgcolor = var(--face) display block position absolute top 56px @@ -127,10 +129,4 @@ root(isDark) 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.post.vue b/src/client/app/desktop/views/components/ui.header.post.vue index 3665488542..9527792a34 100644 --- a/src/client/app/desktop/views/components/ui.header.post.vue +++ b/src/client/app/desktop/views/components/ui.header.post.vue @@ -17,7 +17,7 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> -@import '~const.styl' + .note display inline-block @@ -33,8 +33,8 @@ export default Vue.extend({ font-size 1.2em font-weight normal text-decoration none - color $theme-color-foreground - background $theme-color !important + color var(--primaryForeground) + background var(--primary) !important outline none border none border-radius 4px @@ -45,10 +45,10 @@ export default Vue.extend({ pointer-events none &:hover - background lighten($theme-color, 10%) !important + background var(--primaryLighten10) !important &:active - background darken($theme-color, 10%) !important + background var(--primaryDarken10) !important transition background 0s ease </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 9a36e52fcc..d22efbf84f 100644 --- a/src/client/app/desktop/views/components/ui.header.search.vue +++ b/src/client/app/desktop/views/components/ui.header.search.vue @@ -28,8 +28,7 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> -@import '~const.styl' -root(isDark) +.search > [data-fa] display block position absolute @@ -38,7 +37,7 @@ root(isDark) width 48px text-align center line-height 48px - color #9eaba8 + color var(--desktopHeaderFg) pointer-events none > * @@ -52,26 +51,20 @@ root(isDark) width 14em height 32px font-size 1em - background rgba(#000, 0.05) + background var(--desktopHeaderSearchBg) outline none - //border solid 1px #ddd border none border-radius 16px transition color 0.5s ease, border 0.5s ease - color isDark ? #fff : #000 + color var(--desktopHeaderSearchFg) &::placeholder - color #9eaba8 + color var(--desktopHeaderFg) &:hover - background isDark ? rgba(#fff, 0.04) : rgba(#000, 0.08) + background var(--desktopHeaderSearchHoverBg) &:focus - box-shadow 0 0 0 2px rgba($theme-color, 0.5) !important + box-shadow 0 0 0 2px var(--primaryAlpha05) !important -.search[data-darkmode] - root(true) - -.search:not([data-darkmode]) - root(false) </style> diff --git a/src/client/app/desktop/views/components/ui.header.vue b/src/client/app/desktop/views/components/ui.header.vue index 6de4eaf744..4cfcda0f1a 100644 --- a/src/client/app/desktop/views/components/ui.header.vue +++ b/src/client/app/desktop/views/components/ui.header.vue @@ -1,16 +1,18 @@ <template> -<div class="header"> +<div class="header" :style="style"> + <p class="warn" v-if="env != 'production'">%i18n:common.do-not-use-in-production%</p> <mk-special-message/> <div class="main" ref="main"> <div class="backdrop"></div> <div class="main"> - <p ref="welcomeback" v-if="$store.getters.isSignedIn">%i18n:@welcome-back%<b>{{ $store.state.i | userName }}</b>%i18n:@adjective%</p> <div class="container" ref="mainContainer"> <div class="left"> <x-nav/> </div> <div class="center"> - <div class="icon" @click="goToTop"></div> + <div class="icon" @click="goToTop"> + <img svg-inline src="../../assets/header-icon.svg"/> + </div> </div> <div class="right"> <x-search/> @@ -28,6 +30,7 @@ <script lang="ts"> import Vue from 'vue'; import * as anime from 'animejs'; +import { env } from '../../../config'; import XNav from './ui.header.nav.vue'; import XSearch from './ui.header.search.vue'; @@ -43,62 +46,27 @@ export default Vue.extend({ XAccount, XNotifications, XPost, - XClock, + XClock }, - mounted() { - this.$store.commit('setUiHeaderHeight', 48); - - if (this.$store.getters.isSignedIn) { - const ago = (new Date().getTime() - new Date(this.$store.state.i.lastUsedAt).getTime()) / 1000; - const isHisasiburi = ago >= 3600; - this.$store.state.i.lastUsedAt = new Date(); - - if (isHisasiburi) { - (this.$refs.welcomeback as any).style.display = 'block'; - (this.$refs.main as any).style.overflow = 'hidden'; - - anime({ - targets: this.$refs.welcomeback, - top: '0', - opacity: 1, - delay: 1000, - duration: 500, - easing: 'easeOutQuad' - }); - - anime({ - targets: this.$refs.mainContainer, - opacity: 0, - delay: 1000, - duration: 500, - easing: 'easeOutQuad' - }); - - setTimeout(() => { - anime({ - targets: this.$refs.welcomeback, - top: '-48px', - opacity: 0, - duration: 500, - complete: () => { - (this.$refs.welcomeback as any).style.display = 'none'; - (this.$refs.main as any).style.overflow = 'initial'; - }, - easing: 'easeInQuad' - }); + data() { + return { + env: env + }; + }, - anime({ - targets: this.$refs.mainContainer, - opacity: 1, - duration: 500, - easing: 'easeInQuad' - }); - }, 2500); - } + computed: { + style(): any { + return { + 'box-shadow': this.$store.state.settings.useShadow ? '0 0px 8px rgba(0, 0, 0, 0.2)' : 'none' + }; } }, + mounted() { + this.$store.commit('setUiHeaderHeight', this.$el.offsetHeight); + }, + methods: { goToTop() { window.scrollTo({ @@ -111,13 +79,20 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> -root(isDark) - position -webkit-sticky - position sticky +.header + position fixed top 0 z-index 1000 width 100% - box-shadow 0 1px 1px rgba(#000, 0.075) + + > .warn + display block + margin 0 + padding 4px + text-align center + font-size 12px + background #f00 + color #fff > .main height 48px @@ -128,7 +103,7 @@ root(isDark) z-index 1000 width 100% height 48px - background isDark ? #313543 : #f7f7f7 + background var(--desktopHeaderBg) > .main z-index 1001 @@ -138,17 +113,6 @@ root(isDark) font-size 0.9rem user-select none - > p - display none - position absolute - top 48px - width 100% - line-height 48px - margin 0 - text-align center - color isDark ? #fff : #888 - opacity 0 - > .container display flex width 100% @@ -166,13 +130,15 @@ root(isDark) margin auto display block width 48px - height 48px - background-image isDark ? url('/assets/desktop/header-icon.dark.svg') : url('/assets/desktop/header-icon.light.svg') - background-size 24px - background-position center - background-repeat no-repeat - opacity 0.3 + text-align center cursor pointer + opacity 0.5 + + > svg + width 24px + height 48px + vertical-align top + fill var(--desktopHeaderFg) > .left, > .center @@ -189,10 +155,4 @@ root(isDark) > .mk-ui-header-search display none -.header[data-darkmode] - root(true) - -.header:not([data-darkmode]) - root(false) - </style> diff --git a/src/client/app/desktop/views/components/ui.vue b/src/client/app/desktop/views/components/ui.vue index d410c3d980..2d1e98447b 100644 --- a/src/client/app/desktop/views/components/ui.vue +++ b/src/client/app/desktop/views/components/ui.vue @@ -1,6 +1,7 @@ <template> -<div class="mk-ui" :style="style"> - <x-header class="header" v-show="!zenMode"/> +<div class="mk-ui" v-hotkey.global="keymap"> + <div class="bg" v-if="$store.getters.isSignedIn && $store.state.i.wallpaperUrl" :style="style"></div> + <x-header class="header" v-show="!zenMode" ref="header"/> <div class="content"> <slot></slot> </div> @@ -16,11 +17,13 @@ export default Vue.extend({ components: { XHeader }, + data() { return { zenMode: false }; }, + computed: { style(): any { if (!this.$store.getters.isSignedIn || this.$store.state.i.wallpaperUrl == null) return {}; @@ -28,27 +31,37 @@ export default Vue.extend({ backgroundColor: this.$store.state.i.wallpaperColor && this.$store.state.i.wallpaperColor.length == 3 ? `rgb(${ this.$store.state.i.wallpaperColor.join(',') })` : null, backgroundImage: `url(${ this.$store.state.i.wallpaperUrl })` }; + }, + + keymap(): any { + return { + 'p': this.post, + 'n': this.post, + 'z': this.toggleZenMode + }; } }, - mounted() { - document.addEventListener('keydown', this.onKeydown); + + watch: { + '$store.state.uiHeaderHeight'() { + this.$el.style.paddingTop = this.$store.state.uiHeaderHeight + 'px'; + } }, - beforeDestroy() { - document.removeEventListener('keydown', this.onKeydown); + + mounted() { + this.$el.style.paddingTop = this.$store.state.uiHeaderHeight + 'px'; }, - methods: { - onKeydown(e) { - if (e.target.tagName == 'INPUT' || e.target.tagName == 'TEXTAREA') return; - if (e.which == 80 || e.which == 78) { // p or n - e.preventDefault(); - (this as any).apis.post(); - } + methods: { + post() { + (this as any).apis.post(); + }, - if (e.which == 90) { // z - e.preventDefault(); - this.zenMode = !this.zenMode; - } + toggleZenMode() { + this.zenMode = !this.zenMode; + this.$nextTick(() => { + this.$store.commit('setUiHeaderHeight', this.$refs.header.$el.offsetHeight); + }); } } }); @@ -56,20 +69,22 @@ export default Vue.extend({ <style lang="stylus" scoped> .mk-ui - display flex - flex-direction column - flex 1 - background-size cover - background-position center - background-attachment fixed + min-height 100vh + padding-top 48px + + > .bg + position fixed + top 0 + left 0 + width 100% + height 100vh + background-size cover + background-position center + background-attachment fixed + opacity 0.3 > .header @media (max-width 1000px) display none - > .content - display flex - flex-direction column - flex 1 - overflow hidden </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 index 0a6f758763..3407851fc5 100644 --- a/src/client/app/desktop/views/components/user-list-timeline.vue +++ b/src/client/app/desktop/views/components/user-list-timeline.vue @@ -6,7 +6,6 @@ <script lang="ts"> import Vue from 'vue'; -import { UserListStream } from '../../../common/scripts/streaming/user-list'; const fetchLimit = 10; diff --git a/src/client/app/desktop/views/components/user-lists-window.vue b/src/client/app/desktop/views/components/user-lists-window.vue index 72ae9cf4e4..9c384314cf 100644 --- a/src/client/app/desktop/views/components/user-lists-window.vue +++ b/src/client/app/desktop/views/components/user-lists-window.vue @@ -1,8 +1,8 @@ <template> -<mk-window ref="window" is-modal width="450px" height="500px" @closed="$destroy"> +<mk-window ref="window" is-modal width="450px" height="500px" @closed="destroyDom"> <span slot="header">%fa:list% %i18n:@title%</span> - <div class="xkxvokkjlptzyewouewmceqcxhpgzprp" :data-darkmode="$store.state.device.darkmode"> + <div class="xkxvokkjlptzyewouewmceqcxhpgzprp"> <button class="ui" @click="add">%i18n:@create-list%</button> <a v-for="list in lists" :key="list.id" @click="choice(list)">{{ list.title }}</a> </div> @@ -47,8 +47,7 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> - -root(isDark) +.xkxvokkjlptzyewouewmceqcxhpgzprp padding 16px > button @@ -57,13 +56,7 @@ root(isDark) > a display block padding 16px - border solid 1px isDark ? #1c2023 : #eee + border solid 1px var(--faceDivider) border-radius 4px -.xkxvokkjlptzyewouewmceqcxhpgzprp[data-darkmode] - root(true) - -.xkxvokkjlptzyewouewmceqcxhpgzprp: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 1e1755ec3c..7f5e79eae1 100644 --- a/src/client/app/desktop/views/components/user-preview.vue +++ b/src/client/app/desktop/views/components/user-preview.vue @@ -75,7 +75,7 @@ export default Vue.extend({ 'margin-top': '-8px', duration: 200, easing: 'easeOutQuad', - complete: () => this.$destroy() + complete: () => this.destroyDom() }); } } @@ -83,14 +83,12 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> -@import '~const.styl' - -root(isDark) +.mk-user-preview position absolute z-index 2048 margin-top -8px width 250px - background isDark ? #282c37 : #fff + background var(--face) background-clip content-box border solid 1px rgba(#000, 0.1) border-radius 4px @@ -99,7 +97,7 @@ root(isDark) > .banner height 84px - background-color isDark ? #1c1e26 : #f5f5f5 + background-color rgba(0, 0, 0, 0.1) background-size cover background-position center @@ -111,7 +109,7 @@ root(isDark) z-index 2 width 58px height 58px - border solid 3px isDark ? #282c37 : #fff + border solid 3px var(--face) border-radius 8px > .title @@ -123,19 +121,20 @@ root(isDark) margin 0 font-weight bold line-height 16px - color isDark ? #fff : #656565 + color var(--text) > .username display block margin 0 line-height 16px font-size 0.8em - color isDark ? #606984 : #999 + color var(--text) + opacity 0.7 > .description padding 0 16px font-size 0.7em - color isDark ? #9ea4ad : #555 + color var(--text) > .status padding 8px 16px @@ -147,21 +146,15 @@ root(isDark) > p margin 0 font-size 0.7em - color #aaa + color var(--text) > span font-size 1em - color $theme-color + color var(--primary) > .mk-follow-button position absolute 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 262fd38cd1..f42d577fce 100644 --- a/src/client/app/desktop/views/components/users-list.item.vue +++ b/src/client/app/desktop/views/components/users-list.item.vue @@ -1,17 +1,16 @@ <template> -<div class="root item"> - <mk-avatar class="avatar" :user="user"/> - <div class="main"> - <header> - <router-link class="name" :to="user | userPage" v-user-preview="user.id">{{ user | userName }}</router-link> - <span class="username">@{{ user | acct }}</span> - </header> - <div class="body"> - <p class="followed" v-if="user.isFollowed">%i18n:@followed%</p> - <div class="description">{{ user.description }}</div> +<div class="zvdbznxvfixtmujpsigoccczftvpiwqh"> + <div class="banner" :style="bannerStyle"></div> + <mk-avatar class="avatar" :user="user" :disable-preview="true"/> + <div class="body"> + <router-link :to="user | userPage" class="name">{{ user | userName }}</router-link> + <span class="username">@{{ user | acct }}</span> + <div class="description"> + <misskey-flavored-markdown v-if="user.description" :text="user.description" :i="$store.state.i"/> </div> + <p class="followed" v-if="user.isFollowed">%i18n:@followed%</p> + <mk-follow-button :user="user" :size="'big'"/> </div> - <mk-follow-button :user="user"/> </div> </template> @@ -19,76 +18,69 @@ import Vue from 'vue'; export default Vue.extend({ - props: ['user'] + props: ['user'], + + computed: { + bannerStyle(): any { + if (this.user.bannerUrl == null) return {}; + return { + backgroundColor: this.user.bannerColor && this.user.bannerColor.length == 3 ? `rgb(${ this.user.bannerColor.join(',') })` : null, + backgroundImage: `url(${ this.user.bannerUrl })` + }; + } + }, }); </script> <style lang="stylus" scoped> -.root.item - padding 16px +.zvdbznxvfixtmujpsigoccczftvpiwqh + $bg = #fff + + margin 16px auto + max-width calc(100% - 32px) font-size 16px + text-align center + background $bg + box-shadow 0 2px 4px rgba(0, 0, 0, 0.1) - &:after - content "" - display block - clear both + > .banner + height 100px + background-color #f9f4f4 + background-position center + background-size cover > .avatar display block - float left - margin 0 16px 0 0 - width 58px - height 58px - border-radius 8px - - > .main - float left - width calc(100% - 74px) - - > header - margin-bottom 2px + margin -40px auto 0 auto + width 80px + height 80px + border-radius 100% + border solid 4px $bg - > .name - display inline - margin 0 - padding 0 - color #777 - font-size 1em - font-weight 700 - text-align left - text-decoration none + > .body + padding 4px 32px 32px 32px - &:hover - text-decoration underline + @media (max-width 400px) + padding 4px 16px 16px 16px - > .username - text-align left - margin 0 0 0 8px - color #ccc + > .name + font-size 20px + font-weight bold - > .body - > .followed - display inline-block - margin 0 0 4px 0 - padding 2px 8px - vertical-align top - font-size 10px - color #71afc7 - background #eefaff - border-radius 4px + > .username + display block + opacity 0.7 - > .description - cursor default - display block - margin 0 - padding 0 - overflow-wrap break-word - font-size 1.1em - color #717171 + > .description + margin 16px 0 - > .mk-follow-button - position absolute - top 16px - right 16px + > .followed + margin 0 0 16px 0 + padding 0 + line-height 24px + font-size 0.8em + color #71afc7 + background #eefaff + border-radius 4px </style> diff --git a/src/client/app/desktop/views/components/users-list.vue b/src/client/app/desktop/views/components/users-list.vue index 0423db8ed7..1316f277b7 100644 --- a/src/client/app/desktop/views/components/users-list.vue +++ b/src/client/app/desktop/views/components/users-list.vue @@ -33,7 +33,7 @@ export default Vue.extend({ props: ['fetch', 'count', 'youKnowCount'], data() { return { - limit: 30, + limit: 20, mode: 'all', fetching: true, moreFetching: false, @@ -69,14 +69,18 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> -@import '~const.styl' + .mk-users-list height 100% - background #fff + overflow auto + background #eee > nav - z-index 1 + z-index 10 + position sticky + top 0 + background #fff box-shadow 0 1px 0 rgba(#000, 0.1) > div @@ -100,8 +104,8 @@ export default Vue.extend({ &[data-active] font-weight bold - color $theme-color - border-color $theme-color + color var(--primary) + border-color var(--primary) cursor default > span @@ -114,16 +118,14 @@ export default Vue.extend({ background #eee border-radius 20px - > .users - height calc(100% - 54px) - overflow auto - - > * - border-bottom solid 1px rgba(#000, 0.05) + > button + display block + width calc(100% - 32px) + margin 16px + padding 16px - > * - max-width 600px - margin 0 auto + &:hover + background rgba(#000, 0.1) > .no margin 0 diff --git a/src/client/app/desktop/views/components/widget-container.vue b/src/client/app/desktop/views/components/widget-container.vue index 7cfcd68eba..a506357039 100644 --- a/src/client/app/desktop/views/components/widget-container.vue +++ b/src/client/app/desktop/views/components/widget-container.vue @@ -1,6 +1,6 @@ <template> <div class="mk-widget-container" :class="{ naked }"> - <header :class="{ withGradient }" v-if="showHeader"> + <header v-if="showHeader"> <div class="title"><slot name="header"></slot></div> <slot name="func"></slot> </header> @@ -20,32 +20,23 @@ export default Vue.extend({ type: Boolean, default: false } - }, - computed: { - withGradient(): boolean { - return this.$store.getters.isSignedIn - ? this.$store.state.settings.gradientWindowHeader != null - ? this.$store.state.settings.gradientWindowHeader - : false - : false; - } } }); </script> <style lang="stylus" scoped> -root(isDark) - background isDark ? #282C37 : #fff - border solid 1px rgba(#000, isDark ? 0.2 : 0.075) - border-radius 6px +.mk-widget-container + background var(--face) + box-shadow var(--shadow) + border-radius var(--round) overflow hidden &.naked background transparent !important - border none !important + box-shadow none !important > header - background isDark ? #313543 : #fff + background var(--faceHeader) > .title z-index 1 @@ -54,7 +45,7 @@ root(isDark) line-height 42px font-size 0.9em font-weight bold - color isDark ? #e3e5e8 : #888 + color var(--faceHeaderText) box-shadow 0 1px rgba(#000, 0.07) > [data-fa] @@ -72,23 +63,12 @@ root(isDark) width 42px font-size 0.9em line-height 42px - color isDark ? #9baec8 : #ccc + color var(--faceTextButton) &:hover - color isDark ? #b2c1d5 : #aaa + color var(--faceTextButtonHover) &:active - color isDark ? #b2c1d5 : #999 - - &.withGradient - > .title - 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) + color var(--faceTextButtonActive) </style> diff --git a/src/client/app/desktop/views/components/window.vue b/src/client/app/desktop/views/components/window.vue index ec044ad27e..a1893ffd6b 100644 --- a/src/client/app/desktop/views/components/window.vue +++ b/src/client/app/desktop/views/components/window.vue @@ -4,7 +4,6 @@ <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: $store.state.settings.gradientWindowHeader }" @contextmenu.prevent="() => {}" @mousedown.prevent="onHeaderMousedown" > <h1><slot name="header"></slot></h1> @@ -76,6 +75,11 @@ export default Vue.extend({ name: { type: String, default: null + }, + animation: { + type: Boolean, + required: false, + default: true } }, @@ -106,7 +110,7 @@ export default Vue.extend({ mounted() { if (this.preventMount) { - this.$destroy(); + this.destroyDom(); return; } @@ -142,7 +146,7 @@ export default Vue.extend({ anime({ targets: bg, opacity: 1, - duration: 100, + duration: this.animation ? 100 : 0, easing: 'linear' }); } @@ -152,7 +156,7 @@ export default Vue.extend({ targets: main, opacity: 1, scale: [1.1, 1], - duration: 200, + duration: this.animation ? 200 : 0, easing: 'easeOutQuad' }); @@ -160,7 +164,7 @@ export default Vue.extend({ setTimeout(() => { this.$emit('opened'); - }, 300); + }, this.animation ? 300 : 0); }, close() { @@ -174,7 +178,7 @@ export default Vue.extend({ anime({ targets: bg, opacity: 0, - duration: 300, + duration: this.animation ? 300 : 0, easing: 'linear' }); } @@ -185,14 +189,14 @@ export default Vue.extend({ targets: main, opacity: 0, scale: 0.8, - duration: 300, + duration: this.animation ? 300 : 0, easing: [0.5, -0.5, 1, 0.5] }); setTimeout(() => { - this.$destroy(); this.$emit('closed'); - }, 300); + this.destroyDom(); + }, this.animation ? 300 : 0); }, popout() { @@ -458,9 +462,7 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> -@import '~const.styl' - -root(isDark) +.mk-window display block > .bg @@ -488,10 +490,7 @@ root(isDark) &:focus &:not([data-is-modal]) > .body - if isDark - box-shadow 0 0 0px 1px rgba($theme-color, 0.5), 0 2px 12px 0 rgba(#000, 0.5) - else - box-shadow 0 0 0px 1px rgba($theme-color, 0.5), 0 2px 6px 0 rgba(#000, 0.2) + box-shadow 0 0 0px 1px var(--primaryAlpha05), 0 2px 12px 0 var(--desktopWindowShadow) > .handle $size = 8px @@ -557,13 +556,9 @@ root(isDark) > .body height 100% overflow hidden - background isDark ? #282C37 : #fff + background var(--face) border-radius 6px - - if isDark - box-shadow 0 2px 12px 0 rgba(#000, 0.5) - else - box-shadow 0 2px 6px 0 rgba(#000, 0.2) + box-shadow 0 2px 12px 0 rgba(#000, 0.5) > header $header-height = 40px @@ -573,14 +568,10 @@ root(isDark) overflow hidden white-space nowrap cursor move - background isDark ? #313543 : #fff + background var(--faceHeader) border-radius 6px 6px 0 0 box-shadow 0 1px 0 rgba(#000, 0.1) - &.withGradient - background isDark ? linear-gradient(to bottom, #313543, #1d2027) : linear-gradient(to bottom, #fff, #ececec) - box-shadow 0 1px 0 rgba(#000, 0.15) - &, * user-select none @@ -595,7 +586,7 @@ root(isDark) font-size 1em line-height $header-height font-weight normal - color isDark ? #e3e5e8 : #666 + color var(--desktopWindowTitle) > div:last-child position absolute @@ -610,16 +601,16 @@ root(isDark) padding 0 cursor pointer font-size 1em - color isDark ? #9baec8 : rgba(#000, 0.4) + color var(--faceTextButton) border none outline none background transparent &:hover - color isDark ? #b2c1d5 : rgba(#000, 0.6) + color var(--faceTextButtonHover) &:active - color isDark ? #b2c1d5 : darken(#000, 30%) + color var(--faceTextButtonActive) > [data-fa] padding 0 @@ -634,10 +625,4 @@ root(isDark) > .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/admin/admin.announcements.vue b/src/client/app/desktop/views/pages/admin/admin.announcements.vue new file mode 100644 index 0000000000..5c1ed74b29 --- /dev/null +++ b/src/client/app/desktop/views/pages/admin/admin.announcements.vue @@ -0,0 +1,52 @@ +<template> +<div class="qldxjjsrseehkusjuoooapmsprvfrxyl mk-admin-card"> + <header>%i18n:@announcements%</header> + <textarea v-model="broadcasts" placeholder='[ { "title": "Title1", "text": "Text1" }, { "title": "Title2", "text": "Text2" } ]'></textarea> + <button class="ui" @click="save">%i18n:@save%</button> +</div> +</template> + +<script lang="ts"> +import Vue from "vue"; + +export default Vue.extend({ + data() { + return { + broadcasts: '', + }; + }, + created() { + (this as any).os.getMeta().then(meta => { + this.broadcasts = JSON.stringify(meta.broadcasts, null, ' '); + }); + }, + methods: { + save() { + let json; + + try { + json = JSON.parse(this.broadcasts); + } catch (e) { + (this as any).os.apis.dialog({ text: `Failed: ${e}` }); + return; + } + + (this as any).api('admin/update-meta', { + broadcasts: json + }).then(() => { + (this as any).os.apis.dialog({ text: `Saved` }); + }.catch(e => { + (this as any).os.apis.dialog({ text: `Failed ${e}` }); + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +.qldxjjsrseehkusjuoooapmsprvfrxyl + textarea + width 100% + min-height 300px + +</style> diff --git a/src/client/app/desktop/views/pages/admin/admin.cpu-memory.vue b/src/client/app/desktop/views/pages/admin/admin.cpu-memory.vue index d14ce12553..63b24cea47 100644 --- a/src/client/app/desktop/views/pages/admin/admin.cpu-memory.vue +++ b/src/client/app/desktop/views/pages/admin/admin.cpu-memory.vue @@ -111,7 +111,7 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> -root(isDark) +.zyknedwtlthezamcjlolyusmipqmjgxz > svg display block width 50% @@ -125,7 +125,7 @@ root(isDark) > text font-size 10px - fill isDark ? rgba(#fff, 0.55) : rgba(#000, 0.55) + fill var(--chartCaption) > tspan opacity 0.5 @@ -135,10 +135,4 @@ root(isDark) display block clear both -.zyknedwtlthezamcjlolyusmipqmjgxz[data-darkmode] - root(true) - -.zyknedwtlthezamcjlolyusmipqmjgxz:not([data-darkmode]) - root(false) - </style> diff --git a/src/client/app/desktop/views/pages/admin/admin.dashboard.vue b/src/client/app/desktop/views/pages/admin/admin.dashboard.vue index ebb54d782e..c0075220bc 100644 --- a/src/client/app/desktop/views/pages/admin/admin.dashboard.vue +++ b/src/client/app/desktop/views/pages/admin/admin.dashboard.vue @@ -1,22 +1,42 @@ <template> <div class="obdskegsannmntldydackcpzezagxqfy mk-admin-card"> <header>%i18n:@dashboard%</header> + <div v-if="stats" class="stats"> <div><b>%fa:user% {{ stats.originalUsersCount | number }}</b><span>%i18n:@original-users%</span></div> <div><span>%fa:user% {{ stats.usersCount | number }}</span><span>%i18n:@all-users%</span></div> <div><b>%fa:pencil-alt% {{ stats.originalNotesCount | number }}</b><span>%i18n:@original-notes%</span></div> <div><span>%fa:pencil-alt% {{ stats.notesCount | number }}</span><span>%i18n:@all-notes%</span></div> </div> + <div class="cpu-memory"> <x-cpu-memory :connection="connection"/> </div> - <div> - <label> - <input type="checkbox" v-model="disableRegistration" @change="updateMeta"> - <span>disableRegistration</span> - </label> - <button class="ui" @click="invite">%i18n:@invite%</button> - <p v-if="inviteCode">Code: <code>{{ inviteCode }}</code></p> + + <div v-if="this.$store.state.i && this.$store.state.i.isAdmin" class="form"> + <div> + <label> + <p>%i18n:@banner-url%</p> + <input v-model="bannerUrl"> + </label> + <button class="ui" @click="updateMeta">%i18n:@save%</button> + </div> + + <div> + <label> + <input type="checkbox" v-model="disableRegistration" @change="updateMeta"> + <span>%i18n:@disableRegistration%</span> + </label> + <button class="ui" @click="invite">%i18n:@invite%</button> + <p v-if="inviteCode">Code: <code>{{ inviteCode }}</code></p> + </div> + + <div> + <label> + <input type="checkbox" v-model="disableLocalTimeline" @change="updateMeta"> + <span>%i18n:@disableLocalTimeline%</span> + </label> + </div> </div> </div> </template> @@ -33,17 +53,19 @@ export default Vue.extend({ return { stats: null, disableRegistration: false, + disableLocalTimeline: false, + bannerUrl: null, inviteCode: null, - connection: null, - connectionId: null + connection: null }; }, created() { - this.connection = (this as any).os.streams.serverStatsStream.getConnection(); - this.connectionId = (this as any).os.streams.serverStatsStream.use(); + this.connection = (this as any).os.stream.useSharedConnection('serverStats'); (this as any).os.getMeta().then(meta => { this.disableRegistration = meta.disableRegistration; + this.disableLocalTimeline = meta.disableLocalTimeline; + this.bannerUrl = meta.bannerUrl; }); (this as any).api('stats').then(stats => { @@ -51,17 +73,25 @@ export default Vue.extend({ }); }, beforeDestroy() { - (this as any).os.streams.serverStatsStream.dispose(this.connectionId); + this.connection.dispose(); }, methods: { invite() { (this as any).api('admin/invite').then(x => { this.inviteCode = x.code; + }).catch(e => { + (this as any).os.apis.dialog({ text: `Failed ${e}` }); }); }, updateMeta() { (this as any).api('admin/update-meta', { - disableRegistration: this.disableRegistration + disableRegistration: this.disableRegistration, + disableLocalTimeline: this.disableLocalTimeline, + bannerUrl: this.bannerUrl + }).then(() => { + (this as any).os.apis.dialog({ text: `Saved` }); + }).catch(e => { + (this as any).os.apis.dialog({ text: `Failed ${e}` }); }); } } @@ -69,7 +99,7 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> -@import '~const.styl' + .obdskegsannmntldydackcpzezagxqfy > .stats @@ -86,7 +116,7 @@ export default Vue.extend({ > *:first-child display block - color $theme-color + color var(--primary) > *:last-child font-size 70% @@ -97,4 +127,9 @@ export default Vue.extend({ border solid 1px #eee border-radius: 8px + > .form + > div + padding 16px + border-bottom solid 1px #eee + </style> diff --git a/src/client/app/desktop/views/pages/admin/admin.hashtags.vue b/src/client/app/desktop/views/pages/admin/admin.hashtags.vue new file mode 100644 index 0000000000..10bab1cbd7 --- /dev/null +++ b/src/client/app/desktop/views/pages/admin/admin.hashtags.vue @@ -0,0 +1,45 @@ +<template> +<div class="jdnqwkzlnxcfftthoybjxrebyolvoucw mk-admin-card"> + <header>%i18n:@hided-tags%</header> + <textarea v-model="hidedTags"></textarea> + <button class="ui" @click="save">%i18n:@save%</button> +</div> +</template> + +<script lang="ts"> +import Vue from "vue"; + +export default Vue.extend({ + data() { + return { + hidedTags: '', + }; + }, + created() { + (this as any).os.getMeta().then(meta => { + this.hidedTags = meta.hidedTags.join('\n'); + }); + }, + methods: { + save() { + (this as any).api('admin/update-meta', { + hidedTags: this.hidedTags.split('\n') + }).then(() => { + (this as any).os.apis.dialog({ text: `Saved` }); + }).catch(e => { + (this as any).os.apis.dialog({ text: `Failed ${e}` }); + }); + } + } +}); +</script> + +<style lang="stylus" scoped> + + +.jdnqwkzlnxcfftthoybjxrebyolvoucw + textarea + width 100% + min-height 300px + +</style> diff --git a/src/client/app/desktop/views/pages/admin/admin.suspend-user.vue b/src/client/app/desktop/views/pages/admin/admin.suspend-user.vue index 8d8e37e181..a8ff937bbe 100644 --- a/src/client/app/desktop/views/pages/admin/admin.suspend-user.vue +++ b/src/client/app/desktop/views/pages/admin/admin.suspend-user.vue @@ -21,25 +21,31 @@ export default Vue.extend({ async suspendUser() { this.suspending = true; - const user = await (this as any).os.api( - "users/show", - parseAcct(this.username) - ); + const process = async () => { + const user = await (this as any).os.api( + "users/show", + parseAcct(this.username) + ); - await (this as any).os.api("admin/suspend-user", { - userId: user.id + await (this as any).os.api("admin/suspend-user", { + userId: user.id + }); + + (this as any).os.apis.dialog({ text: "%i18n:@suspended%" }); + }; + + await process().catch(e => { + (this as any).os.apis.dialog({ text: `Failed: ${e}` }); }); this.suspending = false; - - (this as any).os.apis.dialog({ text: "%i18n:@suspended%" }); } } }); </script> <style lang="stylus" scoped> -@import '~const.styl' + header margin 10px 0 diff --git a/src/client/app/desktop/views/pages/admin/admin.unsuspend-user.vue b/src/client/app/desktop/views/pages/admin/admin.unsuspend-user.vue index ec423969be..146f5a41d4 100644 --- a/src/client/app/desktop/views/pages/admin/admin.unsuspend-user.vue +++ b/src/client/app/desktop/views/pages/admin/admin.unsuspend-user.vue @@ -21,25 +21,32 @@ export default Vue.extend({ async unsuspendUser() { this.unsuspending = true; - const user = await (this as any).os.api( - "users/show", - parseAcct(this.username) - ); + const process = async () => { + const user = await (this as any).os.api( + "users/show", + parseAcct(this.username) + ); - await (this as any).os.api("admin/unsuspend-user", { - userId: user.id + await (this as any).os.api("admin/unsuspend-user", { + userId: user.id + }); + + (this as any).os.apis.dialog({ text: "%i18n:@unsuspended%" }); + }; + + await process().catch(e => { + (this as any).os.apis.dialog({ text: `Failed: ${e}` }); }); this.unsuspending = false; - (this as any).os.apis.dialog({ text: "%i18n:@unsuspended%" }); } } }); </script> <style lang="stylus" scoped> -@import '~const.styl' + header margin 10px 0 diff --git a/src/client/app/desktop/views/pages/admin/admin.unverify-user.vue b/src/client/app/desktop/views/pages/admin/admin.unverify-user.vue index e8204e69f4..5e0fdae5c1 100644 --- a/src/client/app/desktop/views/pages/admin/admin.unverify-user.vue +++ b/src/client/app/desktop/views/pages/admin/admin.unverify-user.vue @@ -21,25 +21,31 @@ export default Vue.extend({ async unverifyUser() { this.unverifying = true; - const user = await (this as any).os.api( - "users/show", - parseAcct(this.username) - ); + const process = async () => { + const user = await (this as any).os.api( + "users/show", + parseAcct(this.username) + ); - await (this as any).os.api("admin/unverify-user", { - userId: user.id + await (this as any).os.api("admin/unverify-user", { + userId: user.id + }); + + (this as any).os.apis.dialog({ text: "%i18n:@unverified%" }); + }; + + await process().catch(e => { + (this as any).os.apis.dialog({ text: `Failed: ${e}` }); }); this.unverifying = false; - - (this as any).os.apis.dialog({ text: "%i18n:@unverified%" }); } } }); </script> <style lang="stylus" scoped> -@import '~const.styl' + header margin 10px 0 diff --git a/src/client/app/desktop/views/pages/admin/admin.verify-user.vue b/src/client/app/desktop/views/pages/admin/admin.verify-user.vue index 91fb04af80..d237a5f9c1 100644 --- a/src/client/app/desktop/views/pages/admin/admin.verify-user.vue +++ b/src/client/app/desktop/views/pages/admin/admin.verify-user.vue @@ -21,25 +21,31 @@ export default Vue.extend({ async verifyUser() { this.verifying = true; - const user = await (this as any).os.api( - "users/show", - parseAcct(this.username) - ); + const process = async () => { + const user = await (this as any).os.api( + "users/show", + parseAcct(this.username) + ); - await (this as any).os.api("admin/verify-user", { - userId: user.id + await (this as any).os.api("admin/verify-user", { + userId: user.id + }); + + (this as any).os.apis.dialog({ text: "%i18n:@verified%" }); + }; + + await process().catch(e => { + (this as any).os.apis.dialog({ text: `Failed: ${e}` }); }); this.verifying = false; - - (this as any).os.apis.dialog({ text: "%i18n:@verified%" }); } } }); </script> <style lang="stylus" scoped> -@import '~const.styl' + header margin 10px 0 diff --git a/src/client/app/desktop/views/pages/admin/admin.vue b/src/client/app/desktop/views/pages/admin/admin.vue index 3438462cd6..ad417e5121 100644 --- a/src/client/app/desktop/views/pages/admin/admin.vue +++ b/src/client/app/desktop/views/pages/admin/admin.vue @@ -3,7 +3,16 @@ <nav> <ul> <li @click="nav('dashboard')" :class="{ active: page == 'dashboard' }">%fa:chalkboard .fw%%i18n:@dashboard%</li> - <li @click="nav('users')" :class="{ active: page == 'users' }">%fa:users .fw%%i18n:@users%</li> + + <li v-if="this.$store.state.i && this.$store.state.i.isAdmin" + @click="nav('users')" :class="{ active: page == 'users' }">%fa:users .fw%%i18n:@users%</li> + + <li v-if="this.$store.state.i && this.$store.state.i.isAdmin" + @click="nav('announcements')" :class="{ active: page == 'announcements' }">%fa:broadcast-tower .fw%%i18n:@announcements%</li> + + <li v-if="this.$store.state.i && this.$store.state.i.isAdmin" + @click="nav('hashtags')" :class="{ active: page == 'hashtags' }">%fa:hashtag .fw%%i18n:@hashtags%</li> + <!-- <li @click="nav('drive')" :class="{ active: page == 'drive' }">%fa:cloud .fw%%i18n:@drive%</li> --> <!-- <li @click="nav('update')" :class="{ active: page == 'update' }">%i18n:@update%</li> --> </ul> @@ -13,6 +22,12 @@ <x-dashboard/> <x-charts/> </div> + <div v-show="page == 'announcements'"> + <x-announcements/> + </div> + <div v-show="page == 'hashtags'"> + <x-hashtags/> + </div> <div v-if="page == 'users'"> <x-suspend-user/> <x-unsuspend-user/> @@ -28,6 +43,8 @@ <script lang="ts"> import Vue from "vue"; import XDashboard from "./admin.dashboard.vue"; +import XAnnouncements from "./admin.announcements.vue"; +import XHashtags from "./admin.hashtags.vue"; import XSuspendUser from "./admin.suspend-user.vue"; import XUnsuspendUser from "./admin.unsuspend-user.vue"; import XVerifyUser from "./admin.verify-user.vue"; @@ -37,6 +54,8 @@ import XCharts from "../../components/charts.vue"; export default Vue.extend({ components: { XDashboard, + XAnnouncements, + XHashtags, XSuspendUser, XUnsuspendUser, XVerifyUser, @@ -57,7 +76,7 @@ export default Vue.extend({ </script> <style lang="stylus"> -@import '~const.styl' + .mk-admin display flex @@ -93,7 +112,7 @@ export default Vue.extend({ &.active margin-left 8px - color $theme-color !important + color var(--primary) !important > main width 100% diff --git a/src/client/app/desktop/views/pages/deck/deck.column-core.vue b/src/client/app/desktop/views/pages/deck/deck.column-core.vue index 7f219c0be1..e1490cb0e4 100644 --- a/src/client/app/desktop/views/pages/deck/deck.column-core.vue +++ b/src/client/app/desktop/views/pages/deck/deck.column-core.vue @@ -6,6 +6,9 @@ <x-tl-column v-else-if="column.type == 'hybrid'" :column="column" :is-stacked="isStacked"/> <x-tl-column v-else-if="column.type == 'global'" :column="column" :is-stacked="isStacked"/> <x-tl-column v-else-if="column.type == 'list'" :column="column" :is-stacked="isStacked"/> +<x-tl-column v-else-if="column.type == 'hashtag'" :column="column" :is-stacked="isStacked"/> +<x-mentions-column v-else-if="column.type == 'mentions'" :column="column" :is-stacked="isStacked"/> +<x-direct-column v-else-if="column.type == 'direct'" :column="column" :is-stacked="isStacked"/> </template> <script lang="ts"> @@ -13,12 +16,16 @@ import Vue from 'vue'; import XTlColumn from './deck.tl-column.vue'; import XNotificationsColumn from './deck.notifications-column.vue'; import XWidgetsColumn from './deck.widgets-column.vue'; +import XMentionsColumn from './deck.mentions-column.vue'; +import XDirectColumn from './deck.direct-column.vue'; export default Vue.extend({ components: { XTlColumn, XNotificationsColumn, - XWidgetsColumn + XWidgetsColumn, + XMentionsColumn, + XDirectColumn }, props: { diff --git a/src/client/app/desktop/views/pages/deck/deck.column.vue b/src/client/app/desktop/views/pages/deck/deck.column.vue index d59d430da6..c372ef490e 100644 --- a/src/client/app/desktop/views/pages/deck/deck.column.vue +++ b/src/client/app/desktop/views/pages/deck/deck.column.vue @@ -3,18 +3,20 @@ @dragover.prevent.stop="onDragover" @dragenter.prevent="onDragenter" @dragleave="onDragleave" - @drop.prevent.stop="onDrop" -> + @drop.prevent.stop="onDrop"> <header :class="{ indicate: count > 0 }" draggable="true" - @click="toggleActive" + @click="goTop" @dragstart="onDragstart" @dragend="onDragend" - @contextmenu.prevent.stop="onContextmenu" - > + @contextmenu.prevent.stop="onContextmenu"> + <button class="toggleActive" @click="toggleActive" v-if="isStacked"> + <template v-if="active">%fa:angle-up%</template> + <template v-else>%fa:angle-down%</template> + </button> <slot name="header"></slot> <span class="count" v-if="count > 0">({{ count }})</span> - <button ref="menu" @click.stop="showMenu">%fa:caret-down%</button> + <button class="menu" ref="menu" @click.stop="showMenu">%fa:caret-down%</button> </header> <div ref="body" v-show="active"> <slot></slot> @@ -26,6 +28,7 @@ import Vue from 'vue'; import Menu from '../../../../common/views/components/menu.vue'; import contextmenu from '../../../api/contextmenu'; +import { countIf } from '../../../../../../prelude/array'; export default Vue.extend({ props: { @@ -115,7 +118,7 @@ export default Vue.extend({ toggleActive() { if (!this.isStacked) return; const vms = this.$store.state.settings.deck.layout.find(ids => ids.indexOf(this.column.id) != -1).map(id => this.getColumnVm(id)); - if (this.active && vms.filter(vm => vm.$el.classList.contains('active')).length == 1) return; + if (this.active && countIf(vm => vm.$el.classList.contains('active'), vms) == 1) return; this.active = !this.active; }, @@ -211,6 +214,13 @@ export default Vue.extend({ }); }, + goTop() { + this.$refs.body.scrollTo({ + top: 0, + behavior: 'smooth' + }); + }, + onDragstart(e) { e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('mk-deck-column', this.column.id); @@ -259,24 +269,22 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> -@import '~const.styl' - -root(isDark) +.dnpfarvgbnfmyzbdquhhzyxcmstpdqzs $header-height = 42px width 330px min-width 330px height 100% - background isDark ? #282C37 : #fff + background var(--face) border-radius 6px - box-shadow 0 2px 16px rgba(#000, 0.1) + //box-shadow 0 2px 16px rgba(#000, 0.1) overflow hidden &.draghover - box-shadow 0 0 0 2px rgba($theme-color, 0.8) + box-shadow 0 0 0 2px var(--primaryAlpha08) &.dragging - box-shadow 0 0 0 2px rgba($theme-color, 0.4) + box-shadow 0 0 0 2px var(--primaryAlpha04) &.dropready * @@ -291,23 +299,23 @@ root(isDark) min-width 285px &.naked - background rgba(#000, isDark ? 0.25 : 0.1) + background var(--deckAcrylicColumnBg) > header background transparent box-shadow none - if !isDark - > button - color #bbb + > button + color var(--text) > header + display flex z-index 1 line-height $header-height padding 0 16px font-size 14px - color isDark ? #e3e5e8 : #888 - background isDark ? #313543 : #fff + color var(--faceHeaderText) + background var(--faceHeader) box-shadow 0 1px rgba(#000, 0.15) cursor pointer @@ -318,7 +326,7 @@ root(isDark) pointer-events none &.indicate - box-shadow 0 3px 0 0 $theme-color + box-shadow 0 3px 0 0 var(--primary) > span [data-fa] @@ -328,30 +336,29 @@ root(isDark) margin-left 4px opacity 0.5 - > button - position absolute - top 0 - right 0 + > .toggleActive + > .menu width $header-height line-height $header-height font-size 16px - color isDark ? #9baec8 : #ccc + color var(--faceTextButton) &:hover - color isDark ? #b2c1d5 : #aaa + color var(--faceTextButtonHover) &:active - color isDark ? #b2c1d5 : #999 + color var(--faceTextButtonActive) + + > .toggleActive + margin-left -16px + + > .menu + margin-left auto + margin-right -16px > div height "calc(100% - %s)" % $header-height overflow auto overflow-x hidden -.dnpfarvgbnfmyzbdquhhzyxcmstpdqzs[data-darkmode] - root(true) - -.dnpfarvgbnfmyzbdquhhzyxcmstpdqzs:not([data-darkmode]) - root(false) - </style> diff --git a/src/client/app/desktop/views/pages/deck/deck.direct-column.vue b/src/client/app/desktop/views/pages/deck/deck.direct-column.vue new file mode 100644 index 0000000000..d5093761f4 --- /dev/null +++ b/src/client/app/desktop/views/pages/deck/deck.direct-column.vue @@ -0,0 +1,38 @@ +<template> +<x-column :name="name" :column="column" :is-stacked="isStacked"> + <span slot="header">%fa:envelope R%{{ name }}</span> + + <x-direct/> +</x-column> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import XColumn from './deck.column.vue'; +import XDirect from './deck.direct.vue'; + +export default Vue.extend({ + components: { + XColumn, + XDirect + }, + + props: { + column: { + type: Object, + required: true + }, + isStacked: { + type: Boolean, + required: true + } + }, + + computed: { + name(): string { + if (this.column.name) return this.column.name; + return '%i18n:common.deck.direct%'; + } + }, +}); +</script> diff --git a/src/client/app/desktop/views/pages/deck/deck.direct.vue b/src/client/app/desktop/views/pages/deck/deck.direct.vue new file mode 100644 index 0000000000..c771e58a6e --- /dev/null +++ b/src/client/app/desktop/views/pages/deck/deck.direct.vue @@ -0,0 +1,93 @@ +<template> + <x-notes ref="timeline" :more="existMore ? more : null"/> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import XNotes from './deck.notes.vue'; + +const fetchLimit = 10; + +export default Vue.extend({ + components: { + XNotes + }, + + props: { + }, + + data() { + return { + fetching: true, + moreFetching: false, + existMore: false, + connection: null + }; + }, + + mounted() { + this.connection = (this as any).os.stream.useSharedConnection('main'); + this.connection.on('mention', this.onNote); + + this.fetch(); + }, + + beforeDestroy() { + this.connection.dispose(); + }, + + methods: { + fetch() { + this.fetching = true; + + (this.$refs.timeline as any).init(() => new Promise((res, rej) => { + (this as any).api('notes/mentions', { + limit: fetchLimit + 1, + includeMyRenotes: this.$store.state.settings.showMyRenotes, + includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes, + includeLocalRenotes: this.$store.state.settings.showLocalRenotes, + visibility: 'specified' + }).then(notes => { + if (notes.length == fetchLimit + 1) { + notes.pop(); + this.existMore = true; + } + res(notes); + this.fetching = false; + this.$emit('loaded'); + }, rej); + })); + }, + more() { + this.moreFetching = true; + + const promise = (this as any).api('notes/mentions', { + limit: fetchLimit + 1, + untilId: (this.$refs.timeline as any).tail().id, + includeMyRenotes: this.$store.state.settings.showMyRenotes, + includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes, + includeLocalRenotes: this.$store.state.settings.showLocalRenotes, + visibility: 'specified' + }); + + promise.then(notes => { + if (notes.length == fetchLimit + 1) { + notes.pop(); + } else { + this.existMore = false; + } + notes.forEach(n => (this.$refs.timeline as any).append(n)); + this.moreFetching = false; + }); + + return promise; + }, + onNote(note) { + // Prepend a note + if (note.visibility == 'specified') { + (this.$refs.timeline as any).prepend(note); + } + } + } +}); +</script> diff --git a/src/client/app/desktop/views/pages/deck/deck.hashtag-tl.vue b/src/client/app/desktop/views/pages/deck/deck.hashtag-tl.vue new file mode 100644 index 0000000000..02d99d3883 --- /dev/null +++ b/src/client/app/desktop/views/pages/deck/deck.hashtag-tl.vue @@ -0,0 +1,116 @@ +<template> + <x-notes ref="timeline" :more="existMore ? more : null" :media-view="mediaView"/> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import XNotes from './deck.notes.vue'; + +const fetchLimit = 10; + +export default Vue.extend({ + components: { + XNotes + }, + + props: { + tagTl: { + type: Object, + required: true + }, + mediaOnly: { + type: Boolean, + required: false, + default: false + }, + mediaView: { + type: Boolean, + required: false, + default: false + } + }, + + data() { + return { + fetching: true, + moreFetching: false, + existMore: false, + connection: null + }; + }, + + watch: { + mediaOnly() { + this.fetch(); + } + }, + + mounted() { + if (this.connection) this.connection.close(); + this.connection = (this as any).os.stream.connectToChannel('hashtag', this.tagTl.query); + this.connection.on('note', this.onNote); + + this.fetch(); + }, + + beforeDestroy() { + this.connection.close(); + }, + + methods: { + fetch() { + this.fetching = true; + + (this.$refs.timeline as any).init(() => new Promise((res, rej) => { + (this as any).api('notes/search_by_tag', { + limit: fetchLimit + 1, + withFiles: this.mediaOnly, + includeMyRenotes: this.$store.state.settings.showMyRenotes, + includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes, + includeLocalRenotes: this.$store.state.settings.showLocalRenotes, + query: this.tagTl.query + }).then(notes => { + if (notes.length == fetchLimit + 1) { + notes.pop(); + this.existMore = true; + } + res(notes); + this.fetching = false; + this.$emit('loaded'); + }, rej); + })); + }, + more() { + this.moreFetching = true; + + const promise = (this as any).api('notes/search_by_tag', { + limit: fetchLimit + 1, + untilId: (this.$refs.timeline as any).tail().id, + withFiles: this.mediaOnly, + includeMyRenotes: this.$store.state.settings.showMyRenotes, + includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes, + includeLocalRenotes: this.$store.state.settings.showLocalRenotes, + query: this.tagTl.query + }); + + promise.then(notes => { + if (notes.length == fetchLimit + 1) { + notes.pop(); + } else { + this.existMore = false; + } + notes.forEach(n => (this.$refs.timeline as any).append(n)); + this.moreFetching = false; + }); + + return promise; + }, + onNote(note) { + if (this.mediaOnly && note.files.length == 0) return; + + // Prepend a note + (this.$refs.timeline as any).prepend(note); + } + } +}); +</script> diff --git a/src/client/app/desktop/views/pages/deck/deck.list-tl.vue b/src/client/app/desktop/views/pages/deck/deck.list-tl.vue index 70048f99e3..e543130310 100644 --- a/src/client/app/desktop/views/pages/deck/deck.list-tl.vue +++ b/src/client/app/desktop/views/pages/deck/deck.list-tl.vue @@ -5,7 +5,6 @@ <script lang="ts"> import Vue from 'vue'; import XNotes from './deck.notes.vue'; -import { UserListStream } from '../../../../common/scripts/streaming/user-list'; const fetchLimit = 10; @@ -68,7 +67,7 @@ export default Vue.extend({ (this as any).api('notes/user-list-timeline', { listId: this.list.id, limit: fetchLimit + 1, - mediaOnly: this.mediaOnly, + withFiles: this.mediaOnly, includeMyRenotes: this.$store.state.settings.showMyRenotes, includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes, includeLocalRenotes: this.$store.state.settings.showLocalRenotes @@ -90,7 +89,7 @@ export default Vue.extend({ listId: this.list.id, limit: fetchLimit + 1, untilId: (this.$refs.timeline as any).tail().id, - mediaOnly: this.mediaOnly, + withFiles: this.mediaOnly, includeMyRenotes: this.$store.state.settings.showMyRenotes, includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes, includeLocalRenotes: this.$store.state.settings.showLocalRenotes @@ -109,7 +108,7 @@ export default Vue.extend({ return promise; }, onNote(note) { - if (this.mediaOnly && note.media.length == 0) return; + if (this.mediaOnly && note.files.length == 0) return; // Prepend a note (this.$refs.timeline as any).prepend(note); diff --git a/src/client/app/desktop/views/pages/deck/deck.mentions-column.vue b/src/client/app/desktop/views/pages/deck/deck.mentions-column.vue new file mode 100644 index 0000000000..8ec10164f2 --- /dev/null +++ b/src/client/app/desktop/views/pages/deck/deck.mentions-column.vue @@ -0,0 +1,38 @@ +<template> +<x-column :name="name" :column="column" :is-stacked="isStacked"> + <span slot="header">%fa:at%{{ name }}</span> + + <x-mentions/> +</x-column> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import XColumn from './deck.column.vue'; +import XMentions from './deck.mentions.vue'; + +export default Vue.extend({ + components: { + XColumn, + XMentions + }, + + props: { + column: { + type: Object, + required: true + }, + isStacked: { + type: Boolean, + required: true + } + }, + + computed: { + name(): string { + if (this.column.name) return this.column.name; + return '%i18n:common.deck.mentions%'; + } + }, +}); +</script> diff --git a/src/client/app/desktop/views/pages/deck/deck.mentions.vue b/src/client/app/desktop/views/pages/deck/deck.mentions.vue new file mode 100644 index 0000000000..17b572f146 --- /dev/null +++ b/src/client/app/desktop/views/pages/deck/deck.mentions.vue @@ -0,0 +1,89 @@ +<template> + <x-notes ref="timeline" :more="existMore ? more : null"/> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import XNotes from './deck.notes.vue'; + +const fetchLimit = 10; + +export default Vue.extend({ + components: { + XNotes + }, + + props: { + }, + + data() { + return { + fetching: true, + moreFetching: false, + existMore: false, + connection: null + }; + }, + + mounted() { + this.connection = (this as any).os.stream.useSharedConnection('main'); + this.connection.on('mention', this.onNote); + + this.fetch(); + }, + + beforeDestroy() { + this.connection.dispose(); + }, + + methods: { + fetch() { + this.fetching = true; + + (this.$refs.timeline as any).init(() => new Promise((res, rej) => { + (this as any).api('notes/mentions', { + limit: fetchLimit + 1, + includeMyRenotes: this.$store.state.settings.showMyRenotes, + includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes, + includeLocalRenotes: this.$store.state.settings.showLocalRenotes + }).then(notes => { + if (notes.length == fetchLimit + 1) { + notes.pop(); + this.existMore = true; + } + res(notes); + this.fetching = false; + this.$emit('loaded'); + }, rej); + })); + }, + more() { + this.moreFetching = true; + + const promise = (this as any).api('notes/mentions', { + limit: fetchLimit + 1, + untilId: (this.$refs.timeline as any).tail().id, + includeMyRenotes: this.$store.state.settings.showMyRenotes, + includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes, + includeLocalRenotes: this.$store.state.settings.showLocalRenotes + }); + + promise.then(notes => { + if (notes.length == fetchLimit + 1) { + notes.pop(); + } else { + this.existMore = false; + } + notes.forEach(n => (this.$refs.timeline as any).append(n)); + this.moreFetching = false; + }); + + return promise; + }, + onNote(note) { + // Prepend a note + (this.$refs.timeline as any).prepend(note); + } + } +}); +</script> diff --git a/src/client/app/desktop/views/pages/deck/deck.note.sub.vue b/src/client/app/desktop/views/pages/deck/deck.note.sub.vue index 3ba9ae914e..445bf7e365 100644 --- a/src/client/app/desktop/views/pages/deck/deck.note.sub.vue +++ b/src/client/app/desktop/views/pages/deck/deck.note.sub.vue @@ -29,11 +29,11 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> -root(isDark) +.fnlfosztlhtptnongximhlbykxblytcq display flex padding 16px font-size 10px - background isDark ? #21242d : #fcfcfc + background var(--subNoteBg) &.smart > .main @@ -62,16 +62,10 @@ root(isDark) > .text margin 0 padding 0 - color isDark ? #959ba7 : #717171 + color var(--subNoteText) pre max-height 120px font-size 80% -.fnlfosztlhtptnongximhlbykxblytcq[data-darkmode] - root(true) - -.fnlfosztlhtptnongximhlbykxblytcq:not([data-darkmode]) - root(false) - </style> diff --git a/src/client/app/desktop/views/pages/deck/deck.note.vue b/src/client/app/desktop/views/pages/deck/deck.note.vue index e6d062eac9..e843ac54fe 100644 --- a/src/client/app/desktop/views/pages/deck/deck.note.vue +++ b/src/client/app/desktop/views/pages/deck/deck.note.vue @@ -18,7 +18,7 @@ <div class="body"> <p v-if="p.cw != null" class="cw"> <span class="text" v-if="p.cw != ''">{{ p.cw }}</span> - <span class="toggle" @click="showContent = !showContent">{{ showContent ? '%i18n:@less%' : '%i18n:@more%' }}</span> + <mk-cw-button v-model="showContent"/> </p> <div class="content" v-show="p.cw == null || showContent"> <div class="text"> @@ -28,14 +28,15 @@ <misskey-flavored-markdown v-if="p.text" :text="p.text" :i="$store.state.i"/> <a class="rp" v-if="p.renote != null">RP:</a> </div> - <div class="media" v-if="p.media.length > 0"> - <mk-media-list :media-list="p.media"/> + <div class="files" v-if="p.files.length > 0"> + <mk-media-list :media-list="p.files"/> </div> <mk-poll v-if="p.poll" :note="p" ref="pollViewer"/> <a class="location" v-if="p.geo" :href="`https://maps.google.com/maps?q=${p.geo.coordinates[1]},${p.geo.coordinates[0]}`" target="_blank">%fa:map-marker-alt% %i18n:@location%</a> <div class="renote" v-if="p.renote"> <mk-note-preview :note="p.renote" :mini="true"/> </div> + <mk-url-preview v-for="url in urls" :url="url" :key="url" :detail="false" :mini="true"/> </div> <span class="app" v-if="p.app">via <b>{{ p.app.name }}</b></span> </div> @@ -53,11 +54,11 @@ </article> </div> <div v-else class="srwrkujossgfuhrbnvqkybtzxpblgchi"> - <div v-if="note.media.length > 0"> - <mk-media-list :media-list="note.media"/> + <div v-if="note.files.length > 0"> + <mk-media-list :media-list="note.files"/> </div> - <div v-if="note.renote && note.renote.media.length > 0"> - <mk-media-list :media-list="note.renote.media"/> + <div v-if="note.renote && note.renote.files.length > 0"> + <mk-media-list :media-list="note.renote.files"/> </div> </div> </template> @@ -69,12 +70,15 @@ import parse from '../../../../../../mfm/parse'; import MkNoteMenu from '../../../../common/views/components/note-menu.vue'; import MkReactionPicker from '../../../../common/views/components/reaction-picker.vue'; import XSub from './deck.note.sub.vue'; +import noteSubscriber from '../../../../common/scripts/note-subscriber'; export default Vue.extend({ components: { XSub }, + mixins: [noteSubscriber('note')], + props: { note: { type: Object, @@ -89,9 +93,7 @@ export default Vue.extend({ data() { return { - showContent: false, - connection: null, - connectionId: null + showContent: false }; }, @@ -99,7 +101,7 @@ export default Vue.extend({ isRenote(): boolean { return (this.note.renote && this.note.text == null && - this.note.mediaIds.length == 0 && + this.note.fileIds.length == 0 && this.note.poll == null); }, @@ -119,64 +121,7 @@ export default Vue.extend({ } }, - created() { - if (this.$store.getters.isSignedIn) { - this.connection = (this as any).os.stream.getConnection(); - this.connectionId = (this as any).os.stream.use(); - } - }, - - mounted() { - this.capture(true); - - if (this.$store.getters.isSignedIn) { - this.connection.on('_connected_', this.onStreamConnected); - } - }, - - beforeDestroy() { - this.decapture(true); - - if (this.$store.getters.isSignedIn) { - this.connection.off('_connected_', this.onStreamConnected); - (this as any).os.stream.dispose(this.connectionId); - } - }, - methods: { - capture(withHandler = false) { - if (this.$store.getters.isSignedIn) { - this.connection.send({ - type: 'capture', - id: this.p.id - }); - if (withHandler) this.connection.on('note-updated', this.onStreamNoteUpdated); - } - }, - - decapture(withHandler = false) { - if (this.$store.getters.isSignedIn) { - this.connection.send({ - type: 'decapture', - id: this.p.id - }); - if (withHandler) this.connection.off('note-updated', this.onStreamNoteUpdated); - } - }, - - onStreamConnected() { - this.capture(); - }, - - onStreamNoteUpdated(data) { - const note = data.note; - if (note.id == this.note.id) { - this.$emit('update:note', note); - } else if (note.id == this.note.renoteId) { - this.note.renote = note; - } - }, - reply() { (this as any).apis.post({ reply: this.p @@ -209,9 +154,7 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> -@import '~const.styl' - -mediaRoot(isDark) +.srwrkujossgfuhrbnvqkybtzxpblgchi font-size 13px margin 4px 12px @@ -221,9 +164,9 @@ mediaRoot(isDark) &:last-child margin-bottom 12px -root(isDark) +.zyjjkidcqjnlegkqebitfviomuqmseqk font-size 13px - border-bottom solid 1px isDark ? #1c2023 : #eaeaea + border-bottom solid 1px var(--faceDivider) &:last-of-type border-bottom none @@ -241,8 +184,8 @@ root(isDark) padding 8px 16px 0 16px line-height 28px white-space pre - color #9dbb00 - background isDark ? linear-gradient(to bottom, #314027 0%, #282c37 100%) : linear-gradient(to bottom, #edfde2 0%, #fff 100%) + color var(--renoteText) + background linear-gradient(to bottom, var(--renoteGradient) 0%, var(--face) 100%) .avatar flex-shrink 0 @@ -304,24 +247,11 @@ root(isDark) margin 0 padding 0 overflow-wrap break-word - color isDark ? #fff : #717171 + color var(--noteText) > .text margin-right 8px - > .toggle - display inline-block - padding 4px 8px - font-size 0.7em - color isDark ? #393f4f : #fff - background isDark ? #687390 : #b1b9c1 - border-radius 2px - cursor pointer - user-select none - - &:hover - background isDark ? #707b97 : #bbc4ce - > .content > .text @@ -329,7 +259,7 @@ root(isDark) margin 0 padding 0 overflow-wrap break-word - color isDark ? #fff : #717171 + color var(--noteText) >>> .title display block @@ -337,7 +267,7 @@ root(isDark) padding 4px font-size 90% text-align center - background isDark ? #2f3944 : #eef1f3 + background var(--mfmTitleBg) border-radius 4px >>> .code @@ -346,31 +276,31 @@ root(isDark) >>> .quote margin 8px padding 6px 12px - color isDark ? #6f808e : #aaa - border-left solid 3px isDark ? #637182 : #eee + color var(--mfmQuote) + border-left solid 3px var(--mfmQuoteLine) > .reply margin-right 8px - color isDark ? #99abbf : #717171 + color var(--noteText) > .rp margin-left 4px font-style oblique - color #a0bf46 + color var(--renoteText) [data-is-me]:after content "you" padding 0 4px margin-left 4px font-size 80% - color $theme-color-foreground - background $theme-color + color var(--primaryForeground) + background var(--primary) border-radius 4px .mk-url-preview margin-top 8px - > .media + > .files > img display block max-width 100% @@ -393,9 +323,9 @@ root(isDark) > .renote margin 8px 0 - > .mk-note-preview + > * padding 16px - border dashed 1px isDark ? #4e945e : #c0dac6 + border dashed 1px var(--quoteBorder) border-radius 8px > .app @@ -410,14 +340,14 @@ root(isDark) border none box-shadow none font-size 1em - color isDark ? #606984 : #ddd + color var(--noteActions) cursor pointer &:not(:last-child) margin-right 28px &:hover - color isDark ? #9198af : #666 + color var(--noteActionsHover) > .count display inline @@ -425,18 +355,6 @@ root(isDark) color #999 &.reacted - color $theme-color - -.zyjjkidcqjnlegkqebitfviomuqmseqk[data-darkmode] - root(true) - -.zyjjkidcqjnlegkqebitfviomuqmseqk:not([data-darkmode]) - root(false) - -.srwrkujossgfuhrbnvqkybtzxpblgchi[data-darkmode] - mediaRoot(true) - -.srwrkujossgfuhrbnvqkybtzxpblgchi:not([data-darkmode]) - mediaRoot(false) + color var(--primary) </style> diff --git a/src/client/app/desktop/views/pages/deck/deck.notes.vue b/src/client/app/desktop/views/pages/deck/deck.notes.vue index f7fca5de92..884be3a841 100644 --- a/src/client/app/desktop/views/pages/deck/deck.notes.vue +++ b/src/client/app/desktop/views/pages/deck/deck.notes.vue @@ -127,7 +127,7 @@ export default Vue.extend({ prepend(note, silent = false) { //#region 弾く const isMyNote = note.userId == this.$store.state.i.id; - const isPureRenote = note.renoteId != null && note.text == null && note.mediaIds.length == 0 && note.poll == null; + const isPureRenote = note.renoteId != null && note.text == null && note.fileIds.length == 0 && note.poll == null; if (this.$store.state.settings.showMyRenotes === false) { if (isMyNote && isPureRenote) { @@ -195,9 +195,7 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> -@import '~const.styl' - -root(isDark) +.eamppglmnmimdhrlzhplwpvyeaqmmhxu .transition .mk-notes-enter .mk-notes-leave-to @@ -214,9 +212,9 @@ root(isDark) line-height 32px font-size 14px text-align center - color isDark ? #666b79 : #aaa - background isDark ? #242731 : #fdfdfd - border-bottom solid 1px isDark ? #1c2023 : #eaeaea + color var(--dateDividerFg) + background var(--dateDividerBg) + border-bottom solid 1px var(--faceDivider) span margin 0 16px @@ -232,21 +230,15 @@ root(isDark) width 100% text-align center color #ccc - background isDark ? #282C37 : #fff - border-top solid 1px isDark ? #1c2023 : #eaeaea + background var(--face) + border-top solid 1px var(--faceDivider) border-bottom-left-radius 6px border-bottom-right-radius 6px &:hover - background isDark ? #2e3440 : #f5f5f5 + box-shadow 0 0 0 100px inset rgba(0, 0, 0, 0.05) &:active - background isDark ? #21242b : #eee - -.eamppglmnmimdhrlzhplwpvyeaqmmhxu[data-darkmode] - root(true) - -.eamppglmnmimdhrlzhplwpvyeaqmmhxu:not([data-darkmode]) - root(false) + box-shadow 0 0 0 100px inset rgba(0, 0, 0, 0.1) </style> diff --git a/src/client/app/desktop/views/pages/deck/deck.notification.vue b/src/client/app/desktop/views/pages/deck/deck.notification.vue index d0093ff282..149bd10293 100644 --- a/src/client/app/desktop/views/pages/deck/deck.notification.vue +++ b/src/client/app/desktop/views/pages/deck/deck.notification.vue @@ -109,7 +109,7 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> -root(isDark) +.dsfykdcjpuwfvpefwufddclpjhzktmpw > .notification padding 16px font-size 13px @@ -142,14 +142,14 @@ root(isDark) > .mk-time margin-left auto - color isDark ? #606984 : #c0c0c0 + color var(--noteHeaderInfo) font-size 0.9em > .note-preview - color isDark ? #fff : #717171 + color var(--noteText) > .note-ref - color isDark ? #fff : #717171 + color var(--noteText) [data-fa] font-size 1em @@ -170,10 +170,4 @@ root(isDark) > div > header i color #888 -.dsfykdcjpuwfvpefwufddclpjhzktmpw[data-darkmode] - root(true) - -.dsfykdcjpuwfvpefwufddclpjhzktmpw:not([data-darkmode]) - root(false) - </style> diff --git a/src/client/app/desktop/views/pages/deck/deck.notifications.vue b/src/client/app/desktop/views/pages/deck/deck.notifications.vue index fcb74b9140..29de691fe2 100644 --- a/src/client/app/desktop/views/pages/deck/deck.notifications.vue +++ b/src/client/app/desktop/views/pages/deck/deck.notifications.vue @@ -1,8 +1,7 @@ <template> <div class="oxynyeqmfvracxnglgulyqfgqxnxmehl"> <!-- トランジションを有効にするとなぜかメモリリークする --> - <!--<transition-group name="mk-notifications" class="transition notifications">--> - <div class="notifications"> + <component :is="!$store.state.device.reduceMotion ? 'transition-group' : 'div'" name="mk-notifications" class="transition notifications"> <template v-for="(notification, i) in _notifications"> <x-notification class="notification" :notification="notification" :key="notification.id"/> <p class="date" v-if="i != notifications.length - 1 && notification._date != _notifications[i + 1]._date" :key="notification.id + '-time'"> @@ -10,8 +9,7 @@ <span>%fa:angle-down%{{ _notifications[i + 1]._datetext }}</span> </p> </template> - </div> - <!--</transition-group>--> + </component> <button class="more" :class="{ fetching: fetchingMoreNotifications }" v-if="moreNotifications" @click="fetchMoreNotifications" :disabled="fetchingMoreNotifications"> <template v-if="fetchingMoreNotifications">%fa:spinner .pulse .fw%</template>{{ fetchingMoreNotifications ? '%i18n:common.loading%' : '%i18n:@more%' }} </button> @@ -40,8 +38,7 @@ export default Vue.extend({ notifications: [], queue: [], moreNotifications: false, - connection: null, - connectionId: null + connection: null }; }, @@ -64,8 +61,7 @@ export default Vue.extend({ }, mounted() { - this.connection = (this as any).os.stream.getConnection(); - this.connectionId = (this as any).os.stream.use(); + this.connection = (this as any).os.stream.useSharedConnection('main'); this.connection.on('notification', this.onNotification); @@ -88,8 +84,7 @@ export default Vue.extend({ }, beforeDestroy() { - this.connection.off('notification', this.onNotification); - (this as any).os.stream.dispose(this.connectionId); + this.connection.dispose(); this.column.$off('top', this.onTop); this.column.$off('bottom', this.onBottom); @@ -119,7 +114,7 @@ export default Vue.extend({ onNotification(notification) { // TODO: ユーザーが画面を見てないと思われるとき(ブラウザやタブがアクティブじゃないなど)は送信しない this.connection.send({ - type: 'read_notification', + type: 'readNotification', id: notification.id }); @@ -157,8 +152,7 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> -root(isDark) - +.oxynyeqmfvracxnglgulyqfgqxnxmehl .transition .mk-notifications-enter .mk-notifications-leave-to @@ -171,7 +165,7 @@ root(isDark) > .notifications > .notification:not(:last-child) - border-bottom solid 1px isDark ? #1c2023 : #eaeaea + border-bottom solid 1px var(--faceDivider) > .date display block @@ -179,9 +173,9 @@ root(isDark) line-height 32px text-align center font-size 0.8em - color isDark ? #666b79 : #aaa - background isDark ? #242731 : #fdfdfd - border-bottom solid 1px isDark ? #1c2023 : #eaeaea + color var(--dateDividerFg) + background var(--dateDividerBg) + border-bottom solid 1px var(--faceDivider) span margin 0 16px @@ -223,10 +217,4 @@ root(isDark) > [data-fa] margin-right 4px -.oxynyeqmfvracxnglgulyqfgqxnxmehl[data-darkmode] - root(true) - -.oxynyeqmfvracxnglgulyqfgqxnxmehl:not([data-darkmode]) - root(false) - </style> diff --git a/src/client/app/desktop/views/pages/deck/deck.tl-column.vue b/src/client/app/desktop/views/pages/deck/deck.tl-column.vue index 231b505f5d..d245e3ecf5 100644 --- a/src/client/app/desktop/views/pages/deck/deck.tl-column.vue +++ b/src/client/app/desktop/views/pages/deck/deck.tl-column.vue @@ -6,14 +6,16 @@ <template v-if="column.type == 'hybrid'">%fa:share-alt%</template> <template v-if="column.type == 'global'">%fa:globe%</template> <template v-if="column.type == 'list'">%fa:list%</template> + <template v-if="column.type == 'hashtag'">%fa:hashtag%</template> <span>{{ name }}</span> </span> <div class="editor" style="padding:0 12px" v-if="edit"> - <mk-switch v-model="column.isMediaOnly" @change="onChangeSettings" text="%i18n:@is-media-only%"/> - <mk-switch v-model="column.isMediaView" @change="onChangeSettings" text="%i18n:@is-media-view%"/> + <ui-switch v-model="column.isMediaOnly" @change="onChangeSettings">%i18n:@is-media-only%</ui-switch> + <ui-switch v-model="column.isMediaView" @change="onChangeSettings">%i18n:@is-media-view%</ui-switch> </div> <x-list-tl v-if="column.type == 'list'" :list="column.list" :media-only="column.isMediaOnly" :media-view="column.isMediaView"/> + <x-hashtag-tl v-if="column.type == 'hashtag'" :tag-tl="$store.state.settings.tagTimelines.find(x => x.id == column.tagTlId)" :media-only="column.isMediaOnly" :media-view="column.isMediaView"/> <x-tl v-else :src="column.type" :media-only="column.isMediaOnly" :media-view="column.isMediaView"/> </x-column> </template> @@ -23,12 +25,14 @@ import Vue from 'vue'; import XColumn from './deck.column.vue'; import XTl from './deck.tl.vue'; import XListTl from './deck.list-tl.vue'; +import XHashtagTl from './deck.hashtag-tl.vue'; export default Vue.extend({ components: { XColumn, XTl, - XListTl + XListTl, + XHashtagTl }, props: { @@ -65,6 +69,7 @@ export default Vue.extend({ case 'hybrid': return '%i18n:common.deck.hybrid%'; case 'global': return '%i18n:common.deck.global%'; case 'list': return this.column.list.title; + case 'hashtag': return this.$store.state.settings.tagTimelines.find(x => x.id == this.column.tagTlId).title; } } }, diff --git a/src/client/app/desktop/views/pages/deck/deck.tl.vue b/src/client/app/desktop/views/pages/deck/deck.tl.vue index a9e4d489c3..8aed80fa1b 100644 --- a/src/client/app/desktop/views/pages/deck/deck.tl.vue +++ b/src/client/app/desktop/views/pages/deck/deck.tl.vue @@ -36,18 +36,17 @@ export default Vue.extend({ fetching: true, moreFetching: false, existMore: false, - connection: null, - connectionId: null + connection: null }; }, computed: { stream(): any { switch (this.src) { - case 'home': return (this as any).os.stream; - case 'local': return (this as any).os.streams.localTimelineStream; - case 'hybrid': return (this as any).os.streams.hybridTimelineStream; - case 'global': return (this as any).os.streams.globalTimelineStream; + case 'home': return (this as any).os.stream.useSharedConnection('homeTimeline'); + case 'local': return (this as any).os.stream.useSharedConnection('localTimeline'); + case 'hybrid': return (this as any).os.stream.useSharedConnection('hybridTimeline'); + case 'global': return (this as any).os.stream.useSharedConnection('globalTimeline'); } }, @@ -68,8 +67,7 @@ export default Vue.extend({ }, mounted() { - this.connection = this.stream.getConnection(); - this.connectionId = this.stream.use(); + this.connection = this.stream; this.connection.on('note', this.onNote); if (this.src == 'home') { @@ -81,12 +79,7 @@ export default Vue.extend({ }, 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); + this.connection.dispose(); }, methods: { @@ -96,7 +89,7 @@ export default Vue.extend({ (this.$refs.timeline as any).init(() => new Promise((res, rej) => { (this as any).api(this.endpoint, { limit: fetchLimit + 1, - mediaOnly: this.mediaOnly, + withFiles: this.mediaOnly, includeMyRenotes: this.$store.state.settings.showMyRenotes, includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes, includeLocalRenotes: this.$store.state.settings.showLocalRenotes @@ -117,7 +110,7 @@ export default Vue.extend({ const promise = (this as any).api(this.endpoint, { limit: fetchLimit + 1, - mediaOnly: this.mediaOnly, + withFiles: this.mediaOnly, untilId: (this.$refs.timeline as any).tail().id, includeMyRenotes: this.$store.state.settings.showMyRenotes, includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes, @@ -138,7 +131,7 @@ export default Vue.extend({ }, onNote(note) { - if (this.mediaOnly && note.media.length == 0) return; + if (this.mediaOnly && note.files.length == 0) return; // Prepend a note (this.$refs.timeline as any).prepend(note); diff --git a/src/client/app/desktop/views/pages/deck/deck.vue b/src/client/app/desktop/views/pages/deck/deck.vue index 26b989656e..22b4c50bb4 100644 --- a/src/client/app/desktop/views/pages/deck/deck.vue +++ b/src/client/app/desktop/views/pages/deck/deck.vue @@ -1,6 +1,6 @@ <template> <mk-ui :class="$style.root"> - <div class="qlvquzbjribqcaozciifydkngcwtyzje" :data-darkmode="$store.state.device.darkmode"> + <div class="qlvquzbjribqcaozciifydkngcwtyzje" :style="style"> <template v-for="ids in layout"> <div v-if="ids.length > 1" class="folder"> <template v-for="id, i in ids"> @@ -35,6 +35,11 @@ export default Vue.extend({ if (this.$store.state.settings.deck == null) return []; if (this.$store.state.settings.deck.layout == null) return this.$store.state.settings.deck.columns.map(c => [c.id]); return this.$store.state.settings.deck.layout; + }, + style(): any { + return { + height: `calc(100vh - ${this.$store.state.uiHeaderHeight}px)` + }; } }, @@ -85,6 +90,7 @@ export default Vue.extend({ }, mounted() { + document.title = (this as any).os.instanceName; document.documentElement.style.overflow = 'hidden'; }, @@ -138,6 +144,24 @@ export default Vue.extend({ }); } }, { + icon: '%fa:at%', + text: '%i18n:common.deck.mentions%', + action: () => { + this.$store.dispatch('settings/addDeckColumn', { + id: uuid(), + type: 'mentions' + }); + } + }, { + icon: '%fa:envelope R%', + text: '%i18n:common.deck.direct%', + action: () => { + this.$store.dispatch('settings/addDeckColumn', { + id: uuid(), + type: 'direct' + }); + } + }, { icon: '%fa:list%', text: '%i18n:common.deck.list%', action: () => { @@ -152,6 +176,20 @@ export default Vue.extend({ }); } }, { + icon: '%fa:hashtag%', + text: '%i18n:common.deck.hashtag%', + action: () => { + (this as any).apis.input({ + title: '%i18n:@enter-hashtag-tl-title%' + }).then(title => { + this.$store.dispatch('settings/addDeckColumn', { + id: uuid(), + type: 'hashtag', + tagTlId: this.$store.state.settings.tagTimelines.find(x => x.title == title).id + }); + }); + } + }, { icon: '%fa:bell R%', text: '%i18n:common.deck.notifications%', action: () => { @@ -183,9 +221,7 @@ export default Vue.extend({ </style> <style lang="stylus" scoped> -@import '~const.styl' - -root(isDark) +.qlvquzbjribqcaozciifydkngcwtyzje display flex flex 1 padding 16px 0 16px 16px @@ -213,18 +249,12 @@ root(isDark) > button padding 0 16px - color isDark ? #93a0a5 : #888 + color var(--faceTextButton) &:hover - color isDark ? #b8c5ca : #777 + color var(--faceTextButtonHover) &:active - color isDark ? #fff : #555 - -.qlvquzbjribqcaozciifydkngcwtyzje[data-darkmode] - root(true) - -.qlvquzbjribqcaozciifydkngcwtyzje:not([data-darkmode]) - root(false) + color var(--faceTextButtonActive) </style> diff --git a/src/client/app/desktop/views/pages/deck/deck.widgets-column.vue b/src/client/app/desktop/views/pages/deck/deck.widgets-column.vue index 15397232e0..e1fecc98bc 100644 --- a/src/client/app/desktop/views/pages/deck/deck.widgets-column.vue +++ b/src/client/app/desktop/views/pages/deck/deck.widgets-column.vue @@ -135,9 +135,7 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> -@import '~const.styl' - -root(isDark) +.wtdtxvecapixsepjtcupubtsmometobz .gqpwvtwtprsbmnssnbicggtwqhmylhnq > header padding 16px @@ -169,14 +167,5 @@ root(isDark) background rgba(#000, 0.7) border-radius 4px - > header - color isDark ? #fff : #000 - -.wtdtxvecapixsepjtcupubtsmometobz[data-darkmode] - root(true) - -.wtdtxvecapixsepjtcupubtsmometobz:not([data-darkmode]) - root(false) - </style> diff --git a/src/client/app/desktop/views/pages/drive.vue b/src/client/app/desktop/views/pages/drive.vue index 217dcb7751..dec6c4551a 100644 --- a/src/client/app/desktop/views/pages/drive.vue +++ b/src/client/app/desktop/views/pages/drive.vue @@ -31,7 +31,7 @@ export default Vue.extend({ const title = folder.name + ' | %i18n:@title%'; // Rewrite URL - history.pushState(null, title, '/i/drive/folder/' + folder.id); + history.pushState(null, title, `/i/drive/folder/${folder.id}`); document.title = title; } diff --git a/src/client/app/desktop/views/pages/games/reversi.vue b/src/client/app/desktop/views/pages/games/reversi.vue index ce9b42c65f..1b0e790a22 100644 --- a/src/client/app/desktop/views/pages/games/reversi.vue +++ b/src/client/app/desktop/views/pages/games/reversi.vue @@ -16,10 +16,10 @@ export default Vue.extend({ methods: { nav(game, actualNav) { if (actualNav) { - this.$router.push('/reversi/' + game.id); + this.$router.push(`/reversi/${game.id}`); } else { // TODO: https://github.com/vuejs/vue-router/issues/703 - this.$router.push('/reversi/' + game.id); + this.$router.push(`/reversi/${game.id}`); } } } diff --git a/src/client/app/desktop/views/pages/home.vue b/src/client/app/desktop/views/pages/home.vue index c7ff0904e0..e595ef4c36 100644 --- a/src/client/app/desktop/views/pages/home.vue +++ b/src/client/app/desktop/views/pages/home.vue @@ -1,6 +1,6 @@ <template> <mk-ui> - <mk-home :mode="mode" @loaded="loaded"/> + <mk-home :mode="mode" @loaded="loaded" ref="home" v-hotkey.global="keymap"/> </mk-ui> </template> @@ -15,6 +15,13 @@ export default Vue.extend({ default: 'timeline' } }, + computed: { + keymap(): any { + return { + 't': this.focus + }; + } + }, mounted() { document.title = (this as any).os.instanceName; @@ -23,6 +30,9 @@ export default Vue.extend({ methods: { loaded() { Progress.done(); + }, + focus() { + this.$refs.home.focus(); } } }); diff --git a/src/client/app/desktop/views/pages/messaging-room.vue b/src/client/app/desktop/views/pages/messaging-room.vue index 1ebd53cef4..4be33dda04 100644 --- a/src/client/app/desktop/views/pages/messaging-room.vue +++ b/src/client/app/desktop/views/pages/messaging-room.vue @@ -46,7 +46,7 @@ export default Vue.extend({ this.user = user; this.fetching = false; - document.title = 'メッセージ: ' + getUserName(this.user); + document.title = `メッセージ: ${getUserName(this.user)}`; Progress.done(); }); diff --git a/src/client/app/desktop/views/pages/selectdrive.vue b/src/client/app/desktop/views/pages/selectdrive.vue index c846f2418f..b82ed0a208 100644 --- a/src/client/app/desktop/views/pages/selectdrive.vue +++ b/src/client/app/desktop/views/pages/selectdrive.vue @@ -54,7 +54,7 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> -@import '~const.styl' + .mkp-selectdrive display block @@ -72,7 +72,7 @@ export default Vue.extend({ left 0 width 100% height 72px - background lighten($theme-color, 95%) + background var(--primaryLighten95) .upload display inline-block @@ -85,7 +85,7 @@ export default Vue.extend({ width 40px height 40px font-size 1em - color rgba($theme-color, 0.5) + color var(--primaryAlpha05) background transparent outline none border solid 1px transparent @@ -93,13 +93,13 @@ export default Vue.extend({ &:hover background transparent - border-color rgba($theme-color, 0.3) + border-color var(--primaryAlpha03) &:active - color rgba($theme-color, 0.6) + color var(--primaryAlpha06) background transparent - border-color rgba($theme-color, 0.5) - box-shadow 0 2px 4px rgba(darken($theme-color, 50%), 0.15) inset + border-color var(--primaryAlpha05) + //box-shadow 0 2px 4px rgba(var(--primaryDarken50), 0.15) inset &:focus &:after @@ -110,7 +110,7 @@ export default Vue.extend({ right -5px bottom -5px left -5px - border 2px solid rgba($theme-color, 0.3) + border 2px solid var(--primaryAlpha03) border-radius 8px .ok @@ -136,7 +136,7 @@ export default Vue.extend({ right -5px bottom -5px left -5px - border 2px solid rgba($theme-color, 0.3) + border 2px solid var(--primaryAlpha03) border-radius 8px &:disabled @@ -145,20 +145,20 @@ export default Vue.extend({ .ok right 16px - 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%) + color var(--primaryForeground) + background linear-gradient(to bottom, var(--primaryLighten25) 0%, var(--primaryLighten10) 100%) + border solid 1px var(--primaryLighten15) &:not(:disabled) font-weight bold &:hover:not(:disabled) - background linear-gradient(to bottom, lighten($theme-color, 8%) 0%, darken($theme-color, 8%) 100%) - border-color $theme-color + background linear-gradient(to bottom, var(--primaryLighten8) 0%, var(--primaryDarken8) 100%) + border-color var(--primary) &:active:not(:disabled) - background $theme-color - border-color $theme-color + background var(--primary) + border-color var(--primary) .cancel right 148px diff --git a/src/client/app/desktop/views/pages/stats/stats.vue b/src/client/app/desktop/views/pages/stats/stats.vue index 41005b6398..219885fb9e 100644 --- a/src/client/app/desktop/views/pages/stats/stats.vue +++ b/src/client/app/desktop/views/pages/stats/stats.vue @@ -34,7 +34,7 @@ export default Vue.extend({ </script> <style lang="stylus"> -@import '~const.styl' + .tcrwdhwpuxrwmcttxjcsehgpagpstqey width 100% @@ -43,7 +43,7 @@ export default Vue.extend({ > .stats display flex justify-content center - margin-bottom 16px + margin 0 auto 16px auto padding 32px background #fff box-shadow 0 2px 8px rgba(#000, 0.1) @@ -54,11 +54,12 @@ export default Vue.extend({ > *:first-child display block - color $theme-color + color var(--primary) > *:last-child font-size 70% > div - max-width 850px + max-width 950px + margin 0 auto </style> diff --git a/src/client/app/desktop/views/pages/user-list.users.vue b/src/client/app/desktop/views/pages/user-list.users.vue deleted file mode 100644 index 7d9a4606a1..0000000000 --- a/src/client/app/desktop/views/pages/user-list.users.vue +++ /dev/null @@ -1,125 +0,0 @@ -<template> -<div> - <mk-widget-container> - <template slot="header">%fa:users% %i18n:@users%</template> - <button slot="func" title="%i18n:@add-user%" @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: '%i18n:@username%', - }).then(async (username: string) => { - if (username.startsWith('@')) username = username.slice(1); - 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 deleted file mode 100644 index 2241b84e5e..0000000000 --- a/src/client/app/desktop/views/pages/user-list.vue +++ /dev/null @@ -1,71 +0,0 @@ -<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 e4a771910a..cf05006c00 100644 --- a/src/client/app/desktop/views/pages/user/user.followers-you-know.vue +++ b/src/client/app/desktop/views/pages/user/user.followers-you-know.vue @@ -1,5 +1,5 @@ <template> -<div class="followers-you-know"> +<div class="vahgrswmbzfdlmomxnqftuueyvwaafth"> <p class="title">%fa:users%%i18n:@title%</p> <p class="initializing" v-if="fetching">%fa:spinner .pulse .fw%%i18n:@loading%<mk-ellipsis/></p> <div v-if="!fetching && users.length > 0"> @@ -36,10 +36,10 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> -.followers-you-know - background #fff - border solid 1px rgba(#000, 0.075) - border-radius 6px +.vahgrswmbzfdlmomxnqftuueyvwaafth + background var(--face) + box-shadow var(--shadow) + border-radius var(--round) > .title z-index 1 @@ -48,7 +48,7 @@ export default Vue.extend({ line-height 42px font-size 0.9em font-weight bold - color #888 + color var(--faceHeaderText) box-shadow 0 1px rgba(#000, 0.07) > i diff --git a/src/client/app/desktop/views/pages/user/user.friends.vue b/src/client/app/desktop/views/pages/user/user.friends.vue index 516eea0288..36ae360248 100644 --- a/src/client/app/desktop/views/pages/user/user.friends.vue +++ b/src/client/app/desktop/views/pages/user/user.friends.vue @@ -1,5 +1,5 @@ <template> -<div class="friends"> +<div class="hozptpaliadatkehcmcayizwzwwctpbc"> <p class="title">%fa:users%%i18n:@title%</p> <p class="initializing" v-if="fetching">%fa:spinner .pulse .fw%%i18n:@loading%<mk-ellipsis/></p> <template v-if="!fetching && users.length != 0"> @@ -40,11 +40,10 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> -root(isDark) -.friends - background isDark ? #282C37 : #fff - border solid 1px rgba(#000, 0.075) - border-radius 6px +.hozptpaliadatkehcmcayizwzwwctpbc + background var(--face) + box-shadow var(--shadow) + border-radius var(--round) overflow hidden > .title @@ -54,8 +53,8 @@ root(isDark) line-height 42px font-size 0.9em font-weight bold - background isDark ? #313543 : inherit - color isDark ? #e3e5e8 : #888 + background var(--faceHeader) + color var(--faceHeaderText) box-shadow 0 1px rgba(#000, 0.07) > i @@ -73,7 +72,7 @@ root(isDark) > .user padding 16px - border-bottom solid 1px isDark ? #21242f : #eee + border-bottom solid 1px var(--faceDivider) &:last-child border-bottom none @@ -99,24 +98,19 @@ root(isDark) margin 0 font-size 16px line-height 24px - color isDark ? #ccc : #555 + color var(--text) > .username display block margin 0 font-size 15px line-height 16px - color isDark ? #555 : #ccc + color var(--text) + opacity 0.7 > .mk-follow-button position absolute top 16px right 16px -.friends[data-darkmode] - root(true) - -.friends:not([data-darkmode]) - root(false) - </style> diff --git a/src/client/app/desktop/views/pages/user/user.header.vue b/src/client/app/desktop/views/pages/user/user.header.vue index d8f4656ed0..76eb8f9e1c 100644 --- a/src/client/app/desktop/views/pages/user/user.header.vue +++ b/src/client/app/desktop/views/pages/user/user.header.vue @@ -6,7 +6,7 @@ <div class="title"> <p class="name">{{ user | userName }}</p> <div> - <span class="username"><mk-acct :user="user"/></span> + <span class="username"><mk-acct :user="user" :detail="true" /></span> <span v-if="user.isBot" title="%i18n:@is-bot%">%fa:robot%</span> <span class="location" v-if="user.host === null && user.profile.location">%fa:map-marker% {{ user.profile.location }}</span> <span class="birthday" v-if="user.host === null && user.profile.birthday">%fa:birthday-cake% {{ user.profile.birthday.replace('-', '年').replace('-', '月') + '日' }} ({{ age }}歳)</span> @@ -100,12 +100,10 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> -@import '~const.styl' - -root(isDark) - background isDark ? #282C37 : #fff - border 1px solid rgba(#000, 0.075) - border-radius 6px +.header + background var(--face) + box-shadow var(--shadow) + border-radius var(--round) overflow hidden &[data-is-dark-background] @@ -182,12 +180,12 @@ root(isDark) > .body padding 16px 16px 16px 154px - color isDark ? #c5ced6 : #555 + color var(--text) > .status margin-top 16px padding-top 16px - border-top solid 1px rgba(#000, isDark ? 0.2 : 0.1) + border-top solid 1px var(--faceDivider) font-size 80% > * @@ -196,24 +194,18 @@ root(isDark) margin-right 16px &:not(:last-child) - border-right solid 1px rgba(#000, isDark ? 0.2 : 0.1) + border-right solid 1px var(--faceDivider) &.clickable cursor pointer &:hover - color isDark ? #fff : #000 + color var(--faceTextButtonHover) > b margin-right 4px font-size 1rem font-weight bold - color $theme-color - -.header[data-darkmode] - root(true) - -.header:not([data-darkmode]) - root(false) + color var(--primary) </style> diff --git a/src/client/app/desktop/views/pages/user/user.photos.vue b/src/client/app/desktop/views/pages/user/user.photos.vue index 8397e56484..628d5b6d95 100644 --- a/src/client/app/desktop/views/pages/user/user.photos.vue +++ b/src/client/app/desktop/views/pages/user/user.photos.vue @@ -1,10 +1,10 @@ <template> -<div class="photos"> +<div class="dzsuvbsrrrwobdxifudxuefculdfiaxd"> <p class="title">%fa:camera%%i18n:@title%</p> <p class="initializing" v-if="fetching">%fa:spinner .pulse .fw%%i18n:@loading%<mk-ellipsis/></p> <div class="stream" v-if="!fetching && images.length > 0"> <div v-for="image in images" class="img" - :style="`background-image: url(${image.url})`" + :style="`background-image: url(${image.thumbnailUrl})`" ></div> </div> <p class="empty" v-if="!fetching && images.length == 0">%i18n:@no-photos%</p> @@ -24,12 +24,12 @@ export default Vue.extend({ mounted() { (this as any).api('users/notes', { userId: this.user.id, - withMedia: true, + withFiles: true, limit: 9 }).then(notes => { notes.forEach(note => { - note.media.forEach(media => { - if (this.images.length < 9) this.images.push(media); + note.files.forEach(file => { + if (this.images.length < 9) this.images.push(file); }); }); this.fetching = false; @@ -39,11 +39,10 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> -root(isDark) -.photos - background isDark ? #282C37 : #fff - border solid 1px rgba(#000, 0.075) - border-radius 6px +.dzsuvbsrrrwobdxifudxuefculdfiaxd + background var(--face) + box-shadow var(--shadow) + border-radius var(--round) overflow hidden > .title @@ -53,8 +52,8 @@ root(isDark) line-height 42px font-size 0.9em font-weight bold - background: isDark ? #313543 : inherit - color isDark ? #e3e5e8 : #888 + background var(--faceHeader) + color var(--faceHeaderText) box-shadow 0 1px rgba(#000, 0.07) > i @@ -88,10 +87,4 @@ root(isDark) > i margin-right 4px -.photos[data-darkmode] - root(true) - -.photos:not([data-darkmode]) - root(false) - </style> 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 efd5be4672..fe10b54378 100644 --- a/src/client/app/desktop/views/pages/user/user.profile.vue +++ b/src/client/app/desktop/views/pages/user/user.profile.vue @@ -85,10 +85,10 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> -root(isDark) - background isDark ? #282C37 : #fff - border solid 1px rgba(#000, 0.075) - border-radius 6px +.profile + background var(--face) + box-shadow var(--shadow) + border-radius var(--round) > *:first-child border-top none !important @@ -96,7 +96,7 @@ root(isDark) > .friend-form padding 16px text-align center - border-bottom solid 1px isDark ? #21242f : #eee + border-bottom solid 1px var(--faceDivider) > .followed margin 12px 0 0 0 @@ -114,7 +114,7 @@ root(isDark) > .action-form padding 16px text-align center - border-bottom solid 1px isDark ? #21242f : #eee + border-bottom solid 1px var(--faceDivider) > * width 100% @@ -122,10 +122,4 @@ root(isDark) &:not(:last-child) margin-bottom 12px -.profile[data-darkmode] - root(true) - -.profile:not([data-darkmode]) - root(false) - </style> diff --git a/src/client/app/desktop/views/pages/user/user.timeline.vue b/src/client/app/desktop/views/pages/user/user.timeline.vue index 67987fcb94..608c12b7e2 100644 --- a/src/client/app/desktop/views/pages/user/user.timeline.vue +++ b/src/client/app/desktop/views/pages/user/user.timeline.vue @@ -66,7 +66,7 @@ export default Vue.extend({ limit: fetchLimit + 1, untilDate: this.date ? this.date.getTime() : undefined, includeReplies: this.mode == 'with-replies', - withMedia: this.mode == 'with-media' + withFiles: this.mode == 'with-media' }).then(notes => { if (notes.length == fetchLimit + 1) { notes.pop(); @@ -86,7 +86,7 @@ export default Vue.extend({ userId: this.user.id, limit: fetchLimit + 1, includeReplies: this.mode == 'with-replies', - withMedia: this.mode == 'with-media', + withFiles: this.mode == 'with-media', untilId: (this.$refs.timeline as any).tail().id }); @@ -112,17 +112,16 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> -@import '~const.styl' - -root(isDark) - background isDark ? #282C37 : #fff +.oh5y2r7l5lx8j6jj791ykeiwgihheguk + background var(--face) + border-radius var(--round) + overflow hidden > header 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) + background var(--faceHeader) + box-shadow 0 1px var(--desktopTimelineHeaderShadow) > span display inline-block @@ -132,7 +131,7 @@ root(isDark) user-select none &[data-active] - color $theme-color + color var(--primary) cursor default font-weight bold @@ -144,14 +143,14 @@ root(isDark) left -8px width calc(100% + 16px) height 2px - background $theme-color + background var(--primary) &:not([data-active]) - color isDark ? #9aa2a7 : #6f7477 + color var(--desktopTimelineSrc) cursor pointer &:hover - color isDark ? #d9dcde : #525a5f + color var(--desktopTimelineSrcHover) > .loading padding 64px 0 @@ -170,10 +169,4 @@ root(isDark) font-size 3em color #ccc -.oh5y2r7l5lx8j6jj791ykeiwgihheguk[data-darkmode] - root(true) - -.oh5y2r7l5lx8j6jj791ykeiwgihheguk:not([data-darkmode]) - root(false) - </style> diff --git a/src/client/app/desktop/views/pages/user/user.vue b/src/client/app/desktop/views/pages/user/user.vue index afb5e674d9..a8da890936 100644 --- a/src/client/app/desktop/views/pages/user/user.vue +++ b/src/client/app/desktop/views/pages/user/user.vue @@ -1,15 +1,16 @@ <template> <mk-ui> - <div class="xygkxeaeontfaokvqmiblezmhvhostak" v-if="!fetching" :data-darkmode="$store.state.device.darkmode"> + <div class="xygkxeaeontfaokvqmiblezmhvhostak" v-if="!fetching"> <div class="is-suspended" v-if="user.isSuspended">%fa:exclamation-triangle% %i18n:@is-suspended%</div> <div class="is-remote" v-if="user.host != null">%fa:exclamation-triangle% %i18n:@is-remote%<a :href="user.url || user.uri" target="_blank">%i18n:@view-remote%</a></div> <main> <div class="main"> <x-header :user="user"/> - <mk-note-detail v-if="user.pinnedNote" :note="user.pinnedNote" :compact="true"/> + <mk-note-detail v-for="n in user.pinnedNotes" :key="n.id" :note="n" :compact="true"/> <x-timeline class="timeline" ref="tl" :user="user"/> </div> <div class="side"> + <div class="instance" v-if="!$store.getters.isSignedIn"><mk-instance/></div> <x-profile :user="user"/> <x-twitter :user="user" v-if="user.host === null && user.twitter"/> <mk-calendar @chosen="warp" :start="new Date(user.createdAt)"/> @@ -28,7 +29,6 @@ <script lang="ts"> import Vue from 'vue'; import parseAcct from '../../../../../../misc/acct/parse'; -import getUserName from '../../../../../../misc/get-user-name'; import Progress from '../../../../common/scripts/loading'; import XHeader from './user.header.vue'; import XTimeline from './user.timeline.vue'; @@ -79,7 +79,7 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> -root(isDark) +.xygkxeaeontfaokvqmiblezmhvhostak width 980px padding 16px margin 0 auto @@ -89,17 +89,16 @@ root(isDark) margin-bottom 16px padding 14px 16px font-size 14px - border-radius 6px + box-shadow var(--shadow) + border-radius var(--round) &.is-suspended - color isDark ? #ffb4b4 : #570808 - background isDark ? #611d1d : #ffdbdb - border solid 1px isDark ? #d64a4a : #e09696 + color var(--suspendedInfoFg) + background var(--suspendedInfoBg) &.is-remote - color isDark ? #ffbd3e : #573c08 - background isDark ? #42321c : #fff0db - border solid 1px isDark ? #90733c : #dcbb7b + color var(--remoteInfoFg) + background var(--remoteInfoBg) > a font-weight bold @@ -119,8 +118,7 @@ root(isDark) margin-right 16px > .timeline - border 1px solid rgba(#000, 0.075) - border-radius 6px + box-shadow var(--shadow) > .side width 275px @@ -134,24 +132,22 @@ root(isDark) font-size 0.8em color #aaa + > .instance + box-shadow var(--shadow) + border-radius var(--round) + > .nav padding 16px font-size 12px - color #aaa - background isDark ? #21242f : #fff - border solid 1px rgba(#000, 0.075) - border-radius 6px + color var(--text) + background var(--face) + box-shadow var(--shadow) + border-radius var(--round) a - color #999 + color var(--text)99 i - color #ccc - -.xygkxeaeontfaokvqmiblezmhvhostak[data-darkmode] - root(true) - -.xygkxeaeontfaokvqmiblezmhvhostak:not([data-darkmode]) - root(false) + color var(--text) </style> diff --git a/src/client/app/desktop/views/pages/welcome.vue b/src/client/app/desktop/views/pages/welcome.vue index ac2f921a21..65651f7ffc 100644 --- a/src/client/app/desktop/views/pages/welcome.vue +++ b/src/client/app/desktop/views/pages/welcome.vue @@ -1,45 +1,147 @@ <template> <div class="mk-welcome"> - <img ref="pointer" class="pointer" src="/assets/pointer.png" alt=""> + <div class="banner" :style="{ backgroundImage: banner ? `url(${banner})` : null }"></div> + <button @click="dark"> <template v-if="$store.state.device.darkmode">%fa:moon%</template> <template v-else>%fa:R moon%</template> </button> - <div class="body"> - <div class="container"> - <div class="info"> - <span><b>{{ host }}</b></span> - <span class="stats" v-if="stats"> - <span>%fa:user% {{ stats.originalUsersCount | number }}</span> - <span>%fa:pencil-alt% {{ stats.originalNotesCount | number }}</span> - </span> - </div> - <main> - <div class="about"> + + <mk-forkit class="forkit"/> + + <main> + <div class="body"> + <div class="main block"> + <div> <h1 v-if="name != 'Misskey'">{{ name }}</h1> - <h1 v-else><img :src="$store.state.device.darkmode ? 'assets/title.dark.svg' : 'assets/title.light.svg'" :alt="name"></h1> - <p class="powerd-by" v-if="name != 'Misskey'" v-html="'%i18n:@powered-by-misskey%'"></p> - <p class="desc" v-html="description || '%i18n:common.about%'"></p> - <a ref="signup" @click="signup">📦 %i18n:@signup%</a> + <h1 v-else><img svg-inline src="../../../../assets/title.svg" :alt="name"></h1> + + <div class="info"> + <span><b>{{ host }}</b> - <span v-html="'%i18n:@powered-by-misskey%'"></span></span> + <span class="stats" v-if="stats"> + <span>%fa:user% {{ stats.originalUsersCount | number }}</span> + <span>%fa:pencil-alt% {{ stats.originalNotesCount | number }}</span> + </span> + </div> + + <div class="desc"> + <span class="desc" v-html="description || '%i18n:common.about%'"></span> + <a class="about" @click="about">%i18n:@about%</a> + </div> + + <p class="sign"> + <span class="signup" @click="signup">%i18n:@signup%</span> + <span class="divider">|</span> + <span class="signin" @click="signin">%i18n:@signin%</span> + </p> + + <img src="/assets/ai.png" alt="" title="藍" class="char"> </div> - <div class="login"> - <mk-signin/> + </div> + + <div class="announcements block"> + <header>%fa:broadcast-tower% %i18n:@announcements%</header> + <div v-if="announcements && announcements.length > 0"> + <div v-for="announcement in announcements"> + <h1 v-html="announcement.title"></h1> + <div v-html="announcement.text"></div> + </div> + </div> + </div> + + <div class="photos block"> + <header>%fa:images% %i18n:@photos%</header> + <div> + <div v-for="photo in photos" :style="`background-image: url(${photo.thumbnailUrl})`"></div> + </div> + </div> + + <div class="tag-cloud block"> + <div> + <mk-tag-cloud/> + </div> + </div> + + <div class="nav block"> + <div> + <mk-nav class="nav"/> + </div> + </div> + + <div class="side"> + <div class="trends block"> + <div> + <mk-trends/> + </div> + </div> + + <div class="tl block"> + <header>%fa:comment-alt R% %i18n:@timeline%</header> + <div> + <mk-welcome-timeline class="tl" :max="20"/> + </div> + </div> + + <div class="info block"> + <header>%fa:info-circle% %i18n:@info%</header> + <div> + <div v-if="meta" class="body"> + <p>Version: <b>{{ meta.version }}</b></p> + <p>Maintainer: <b><a :href="meta.maintainer.url" target="_blank">{{ meta.maintainer.name }}</a></b></p> + </div> + </div> </div> - </main> - <div class="hashtags"> - <router-link v-for="tag in tags" :key="tag" :to="`/tags/${ tag }`" :title="tag">#{{ tag }}</router-link> </div> - <mk-nav class="nav"/> </div> - <mk-forkit class="forkit"/> - <img src="assets/title.dark.svg" :alt="name"> - </div> - <div class="tl"> - <mk-welcome-timeline :max="20"/> - </div> - <modal name="signup" width="500px" height="auto" scrollable> - <header :class="$style.signupFormHeader">%i18n:@signup%</header> - <mk-signup :class="$style.signupForm"/> + </main> + + <modal name="about" class="about modal" width="800px" height="auto" scrollable> + <article class="fpdezooorhntlzyeszemrsqdlgbysvxq"> + <h1>%i18n:common.intro.title%</h1> + <p v-html="'%i18n:common.intro.about%'"></p> + <section> + <h2>%i18n:common.intro.features%</h2> + <section> + <div class="body"> + <h3>%i18n:common.intro.rich-contents%</h3> + <p v-html="'%i18n:common.intro.rich-contents-desc%'"></p> + </div> + <div class="image"><img src="/assets/about/post.png" alt=""></div> + </section> + <section> + <div class="body"> + <h3>%i18n:common.intro.reaction%</h3> + <p v-html="'%i18n:common.intro.reaction-desc%'"></p> + </div> + <div class="image"><img src="/assets/about/reaction.png" alt=""></div> + </section> + <section> + <div class="body"> + <h3>%i18n:common.intro.ui%</h3> + <p v-html="'%i18n:common.intro.ui-desc%'"></p> + </div> + <div class="image"><img src="/assets/about/ui.png" alt=""></div> + </section> + <section> + <div class="body"> + <h3>%i18n:common.intro.drive%</h3> + <p v-html="'%i18n:common.intro.drive-desc%'"></p> + </div> + <div class="image"><img src="/assets/about/drive.png" alt=""></div> + </section> + </section> + <p v-html="'%i18n:common.intro.outro%'"></p> + </article> + </modal> + + <modal name="signup" class="modal" width="450px" height="auto" scrollable> + <header class="formHeader">%i18n:@signup%</header> + <mk-signup class="form"/> + </modal> + + <modal name="signin" class="modal" width="450px" height="auto" scrollable> + <header class="formHeader">%i18n:@signin%</header> + <mk-signin class="form"/> </modal> </div> </template> @@ -47,52 +149,65 @@ <script lang="ts"> import Vue from 'vue'; import { host, copyright } from '../../../config'; +import { concat } from '../../../../../prelude/array'; export default Vue.extend({ data() { return { + meta: null, stats: null, + banner: null, copyright, host, name: 'Misskey', description: '', - pointerInterval: null, - tags: [] + announcements: [], + photos: [] }; }, + created() { (this as any).os.getMeta().then(meta => { + this.meta = meta; this.name = meta.name; this.description = meta.description; + this.announcements = meta.broadcasts; + this.banner = meta.bannerUrl; }); (this as any).api('stats').then(stats => { this.stats = stats; }); - (this as any).api('hashtags/trend').then(stats => { - this.tags = stats.map(x => x.tag); + const image = [ + 'image/jpeg', + 'image/png', + 'image/gif' + ]; + + (this as any).api('notes/local-timeline', { + fileType: image, + excludeNsfw: true, + limit: 6 + }).then((notes: any[]) => { + const files = concat(notes.map((n: any): any[] => n.files)); + this.photos = files.filter(f => image.includes(f.type)).slice(0, 6); }); }, - mounted() { - this.point(); - this.pointerInterval = setInterval(this.point, 100); - }, - beforeDestroy() { - clearInterval(this.pointerInterval); - }, + methods: { - point() { - const x = this.$refs.signup.getBoundingClientRect(); - this.$refs.pointer.style.top = x.top + x.height + 'px'; - this.$refs.pointer.style.left = x.left + 'px'; + about() { + this.$modal.show('about'); }, + signup() { this.$modal.show('signup'); }, + signin() { this.$modal.show('signin'); }, + dark() { this.$store.commit('device/set', { key: 'darkmode', @@ -103,189 +218,289 @@ export default Vue.extend({ }); </script> -<style> -#wait { - right: auto; - left: 15px; -} +<style lang="stylus"> +#wait + right auto + left 15px + +.v--modal-overlay + background rgba(0, 0, 0, 0.6) + +.modal + .form + padding 24px 48px 48px 48px + + .formHeader + text-align center + padding 48px 0 12px 0 + margin 0 48px + font-size 1.5em + + .v--modal-box + background var(--face) + color var(--text) + + .formHeader + border-bottom solid 1px rgba(#000, 0.2) + +.v--modal-overlay.about + .v--modal-box.v--modal + margin 32px 0 + +.fpdezooorhntlzyeszemrsqdlgbysvxq + padding 64px + + > p:last-child + margin-bottom 0 + + > h1 + margin-top 0 + + > section + > h2 + border-bottom 1px solid var(--faceDivider) + + > section + display grid + grid-template-rows 1fr + grid-template-columns 180px 1fr + gap 32px + margin-bottom 32px + padding-bottom 32px + border-bottom 1px solid var(--faceDivider) + + &:nth-child(odd) + grid-template-columns 1fr 180px + + > .body + grid-column 1 + + > .image + grid-column 2 + + > .body + grid-row 1 + grid-column 2 + + > .image + grid-row 1 + grid-column 1 + + > img + display block + width 100% + height 100% + object-fit cover </style> <style lang="stylus" scoped> -@import '~const.styl' - -root(isDark) +.mk-welcome display flex min-height 100vh - > .pointer - display block + > .banner + position absolute + top 0 + left 0 + width 100% + height 400px + background-position center + background-size cover + opacity 0.7 + + &:after + content "" + display block + position absolute + bottom 0 + left 0 + width 100% + height 100px + background linear-gradient(transparent, var(--bg)) + + > .forkit position absolute - z-index 1 top 0 right 0 - width 180px - margin 0 0 0 -180px - transform rotateY(180deg) translateX(-10px) translateY(-48px) - pointer-events none > button position fixed z-index 1 - top 0 - left 0 + bottom 16px + left 16px padding 16px font-size 18px - color #fff + color var(--text) - display none // TODO + > main + margin 0 auto + padding 64px + width 100% + max-width 1200px - > .body - flex 1 - padding 64px 0 0 0 - text-align center - background #578394 - background-position center - background-size cover + .block + color var(--text) + background var(--face) + box-shadow var(--shadow) + //border-radius 8px + overflow auto - &:before - content '' - display block - position absolute - top 0 - left 0 - right 0 - bottom 0 - background rgba(#000, 0.5) + > header + z-index 1 + padding 0 16px + line-height 48px + background var(--faceHeader) + box-shadow 0 1px 0px rgba(0, 0, 0, 0.1) - > .forkit - position absolute - top 0 - right 0 + & + div + max-height calc(100% - 48px) - > img - position absolute - bottom 16px - right 16px - width 150px + > div + overflow auto - > .container - $aboutWidth = 380px - $loginWidth = 340px - $width = $aboutWidth + $loginWidth + > .body + display grid + grid-template-rows 390px 1fr 256px 64px + grid-template-columns 1fr 1fr 350px + gap 16px + height 1150px - > .info - margin 0 auto 16px auto - width $width - font-size 14px - color #fff + > .main + grid-row 1 + grid-column 1 / 3 + border-top solid 5px var(--primary) - > .stats - margin-left 16px - padding-left 16px - border-left solid 1px #fff + > div + padding 32px + min-height 100% - > * - margin-right 16px + > h1 + margin 0 - > main - display flex - margin auto - width $width - border-radius 8px - overflow hidden - box-shadow 0 2px 8px rgba(#000, 0.3) + > svg + margin -8px 0 0 -16px + width 280px + height 100px + fill currentColor - > .about - width $aboutWidth - color #444 - background #fff + > .info + margin 0 auto 16px auto + width $width + font-size 14px - > h1 - margin 0 0 16px 0 - padding 32px 32px 0 32px - color #444 + > .stats + margin-left 16px + padding-left 16px + border-left solid 1px var(--faceDivider) + + > * + margin-right 16px + + > .desc + max-width calc(100% - 150px) + + > .sign + font-size 120% + margin-bottom 0 + + > .divider + margin 0 16px + + > .signin + > .signup + cursor pointer - > img - width 170px - vertical-align bottom + &:hover + color var(--primary) - > .powerd-by - margin 16px + > .char + display block + position absolute + right 16px + bottom 0 + height 320px opacity 0.7 - > .desc - margin 0 - padding 0 32px 16px 32px + > *:not(.char) + z-index 1 - > a - display inline-block - margin 0 0 32px 0 - font-weight bold + > .announcements + grid-row 2 + grid-column 1 - > .login - width $loginWidth - padding 16px 32px 32px 32px - background isDark ? #2e3440 : #f5f5f5 + > div + padding 32px - > .hashtags - margin 16px auto - width $width - font-size 14px - color #fff - background rgba(#000, 0.3) - border-radius 8px + > div + padding 0 0 16px 0 + margin 0 0 16px 0 + border-bottom 1px solid var(--faceDivider) - > * - display inline-block - margin 14px + > h1 + margin 0 + font-size 1.25em - > .nav - display block - margin 16px 0 - font-size 14px - color #fff + > .photos + grid-row 2 + grid-column 2 - > .tl - margin 0 - width 410px - height 100vh - text-align left - background isDark ? #313543 : #fff + > div + display grid + grid-template-rows 1fr 1fr 1fr + grid-template-columns 1fr 1fr + gap 8px + height 100% + padding 16px - > * - max-height 100% - overflow auto + > div + //border-radius 4px + background-position center center + background-size cover -.mk-welcome[data-darkmode] - root(true) + > .tag-cloud + grid-row 3 + grid-column 1 / 3 -.mk-welcome:not([data-darkmode]) - root(false) + > div + height 256px + padding 32px -</style> + > .nav + display flex + justify-content center + align-items center + grid-row 4 + grid-column 1 / 3 + font-size 14px + + > .side + display grid + grid-row 1 / 5 + grid-column 3 + grid-template-rows 1fr 350px + grid-template-columns 1fr + gap 16px + + > .tl + grid-row 1 + grid-column 1 + overflow auto -<style lang="stylus" module> -.signupForm - padding 24px 48px 48px 48px + > .trends + grid-row 2 + grid-column 1 + padding 8px -.signupFormHeader - padding 48px 0 12px 0 - margin: 0 48px - font-size 1.5em - color #777 - border-bottom solid 1px #eee + > .info + grid-row 3 + grid-column 1 -.signinForm - padding 24px 48px 48px 48px + > div + padding 16px -.signinFormHeader - padding 48px 0 12px 0 - margin: 0 48px - font-size 1.5em - color #777 - border-bottom solid 1px #eee + > .body + > p + display block + margin 0 -.nav - a - color #666 </style> diff --git a/src/client/app/desktop/views/widgets/polls.vue b/src/client/app/desktop/views/widgets/polls.vue index 8ff0bb5d0d..c10ac1ca17 100644 --- a/src/client/app/desktop/views/widgets/polls.vue +++ b/src/client/app/desktop/views/widgets/polls.vue @@ -4,7 +4,7 @@ <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="$store.state.device.darkmode"> + <div class="mkw-polls--body"> <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> @@ -64,11 +64,11 @@ export default define({ </script> <style lang="stylus" scoped> -root(isDark) +.mkw-polls--body > .poll padding 16px font-size 12px - color isDark ? #9ea4ad : #555 + color var(--text) > p margin 0 0 8px 0 @@ -91,10 +91,4 @@ root(isDark) > [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 19a2790d95..a763f4d17c 100644 --- a/src/client/app/desktop/views/widgets/post-form.vue +++ b/src/client/app/desktop/views/widgets/post-form.vue @@ -68,7 +68,7 @@ export default define({ </script> <style lang="stylus" scoped> -@import '~const.styl' + .mkw-post-form background #fff @@ -107,8 +107,8 @@ export default define({ margin 0 padding 0 10px height 28px - color $theme-color-foreground - background $theme-color !important + color var(--primaryForeground) + background var(--primary) !important outline none border none border-radius 4px @@ -116,10 +116,10 @@ export default define({ cursor pointer &:hover - background lighten($theme-color, 10%) !important + background var(--primaryLighten10) !important &:active - background darken($theme-color, 10%) !important + background var(--primaryDarken10) !important transition background 0s ease </style> diff --git a/src/client/app/desktop/views/widgets/profile.vue b/src/client/app/desktop/views/widgets/profile.vue index a22607b612..30b7b95d35 100644 --- a/src/client/app/desktop/views/widgets/profile.vue +++ b/src/client/app/desktop/views/widgets/profile.vue @@ -1,20 +1,24 @@ <template> -<div class="mkw-profile" - :data-compact="props.design == 1 || props.design == 2" - :data-melt="props.design == 2" -> - <div class="banner" - :style="$store.state.i.bannerUrl ? `background-image: url(${$store.state.i.bannerUrl})` : ''" - title="%i18n:@update-banner%" - @click="() => os.apis.updateBanner()" - ></div> - <mk-avatar class="avatar" :user="$store.state.i" - :disable-link="true" - @click="() => os.apis.updateAvatar()" - title="%i18n:@update-avatar%" - /> - <router-link class="name" :to="$store.state.i | userPage">{{ $store.state.i | userName }}</router-link> - <p class="username">@{{ $store.state.i | acct }}</p> +<div class="egwyvoaaryotefqhqtmiyawwefemjfsd"> + <mk-widget-container :show-header="false" :naked="props.design == 2"> + <div class="egwyvoaaryotefqhqtmiyawwefemjfsd-body" + :data-compact="props.design == 1 || props.design == 2" + :data-melt="props.design == 2" + > + <div class="banner" + :style="$store.state.i.bannerUrl ? `background-image: url(${$store.state.i.bannerUrl})` : ''" + title="%i18n:@update-banner%" + @click="() => os.apis.updateBanner()" + ></div> + <mk-avatar class="avatar" :user="$store.state.i" + :disable-link="true" + @click="() => os.apis.updateAvatar()" + title="%i18n:@update-avatar%" + /> + <router-link class="name" :to="$store.state.i | userPage">{{ $store.state.i | userName }}</router-link> + <p class="username">@{{ $store.state.i | acct }}</p> + </div> + </mk-widget-container> </div> </template> @@ -41,12 +45,7 @@ export default define({ </script> <style lang="stylus" scoped> -root(isDark) - overflow hidden - background isDark ? #282c37 : #fff - border solid 1px rgba(#000, 0.075) - border-radius 6px - +.egwyvoaaryotefqhqtmiyawwefemjfsd-body &[data-compact] > .banner:before content "" @@ -75,9 +74,6 @@ root(isDark) display none &[data-melt] - background transparent !important - border none !important - > .banner visibility hidden @@ -90,7 +86,7 @@ root(isDark) > .banner height 100px - background-color isDark ? #303e4a : #f5f5f5 + background-color var(--primaryAlpha01) background-size cover background-position center cursor pointer @@ -102,7 +98,7 @@ root(isDark) left 16px width 58px height 58px - border solid 3px isDark ? #282c37 : #fff + border solid 3px var(--face) border-radius 8px cursor pointer @@ -111,19 +107,14 @@ root(isDark) margin 10px 0 0 84px line-height 16px font-weight bold - color isDark ? #fff : #555 + color var(--text) > .username display block margin 4px 0 8px 84px line-height 16px font-size 0.9em - color isDark ? #606984 : #999 - -.mkw-profile[data-darkmode] - root(true) - -.mkw-profile:not([data-darkmode]) - root(false) + color var(--text) + opacity 0.7 </style> diff --git a/src/client/app/desktop/views/widgets/trends.vue b/src/client/app/desktop/views/widgets/trends.vue index c33bf2f2f2..a886796132 100644 --- a/src/client/app/desktop/views/widgets/trends.vue +++ b/src/client/app/desktop/views/widgets/trends.vue @@ -49,7 +49,7 @@ export default define({ offset: this.offset, renote: false, reply: false, - media: false, + file: false, poll: false }).then(notes => { const note = notes ? notes[0] : null; @@ -67,7 +67,7 @@ export default define({ </script> <style lang="stylus" scoped> -root(isDark) +.mkw-trends .mkw-trends--body > .note padding 16px @@ -98,10 +98,4 @@ root(isDark) > [data-fa] margin-right 4px -.mkw-trends[data-darkmode] - root(true) - -.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 328fa56697..28c6372b6f 100644 --- a/src/client/app/desktop/views/widgets/users.vue +++ b/src/client/app/desktop/views/widgets/users.vue @@ -73,11 +73,11 @@ export default define({ </script> <style lang="stylus" scoped> -root(isDark) +.mkw-users .mkw-users--body > .user padding 16px - border-bottom solid 1px isDark ? #1c2023 : #eee + border-bottom solid 1px var(--faceDivider) &:last-child border-bottom none @@ -103,14 +103,15 @@ root(isDark) margin 0 font-size 16px line-height 24px - color isDark ? #fff : #555 + color var(--text) > .username display block margin 0 font-size 15px line-height 16px - color isDark ? #606984 : #ccc + color var(--text) + opacity 0.7 > .mk-follow-button position absolute @@ -132,10 +133,4 @@ root(isDark) > [data-fa] margin-right 4px -.mkw-users[data-darkmode] - root(true) - -.mkw-users:not([data-darkmode]) - root(false) - </style> diff --git a/src/client/app/init.css b/src/client/app/init.css index 6ee25d64e2..92bb1d8cf4 100644 --- a/src/client/app/init.css +++ b/src/client/app/init.css @@ -32,7 +32,7 @@ body > noscript { left: 0; width: 100%; height: 100%; - background: #fff; + background: var(--bg); cursor: wait; } #ini > svg { @@ -47,10 +47,6 @@ body > noscript { animation: ini 0.6s infinite linear; } -html[data-darkmode] #ini { - background: #191b22; -} - @keyframes ini { from { transform: rotate(0deg); diff --git a/src/client/app/init.ts b/src/client/app/init.ts index cf97957400..c2381067da 100644 --- a/src/client/app/init.ts +++ b/src/client/app/init.ts @@ -5,31 +5,27 @@ import Vue from 'vue'; import Vuex from 'vuex'; import VueRouter from 'vue-router'; -import VModal from 'vue-js-modal'; import * as TreeView from 'vue-json-tree-view'; import VAnimateCss from 'v-animate-css'; -import Element from 'element-ui'; -import ElementLocaleEn from 'element-ui/lib/locale/lang/en'; -import ElementLocaleJa from 'element-ui/lib/locale/lang/ja'; +import VModal from 'vue-js-modal'; +import VueHotkey from './common/hotkey'; import App from './app.vue'; import checkForUpdate from './common/scripts/check-for-update'; import MiOS, { API } from './mios'; import { version, codename, lang } from './config'; +import { builtinThemes, lightTheme, applyTheme } from './theme'; -let elementLocale; -switch (lang) { - case 'ja-JP': elementLocale = ElementLocaleJa; break; - case 'en-US': elementLocale = ElementLocaleEn; break; - default: elementLocale = ElementLocaleEn; break; +if (localStorage.getItem('theme') == null) { + applyTheme(lightTheme); } Vue.use(Vuex); Vue.use(VueRouter); -Vue.use(VModal); Vue.use(TreeView); Vue.use(VAnimateCss); -Vue.use(Element, { locale: elementLocale }); +Vue.use(VModal); +Vue.use(VueHotkey); // Register global directives require('./common/views/directives'); @@ -42,9 +38,13 @@ require('./common/views/widgets'); require('./common/views/filters'); Vue.mixin({ - destroyed(this: any) { - if (this.$el.parentNode) { - this.$el.parentNode.removeChild(this.$el); + methods: { + destroyDom() { + this.$destroy(); + + if (this.$el.parentNode) { + this.$el.parentNode.removeChild(this.$el); + } } } }); @@ -91,45 +91,55 @@ export default (callback: (launch: (router: VueRouter, api?: (os: MiOS) => API) const launch = (router: VueRouter, api?: (os: MiOS) => API) => { os.apis = api ? api(os) : null; - //#region Dark/Light - Vue.mixin({ - data() { - return { - _unwatchDarkmode_: null - }; - }, - mounted() { - const apply = v => { - if (this.$el.setAttribute == null) return; - if (v) { - this.$el.setAttribute('data-darkmode', 'true'); - } else { - this.$el.removeAttribute('data-darkmode'); - } - }; - - apply(os.store.state.device.darkmode); - - this._unwatchDarkmode_ = os.store.watch(s => { - return s.device.darkmode; - }, apply); - }, - beforeDestroy() { - this._unwatchDarkmode_(); + //#region theme + os.store.watch(s => { + return s.device.darkmode; + }, v => { + const themes = os.store.state.device.themes.concat(builtinThemes); + const dark = themes.find(t => t.id == os.store.state.device.darkTheme); + const light = themes.find(t => t.id == os.store.state.device.lightTheme); + applyTheme(v ? dark : light); + }); + os.store.watch(s => { + return s.device.lightTheme; + }, v => { + const themes = os.store.state.device.themes.concat(builtinThemes); + const theme = themes.find(t => t.id == v); + if (!os.store.state.device.darkmode) { + applyTheme(theme); } }); - os.store.watch(s => { - return s.device.darkmode; + return s.device.darkTheme; }, v => { - if (v) { - document.documentElement.setAttribute('data-darkmode', 'true'); - } else { - document.documentElement.removeAttribute('data-darkmode'); + const themes = os.store.state.device.themes.concat(builtinThemes); + const theme = themes.find(t => t.id == v); + if (os.store.state.device.darkmode) { + applyTheme(theme); } }); //#endregion + //#region shadow + const shadow = '0 3px 8px rgba(0, 0, 0, 0.2)'; + if (os.store.state.settings.useShadow) document.documentElement.style.setProperty('--shadow', shadow); + os.store.watch(s => { + return s.settings.useShadow; + }, v => { + document.documentElement.style.setProperty('--shadow', v ? shadow : 'none'); + }); + //#endregion + + //#region rounded corners + const round = '6px'; + if (os.store.state.settings.roundedCorners) document.documentElement.style.setProperty('--round', round); + os.store.watch(s => { + return s.settings.roundedCorners; + }, v => { + document.documentElement.style.setProperty('--round', v ? round : '0'); + }); + //#endregion + Vue.mixin({ data() { return { diff --git a/src/client/app/mios.ts b/src/client/app/mios.ts index 664848b5e7..42171e71fa 100644 --- a/src/client/app/mios.ts +++ b/src/client/app/mios.ts @@ -1,22 +1,14 @@ +import autobind from 'autobind-decorator'; import Vue from 'vue'; import { EventEmitter } from 'eventemitter3'; import * as uuid from 'uuid'; import initStore from './store'; -import { apiUrl, swPublickey, version, lang, googleMapsApiKey } from './config'; +import { apiUrl, version, lang } from './config'; import Progress from './common/scripts/loading'; -import Connection from './common/scripts/streaming/stream'; -import { HomeStreamManager } from './common/scripts/streaming/home'; -import { DriveStreamManager } from './common/scripts/streaming/drive'; -import { ServerStatsStreamManager } from './common/scripts/streaming/server-stats'; -import { NotesStatsStreamManager } from './common/scripts/streaming/notes-stats'; -import { MessagingIndexStreamManager } from './common/scripts/streaming/messaging-index'; -import { ReversiStreamManager } from './common/scripts/streaming/games/reversi/reversi'; import Err from './common/views/components/connect-failed.vue'; -import { LocalTimelineStreamManager } from './common/scripts/streaming/local-timeline'; -import { HybridTimelineStreamManager } from './common/scripts/streaming/hybrid-timeline'; -import { GlobalTimelineStreamManager } from './common/scripts/streaming/global-timeline'; +import Stream from './common/scripts/stream'; //#region api requests let spinner = null; @@ -101,30 +93,7 @@ export default class MiOS extends EventEmitter { /** * A connection manager of home stream */ - public stream: HomeStreamManager; - - /** - * Connection managers - */ - public streams: { - localTimelineStream: LocalTimelineStreamManager; - hybridTimelineStream: HybridTimelineStreamManager; - globalTimelineStream: GlobalTimelineStreamManager; - driveStream: DriveStreamManager; - serverStatsStream: ServerStatsStreamManager; - notesStatsStream: NotesStatsStreamManager; - messagingIndexStream: MessagingIndexStreamManager; - reversiStream: ReversiStreamManager; - } = { - localTimelineStream: null, - hybridTimelineStream: null, - globalTimelineStream: null, - driveStream: null, - serverStatsStream: null, - notesStatsStream: null, - messagingIndexStream: null, - reversiStream: null - }; + public stream: Stream; /** * A registration of service worker @@ -150,71 +119,36 @@ export default class MiOS extends EventEmitter { this.shouldRegisterSw = shouldRegisterSw; - //#region BIND - this.log = this.log.bind(this); - this.logInfo = this.logInfo.bind(this); - this.logWarn = this.logWarn.bind(this); - this.logError = this.logError.bind(this); - this.init = this.init.bind(this); - this.api = this.api.bind(this); - this.getMeta = this.getMeta.bind(this); - this.registerSw = this.registerSw.bind(this); - //#endregion - if (this.debug) { (window as any).os = this; } } - private googleMapsIniting = false; - - public getGoogleMaps() { - return new Promise((res, rej) => { - if ((window as any).google && (window as any).google.maps) { - res((window as any).google.maps); - } else { - this.once('init-google-maps', () => { - res((window as any).google.maps); - }); - - //#region load google maps api - if (!this.googleMapsIniting) { - this.googleMapsIniting = true; - (window as any).initGoogleMaps = () => { - this.emit('init-google-maps'); - }; - const head = document.getElementsByTagName('head')[0]; - const script = document.createElement('script'); - script.setAttribute('src', `https://maps.googleapis.com/maps/api/js?key=${googleMapsApiKey}&callback=initGoogleMaps`); - script.setAttribute('async', 'true'); - script.setAttribute('defer', 'true'); - head.appendChild(script); - } - //#endregion - } - }); - } - + @autobind public log(...args) { if (!this.debug) return; console.log.apply(null, args); } + @autobind public logInfo(...args) { if (!this.debug) return; console.info.apply(null, args); } + @autobind public logWarn(...args) { if (!this.debug) return; console.warn.apply(null, args); } + @autobind public logError(...args) { if (!this.debug) return; console.error.apply(null, args); } + @autobind public signout() { this.store.dispatch('logout'); location.href = '/'; @@ -224,27 +158,10 @@ export default class MiOS extends EventEmitter { * Initialize MiOS (boot) * @param callback A function that call when initialized */ + @autobind public async init(callback) { this.store = initStore(this); - //#region Init stream managers - this.streams.serverStatsStream = new ServerStatsStreamManager(this); - this.streams.notesStatsStream = new NotesStatsStreamManager(this); - - this.once('signedin', () => { - // Init home stream manager - this.stream = new HomeStreamManager(this, this.store.state.i); - - // Init other stream manager - this.streams.localTimelineStream = new LocalTimelineStreamManager(this, this.store.state.i); - this.streams.hybridTimelineStream = new HybridTimelineStreamManager(this, this.store.state.i); - this.streams.globalTimelineStream = new GlobalTimelineStreamManager(this, this.store.state.i); - this.streams.driveStream = new DriveStreamManager(this, this.store.state.i); - this.streams.messagingIndexStream = new MessagingIndexStreamManager(this, this.store.state.i); - this.streams.reversiStream = new ReversiStreamManager(this, this.store.state.i); - }); - //#endregion - // ユーザーをフェッチしてコールバックする const fetchme = (token, cb) => { let me = null; @@ -264,7 +181,7 @@ export default class MiOS extends EventEmitter { // When success .then(res => { // When failed to authenticate user - if (res.status !== 200) { + if (res.status !== 200 && res.status < 500) { return this.signout(); } @@ -295,6 +212,8 @@ export default class MiOS extends EventEmitter { const fetched = () => { this.emit('signedin'); + this.stream = new Stream(this); + // Finish init callback(); @@ -327,6 +246,8 @@ export default class MiOS extends EventEmitter { } else { // Finish init callback(); + + this.stream = new Stream(this); } }); } @@ -335,6 +256,7 @@ export default class MiOS extends EventEmitter { /** * Register service worker */ + @autobind private registerSw() { // Check whether service worker and push manager supported const isSwSupported = @@ -361,7 +283,7 @@ export default class MiOS extends EventEmitter { // A public key your push server will use to send // messages to client apps via a push server. - applicationServerKey: urlBase64ToUint8Array(swPublickey) + applicationServerKey: urlBase64ToUint8Array(this.meta.data.swPublickey) }; // Subscribe push notification @@ -417,7 +339,8 @@ export default class MiOS extends EventEmitter { * @param endpoint エンドポイント名 * @param data パラメータ */ - public api(endpoint: string, data: { [x: string]: any } = {}): Promise<{ [x: string]: any }> { + @autobind + public api(endpoint: string, data: { [x: string]: any } = {}, forceFetch = false): Promise<{ [x: string]: any }> { if (++pending === 1) { spinner = document.createElement('div'); spinner.setAttribute('id', 'wait'); @@ -429,13 +352,12 @@ export default class MiOS extends EventEmitter { }; const promise = new Promise((resolve, reject) => { - const viaStream = this.stream && this.stream.hasConnection && this.store.state.device.apiViaStream; + const viaStream = this.stream && this.store.state.device.apiViaStream && !forceFetch; if (viaStream) { - const stream = this.stream.borrow(); const id = Math.random().toString(); - stream.once(`api-res:${id}`, res => { + this.stream.once(`api:${id}`, res => { if (res == null || Object.keys(res).length == 0) { resolve(null); } else if (res.res) { @@ -445,11 +367,10 @@ export default class MiOS extends EventEmitter { } }); - stream.send({ - type: 'api', - id, - endpoint, - data + this.stream.send('api', { + id: id, + ep: endpoint, + data: data }); } else { // Append a credential @@ -502,6 +423,7 @@ export default class MiOS extends EventEmitter { * Misskeyのメタ情報を取得します * @param force キャッシュを無視するか否か */ + @autobind public getMeta(force = false) { return new Promise<{ [x: string]: any }>(async (res, rej) => { if (this.isMetaFetching) { @@ -529,16 +451,6 @@ export default class MiOS extends EventEmitter { } }); } - - public connections: Connection[] = []; - - public registerStreamConnection(connection: Connection) { - this.connections.push(connection); - } - - public unregisterStreamConnection(connection: Connection) { - this.connections = this.connections.filter(c => c != connection); - } } class WindowSystem extends EventEmitter { diff --git a/src/client/app/mobile/api/post.ts b/src/client/app/mobile/api/post.ts index 15b2f6b691..5c0f0af852 100644 --- a/src/client/app/mobile/api/post.ts +++ b/src/client/app/mobile/api/post.ts @@ -1,13 +1,12 @@ -import PostForm from '../views/components/post-form.vue'; +import PostForm from '../views/components/post-form-dialog.vue'; export default (os) => (opts) => { const o = opts || {}; - const app = document.getElementById('app'); - app.style.display = 'none'; + document.documentElement.style.overflow = 'hidden'; function recover() { - app.style.display = 'block'; + document.documentElement.style.overflow = 'auto'; } const vm = new PostForm({ diff --git a/src/client/app/mobile/script.ts b/src/client/app/mobile/script.ts index 5b9d45462a..9412c85980 100644 --- a/src/client/app/mobile/script.ts +++ b/src/client/app/mobile/script.ts @@ -6,7 +6,6 @@ import VueRouter from 'vue-router'; // Style import './style.styl'; -import '../../element.scss'; import init from '../init'; diff --git a/src/client/app/mobile/style.styl b/src/client/app/mobile/style.styl index df8f4a8fae..095e5266fd 100644 --- a/src/client/app/mobile/style.styl +++ b/src/client/app/mobile/style.styl @@ -8,12 +8,4 @@ html height 100% - background #ececed !important - - &[data-darkmode] - background #191B22 !important - -body - display flex - flex-direction column - min-height 100% + background var(--bg) diff --git a/src/client/app/mobile/views/components/dialog.vue b/src/client/app/mobile/views/components/dialog.vue index 9ee01cb782..fff44a28c3 100644 --- a/src/client/app/mobile/views/components/dialog.vue +++ b/src/client/app/mobile/views/components/dialog.vue @@ -78,7 +78,7 @@ export default Vue.extend({ scale: 0.8, duration: 300, easing: [ 0.5, -0.5, 1, 0.5 ], - complete: () => this.$destroy() + complete: () => this.destroyDom() }); }, onBgClick() { @@ -91,7 +91,7 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> -@import '~const.styl' + .mk-dialog > .bg @@ -145,20 +145,20 @@ export default Vue.extend({ margin 0 0.375em &:hover - color $theme-color + color var(--primary) &:active - color darken($theme-color, 10%) + color var(--primaryDarken10) transition color 0s ease </style> <style lang="stylus" module> -@import '~const.styl' + .header margin 0 0 1em 0 - color $theme-color + color var(--primary) // color #43A4EC font-weight bold diff --git a/src/client/app/mobile/views/components/drive-file-chooser.vue b/src/client/app/mobile/views/components/drive-file-chooser.vue index d95d5fa223..5fca19939e 100644 --- a/src/client/app/mobile/views/components/drive-file-chooser.vue +++ b/src/client/app/mobile/views/components/drive-file-chooser.vue @@ -1,12 +1,12 @@ <template> -<div class="mk-drive-file-chooser"> +<div class="cdxzvcfawjxdyxsekbxbfgtplebnoneb"> <div class="body"> <header> <h1>%i18n:@select-file%<span class="count" v-if="files.length > 0">({{ files.length }})</span></h1> <button class="close" @click="cancel">%fa:times%</button> <button v-if="multiple" class="ok" @click="ok">%fa:check%</button> </header> - <mk-drive ref="browser" + <mk-drive class="drive" ref="browser" :select-file="true" :multiple="multiple" @change-selection="onChangeSelection" @@ -31,24 +31,24 @@ export default Vue.extend({ }, onSelected(file) { this.$emit('selected', file); - this.$destroy(); + this.destroyDom(); }, cancel() { this.$emit('canceled'); - this.$destroy(); + this.destroyDom(); }, ok() { this.$emit('selected', this.files); - this.$destroy(); + this.destroyDom(); } } }); </script> <style lang="stylus" scoped> -.mk-drive-file-chooser +.cdxzvcfawjxdyxsekbxbfgtplebnoneb position fixed - z-index 2048 + z-index 20000 top 0 left 0 width 100% @@ -59,10 +59,11 @@ export default Vue.extend({ > .body width 100% height 100% - background #fff + background var(--faceHeader) > header - border-bottom solid 1px #eee + border-bottom solid 1px var(--faceDivider) + color var(--text) > h1 margin 0 @@ -90,7 +91,7 @@ export default Vue.extend({ line-height 42px width 42px - > .mk-drive + > .drive height calc(100% - 42px) overflow scroll -webkit-overflow-scrolling touch diff --git a/src/client/app/mobile/views/components/drive-folder-chooser.vue b/src/client/app/mobile/views/components/drive-folder-chooser.vue index 7934fb7816..6d3fba1efd 100644 --- a/src/client/app/mobile/views/components/drive-folder-chooser.vue +++ b/src/client/app/mobile/views/components/drive-folder-chooser.vue @@ -19,11 +19,11 @@ export default Vue.extend({ methods: { cancel() { this.$emit('canceled'); - this.$destroy(); + this.destroyDom(); }, ok() { this.$emit('selected', (this.$refs.browser as any).folder); - this.$destroy(); + this.destroyDom(); } } }); diff --git a/src/client/app/mobile/views/components/drive.file-detail.vue b/src/client/app/mobile/views/components/drive.file-detail.vue index deb9941be8..7425afe1e2 100644 --- a/src/client/app/mobile/views/components/drive.file-detail.vue +++ b/src/client/app/mobile/views/components/drive.file-detail.vue @@ -1,5 +1,5 @@ <template> -<div class="file-detail"> +<div class="pyvicwrksnfyhpfgkjwqknuururpaztw"> <div class="preview"> <img v-if="kind == 'image'" ref="img" :src="file.url" @@ -25,7 +25,7 @@ </div> <div class="info"> <div> - <span class="type"><mk-file-type-icon :type="file.type"/>{{ file.type }}</span> + <span class="type"><mk-file-type-icon :type="file.type"/> {{ file.type }}</span> <span class="separator"></span> <span class="data-size">{{ file.datasize | bytes }}</span> <span class="separator"></span> @@ -38,10 +38,10 @@ </div> <div class="menu"> <div> - <a :href="`${file.url}?download`" :download="file.name">%fa:download%%i18n:@download%</a> - <button @click="rename">%fa:pencil-alt%%i18n:@rename%</button> - <button @click="move">%fa:R folder-open%%i18n:@move%</button> - <button @click="del">%fa:trash-alt R%%i18n:@delete%</button> + <ui-button link :href="`${file.url}?download`" :download="file.name">%fa:download% %i18n:@download%</ui-button> + <ui-button @click="rename">%fa:pencil-alt% %i18n:@rename%</ui-button> + <ui-button @click="move">%fa:R folder-open% %i18n:@move%</ui-button> + <ui-button @click="del">%fa:trash-alt R% %i18n:@delete%</ui-button> </div> </div> <div class="exif" v-show="exif"> @@ -67,7 +67,7 @@ import Vue from 'vue'; import * as EXIF from 'exif-js'; import * as hljs from 'highlight.js'; -import gcd from '../../../common/scripts/gcd'; +import { gcd } from '../../../../../prelude/math'; export default Vue.extend({ props: ['file'], @@ -134,11 +134,10 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> -.file-detail - +.pyvicwrksnfyhpfgkjwqknuururpaztw > .preview padding 8px - background #f0f0f0 + background var(--bg) > img display block @@ -149,9 +148,10 @@ export default Vue.extend({ > footer padding 8px 8px 0 8px - font-size 0.8em - color #888 text-align center + font-size 0.8em + color var(--text) + opacity 0.7 > .separator display inline @@ -179,25 +179,17 @@ export default Vue.extend({ > .info padding 14px font-size 0.8em - border-top solid 1px #dfdfdf + border-top solid 1px var(--faceDivider) > div max-width 500px margin 0 auto + color var(--text) > .separator padding 0 4px - color #cdcdcd - - > .type - > .data-size - color #9d9d9d - - > mk-file-type-icon - margin-right 4px > .created-at - color #bdbdbd > [data-fa] margin-right 2px @@ -207,42 +199,15 @@ export default Vue.extend({ > .menu padding 14px - border-top solid 1px #dfdfdf + border-top solid 1px var(--faceDivider) > div max-width 500px margin 0 auto - > * - display block - width 100% - padding 10px 16px - margin 0 0 12px 0 - color #333 - font-size 0.9em - text-align center - text-decoration none - text-shadow 0 1px 0 rgba(255, 255, 255, 0.9) - background-image linear-gradient(#fafafa, #eaeaea) - border 1px solid #ddd - border-bottom-color #cecece - border-radius 3px - - &:last-child - margin-bottom 0 - - &: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) - - > [data-fa] - margin-right 4px - > .hash padding 14px - border-top solid 1px #dfdfdf + border-top solid 1px var(--faceDivider) > div max-width 500px @@ -252,7 +217,7 @@ export default Vue.extend({ display block margin 0 padding 0 - color #555 + color var(--text) font-size 0.9em > [data-fa] @@ -273,7 +238,7 @@ export default Vue.extend({ > .exif padding 14px - border-top solid 1px #dfdfdf + border-top solid 1px var(--faceDivider) > div max-width 500px @@ -283,7 +248,7 @@ export default Vue.extend({ display block margin 0 padding 0 - color #555 + color var(--text) font-size 0.9em > [data-fa] diff --git a/src/client/app/mobile/views/components/drive.file.vue b/src/client/app/mobile/views/components/drive.file.vue index 6dec4b9f4f..68978bb944 100644 --- a/src/client/app/mobile/views/components/drive.file.vue +++ b/src/client/app/mobile/views/components/drive.file.vue @@ -1,5 +1,5 @@ <template> -<a class="file" @click.prevent="onClick" :href="`/i/drive/file/${ file.id }`" :data-is-selected="isSelected"> +<a class="vupkuhvjnjyqaqhsiogfbywvjxynrgsm" @click.prevent="onClick" :href="`/i/drive/file/${ file.id }`" :data-is-selected="isSelected"> <div class="container"> <div class="thumbnail" :style="thumbnail"></div> <div class="body"> @@ -7,20 +7,12 @@ <span>{{ file.name.lastIndexOf('.') != -1 ? file.name.substr(0, file.name.lastIndexOf('.')) : file.name }}</span> <span class="ext" v-if="file.name.lastIndexOf('.') != -1">{{ file.name.substr(file.name.lastIndexOf('.')) }}</span> </p> - <!-- - if file.tags.length > 0 - ul.tags - each tag in file.tags - li.tag(style={background: tag.color, color: contrast(tag.color)})= tag.name - --> <footer> <span class="type"><mk-file-type-icon :type="file.type"/>{{ file.type }}</span> <span class="separator"></span> <span class="data-size">{{ file.datasize | bytes }}</span> <span class="separator"></span> - <span class="created-at"> - %fa:R clock%<mk-time :time="file.createdAt"/> - </span> + <span class="created-at">%fa:R clock%<mk-time :time="file.createdAt"/></span> <template v-if="file.isSensitive"> <span class="separator"></span> <span class="nsfw">%fa:eye-slash% %i18n:@nsfw%</span> @@ -71,9 +63,7 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> -@import '~const.styl' - -.file +.vupkuhvjnjyqaqhsiogfbywvjxynrgsm display block text-decoration none !important @@ -111,7 +101,7 @@ export default Vue.extend({ padding 0 font-size 0.9em font-weight bold - color #555 + color var(--text) text-overflow ellipsis overflow-wrap break-word @@ -135,22 +125,22 @@ export default Vue.extend({ display block margin 4px 0 0 0 font-size 0.7em + color var(--text) > .separator padding 0 4px - color #CDCDCD > .type - color #9D9D9D + opacity 0.7 > .mk-file-type-icon margin-right 4px > .data-size - color #9D9D9D + opacity 0.7 > .created-at - color #BDBDBD + opacity 0.7 > [data-fa] margin-right 2px @@ -159,7 +149,7 @@ export default Vue.extend({ color #bf4633 &[data-is-selected] - background $theme-color + background var(--primary) &, * color #fff !important diff --git a/src/client/app/mobile/views/components/drive.folder.vue b/src/client/app/mobile/views/components/drive.folder.vue index 22ff38fecb..05dcbd083e 100644 --- a/src/client/app/mobile/views/components/drive.folder.vue +++ b/src/client/app/mobile/views/components/drive.folder.vue @@ -1,5 +1,5 @@ <template> -<a class="root folder" @click.prevent="onClick" :href="`/i/drive/folder/${ folder.id }`"> +<a class="jvwxssxsytqlqvrpiymarjlzlsxskqsr" @click.prevent="onClick" :href="`/i/drive/folder/${ folder.id }`"> <div class="container"> <p class="name">%fa:folder%{{ folder.name }}</p>%fa:angle-right% </div> @@ -24,9 +24,9 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> -.root.folder +.jvwxssxsytqlqvrpiymarjlzlsxskqsr display block - color #777 + color var(--text) text-decoration none !important * diff --git a/src/client/app/mobile/views/components/drive.vue b/src/client/app/mobile/views/components/drive.vue index c313d225e4..469f6da240 100644 --- a/src/client/app/mobile/views/components/drive.vue +++ b/src/client/app/mobile/views/components/drive.vue @@ -1,5 +1,5 @@ <template> -<div class="mk-drive"> +<div class="kmmwchoexgckptowjmjgfsygeltxfeqs"> <nav ref="nav"> <a @click.prevent="goRoot()" href="/i/drive">%fa:cloud%%i18n:@drive%</a> <template v-for="folder in hierarchyFolders"> @@ -26,11 +26,11 @@ </p> </div> <div class="folders" v-if="folders.length > 0"> - <x-folder v-for="folder in folders" :key="folder.id" :folder="folder"/> + <x-folder class="folder" v-for="folder in folders" :key="folder.id" :folder="folder"/> <p v-if="moreFolders">%i18n:@load-more%</p> </div> <div class="files" v-if="files.length > 0"> - <x-file v-for="file in files" :key="file.id" :file="file"/> + <x-file class="file" v-for="file in files" :key="file.id" :file="file"/> <button class="more" v-if="moreFiles" @click="fetchMoreFiles"> {{ fetchingMoreFiles ? '%i18n:common.loading%' : '%i18n:@load-more%' }} </button> @@ -81,8 +81,7 @@ export default Vue.extend({ hierarchyFolders: [], selectedFiles: [], info: null, - connection: null, - connectionId: null, + connection: null fetching: true, fetchingMoreFiles: false, @@ -94,9 +93,15 @@ export default Vue.extend({ return this.selectFile; } }, + watch: { + top() { + if (this.isNaked) { + (this.$refs.nav as any).style.top = `${this.top}px`; + } + } + }, mounted() { - this.connection = (this as any).os.streams.driveStream.getConnection(); - this.connectionId = (this as any).os.streams.driveStream.use(); + this.connection = (this as any).os.stream.useSharedConnection('drive'); this.connection.on('file_created', this.onStreamDriveFileCreated); this.connection.on('file_updated', this.onStreamDriveFileUpdated); @@ -117,12 +122,7 @@ export default Vue.extend({ } }, beforeDestroy() { - this.connection.off('file_created', this.onStreamDriveFileCreated); - this.connection.off('file_updated', this.onStreamDriveFileUpdated); - this.connection.off('file_deleted', this.onStreamDriveFileDeleted); - this.connection.off('folder_created', this.onStreamDriveFolderCreated); - this.connection.off('folder_updated', this.onStreamDriveFolderUpdated); - (this as any).os.streams.driveStream.dispose(this.connectionId); + this.connection.dispose(); }, methods: { onStreamDriveFileCreated(file) { @@ -466,8 +466,8 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> -.mk-drive - background #fff +.kmmwchoexgckptowjmjgfsygeltxfeqs + background var(--face) > nav display block @@ -480,10 +480,10 @@ export default Vue.extend({ overflow auto white-space nowrap font-size 0.9em - color rgba(#000, 0.67) + color var(--text) -webkit-backdrop-filter blur(12px) backdrop-filter blur(12px) - background-color rgba(#fff, 0.75) + background-color var(--mobileDriveNavBg) border-bottom solid 1px rgba(#000, 0.13) > p @@ -509,7 +509,7 @@ export default Vue.extend({ opacity 0.5 > .info - border-bottom solid 1px #eee + border-bottom solid 1px var(--faceDivider) &:empty display none @@ -520,15 +520,15 @@ export default Vue.extend({ margin 0 auto padding 4px 16px font-size 10px - color #777 + color var(--text) > .folders > .folder - border-bottom solid 1px #eee + border-bottom solid 1px var(--faceDivider) > .files > .file - border-bottom solid 1px #eee + border-bottom solid 1px var(--faceDivider) > .more display block diff --git a/src/client/app/mobile/views/components/follow-button.vue b/src/client/app/mobile/views/components/follow-button.vue index 360ee91d4b..3c8b2f98e6 100644 --- a/src/client/app/mobile/views/components/follow-button.vue +++ b/src/client/app/mobile/views/components/follow-button.vue @@ -5,7 +5,8 @@ :disabled="wait" > <template v-if="!wait"> - <template v-if="u.hasPendingFollowRequestFromYou">%fa:hourglass-half% %i18n:@request-pending%</template> + <template v-if="u.hasPendingFollowRequestFromYou && u.isLocked">%fa:hourglass-half% %i18n:@request-pending%</template> + <template v-else-if="u.hasPendingFollowRequestFromYou && !u.isLocked">%fa:hourglass-start% %i18n:@follow-processing%</template> <template v-else-if="u.isFollowing">%fa:minus% %i18n:@following%</template> <template v-else-if="!u.isFollowing && u.isLocked">%fa:plus% %i18n:@follow-request%</template> <template v-else-if="!u.isFollowing && !u.isLocked">%fa:plus% %i18n:@follow%</template> @@ -27,33 +28,31 @@ export default Vue.extend({ return { u: this.user, wait: false, - connection: null, - connectionId: null + connection: null }; }, mounted() { - this.connection = (this as any).os.stream.getConnection(); - this.connectionId = (this as any).os.stream.use(); + this.connection = (this as any).os.stream.useSharedConnection('main'); 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); + this.connection.dispose(); }, methods: { onFollow(user) { if (user.id == this.u.id) { this.u.isFollowing = user.isFollowing; + this.u.hasPendingFollowRequestFromYou = user.hasPendingFollowRequestFromYou; } }, onUnfollow(user) { if (user.id == this.u.id) { this.u.isFollowing = user.isFollowing; + this.u.hasPendingFollowRequestFromYou = user.hasPendingFollowRequestFromYou; } }, @@ -66,7 +65,7 @@ export default Vue.extend({ userId: this.u.id }); } else { - if (this.u.isLocked && this.u.hasPendingFollowRequestFromYou) { + if (this.u.hasPendingFollowRequestFromYou) { this.u = await (this as any).api('following/requests/cancel', { userId: this.u.id }); @@ -91,7 +90,7 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> -@import '~const.styl' + .mk-follow-button display block @@ -103,29 +102,29 @@ export default Vue.extend({ line-height 36px font-size 14px font-weight bold - color $theme-color + color var(--primary) background transparent outline none - border solid 1px $theme-color + border solid 1px var(--primary) border-radius 36px &:hover - background rgba($theme-color, 0.1) + background var(--primaryAlpha01) &:active - background rgba($theme-color, 0.2) + background var(--primaryAlpha02) &.active - color $theme-color-foreground - background $theme-color + color var(--primaryForeground) + background var(--primary) &:hover - background lighten($theme-color, 10%) - border-color lighten($theme-color, 10%) + background var(--primaryLighten10) + border-color var(--primaryLighten10) &:active - background darken($theme-color, 10%) - border-color darken($theme-color, 10%) + background var(--primaryDarken10) + border-color var(--primaryDarken10) &.wait cursor wait !important diff --git a/src/client/app/mobile/views/components/friends-maker.vue b/src/client/app/mobile/views/components/friends-maker.vue index e0461d2bc2..dbb82f4b18 100644 --- a/src/client/app/mobile/views/components/friends-maker.vue +++ b/src/client/app/mobile/views/components/friends-maker.vue @@ -47,7 +47,7 @@ export default Vue.extend({ this.fetch(); }, close() { - this.$destroy(); + this.destroyDom(); } } }); diff --git a/src/client/app/mobile/views/components/media-image.vue b/src/client/app/mobile/views/components/media-image.vue index e40069bbe3..652a2ad3a4 100644 --- a/src/client/app/mobile/views/components/media-image.vue +++ b/src/client/app/mobile/views/components/media-image.vue @@ -1,5 +1,5 @@ <template> -<div class="qjewsnkgzzxlxtzncydssfbgjibiehcy" v-if="image.isSensitive && hide" @click="hide = false"> +<div class="qjewsnkgzzxlxtzncydssfbgjibiehcy" v-if="image.isSensitive && hide && !$store.state.device.alwaysShowNsfw" @click="hide = false"> <div> <b>%fa:exclamation-triangle% %i18n:@sensitive%</b> <span>%i18n:@click-to-show%</span> @@ -19,12 +19,13 @@ export default Vue.extend({ }, raw: { default: false - }, - hide: { - type: Boolean, - default: true } }, + data() { + return { + hide: true + }; + } computed: { style(): any { let url = `url(${this.image.thumbnailUrl})`; @@ -65,7 +66,7 @@ export default Vue.extend({ text-align center font-size 12px - > b + > * display block </style> diff --git a/src/client/app/mobile/views/components/media-video.vue b/src/client/app/mobile/views/components/media-video.vue index aea7f41460..59ba695b93 100644 --- a/src/client/app/mobile/views/components/media-video.vue +++ b/src/client/app/mobile/views/components/media-video.vue @@ -9,31 +9,35 @@ :href="video.url" target="_blank" :style="imageStyle" - :title="video.name"> + :title="video.name" +> %fa:R play-circle% </a> </template> <script lang="ts"> -import Vue from 'vue' +import Vue from 'vue'; + export default Vue.extend({ props: { video: { type: Object, required: true - }, - hide: { - type: Boolean, - default: true } }, + data() { + return { + hide: true + }; + }, computed: { imageStyle(): any { return { - 'background-image': `url(${this.video.url})` + 'background-image': null // TODO `url(${this.video.thumbnailUrl})` }; } - },}) + } +}); </script> <style lang="stylus" scoped> diff --git a/src/client/app/mobile/views/components/mute-button.vue b/src/client/app/mobile/views/components/mute-button.vue index 3cb568615d..316fbda8f1 100644 --- a/src/client/app/mobile/views/components/mute-button.vue +++ b/src/client/app/mobile/views/components/mute-button.vue @@ -41,11 +41,11 @@ export default Vue.extend({ <style lang="stylus" scoped> -@import '~const.styl' + .mk-mute-button display block - user-select none + user-select none cursor pointer padding 0 16px margin 0 @@ -53,27 +53,27 @@ export default Vue.extend({ line-height 36px font-size 14px font-weight bold - color $theme-color + color var(--primary) background transparent outline none - border solid 1px $theme-color + border solid 1px var(--primary) border-radius 36px &:hover - background rgba($theme-color, 0.1) + background var(--primaryAlpha01) &:active - background rgba($theme-color, 0.2) + background var(--primaryAlpha02) &.active - color $theme-color-foreground - background $theme-color + color var(--primaryForeground) + background var(--primary) &:hover - background lighten($theme-color, 10%) - border-color lighten($theme-color, 10%) + background var(--primaryLighten10) + border-color var(--primaryLighten10) &:active - background darken($theme-color, 10%) - border-color darken($theme-color, 10%) + background var(--primaryDarken10) + border-color var(--primaryDarken10) </style> diff --git a/src/client/app/mobile/views/components/note-card.vue b/src/client/app/mobile/views/components/note-card.vue index e8427798cd..de9c9c1450 100644 --- a/src/client/app/mobile/views/components/note-card.vue +++ b/src/client/app/mobile/views/components/note-card.vue @@ -27,17 +27,18 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> -root(isDark) +.mk-note-card display inline-block width 150px //height 120px font-size 12px - background isDark ? #282c37 : #fff + background var(--face) border-radius 4px + box-shadow 0 2px 8px rgba(0, 0, 0, 0.2) > a display block - color isDark ? #fff : #2c3940 + color var(--noteText) &:hover text-decoration none @@ -75,17 +76,11 @@ root(isDark) left 0 width 100% height 20px - background isDark ? linear-gradient(to bottom, rgba(#282c37, 0) 0%, #282c37 100%) : linear-gradient(to bottom, rgba(#fff, 0) 0%, #fff 100%) + background linear-gradient(to bottom, transparent 0%, var(--face) 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.vue b/src/client/app/mobile/views/components/note-detail.vue index f9996f9da6..082f72f1a9 100644 --- a/src/client/app/mobile/views/components/note-detail.vue +++ b/src/client/app/mobile/views/components/note-detail.vue @@ -35,20 +35,26 @@ </div> </header> <div class="body"> - <div class="text"> - <span v-if="p.isHidden" style="opacity: 0.5">(%i18n:@private%)</span> - <span v-if="p.deletedAt" style="opacity: 0.5">(%i18n:@deleted%)</span> - <misskey-flavored-markdown v-if="p.text" :text="p.text" :i="$store.state.i"/> - </div> - <div class="media" v-if="p.media.length > 0"> - <mk-media-list :media-list="p.media" :raw="true"/> - </div> - <mk-poll v-if="p.poll" :note="p"/> - <mk-url-preview v-for="url in urls" :url="url" :key="url" :detail="true"/> - <a class="location" v-if="p.geo" :href="`https://maps.google.com/maps?q=${p.geo.coordinates[1]},${p.geo.coordinates[0]}`" target="_blank">%fa:map-marker-alt% %i18n:@location%</a> - <div class="map" v-if="p.geo" ref="map"></div> - <div class="renote" v-if="p.renote"> - <mk-note-preview :note="p.renote"/> + <p v-if="p.cw != null" class="cw"> + <span class="text" v-if="p.cw != ''">{{ p.cw }}</span> + <mk-cw-button v-model="showContent"/> + </p> + <div class="content" v-show="p.cw == null || showContent"> + <div class="text"> + <span v-if="p.isHidden" style="opacity: 0.5">(%i18n:@private%)</span> + <span v-if="p.deletedAt" style="opacity: 0.5">(%i18n:@deleted%)</span> + <misskey-flavored-markdown v-if="p.text" :text="p.text" :i="$store.state.i"/> + </div> + <div class="files" v-if="p.files.length > 0"> + <mk-media-list :media-list="p.files" :raw="true"/> + </div> + <mk-poll v-if="p.poll" :note="p"/> + <mk-url-preview v-for="url in urls" :url="url" :key="url" :detail="true"/> + <a class="location" v-if="p.geo" :href="`https://maps.google.com/maps?q=${p.geo.coordinates[1]},${p.geo.coordinates[0]}`" target="_blank">%fa:map-marker-alt% %i18n:@location%</a> + <div class="map" v-if="p.geo" ref="map"></div> + <div class="renote" v-if="p.renote"> + <mk-note-preview :note="p.renote"/> + </div> </div> </div> <router-link class="time" :to="p | notePage"> @@ -85,12 +91,16 @@ import parse from '../../../../../mfm/parse'; import MkNoteMenu from '../../../common/views/components/note-menu.vue'; import MkReactionPicker from '../../../common/views/components/reaction-picker.vue'; import XSub from './note.sub.vue'; +import { sum } from '../../../../../prelude/array'; +import noteSubscriber from '../../../common/scripts/note-subscriber'; export default Vue.extend({ components: { XSub }, + mixins: [noteSubscriber('note')], + props: { note: { type: Object, @@ -103,6 +113,7 @@ export default Vue.extend({ data() { return { + showContent: false, conversation: [], conversationFetching: false, replies: [] @@ -113,19 +124,20 @@ export default Vue.extend({ isRenote(): boolean { return (this.note.renote && this.note.text == null && - this.note.mediaIds.length == 0 && + this.note.fileIds.length == 0 && this.note.poll == null); }, + p(): any { return this.isRenote ? this.note.renote : this.note; }, + reactionsCount(): number { return this.p.reactionCounts - ? Object.keys(this.p.reactionCounts) - .map(key => this.p.reactionCounts[key]) - .reduce((a, b) => a + b) + ? sum(Object.values(this.p.reactionCounts)) : 0; }, + urls(): string[] { if (this.p.text) { const ast = parse(this.p.text); @@ -180,16 +192,19 @@ export default Vue.extend({ this.conversation = conversation.reverse(); }); }, + reply() { (this as any).apis.post({ reply: this.p }); }, + renote() { (this as any).apis.post({ renote: this.p }); }, + react() { (this as any).os.new(MkReactionPicker, { source: this.$refs.reactButton, @@ -198,6 +213,7 @@ export default Vue.extend({ big: true }); }, + menu() { (this as any).os.new(MkNoteMenu, { source: this.$refs.menuButton, @@ -210,13 +226,11 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> -@import '~const.styl' - -root(isDark) +.mk-note-detail overflow hidden width 100% text-align left - background isDark ? #282C37 : #fff + background var(--face) border-radius 8px box-shadow 0 0 2px rgba(#000, 0.1) @@ -235,26 +249,26 @@ root(isDark) text-align center color #999 cursor pointer - background isDark ? #21242d : #fafafa + background var(--subNoteBg) outline none border none - border-bottom solid 1px isDark ? #1c2023 : #eef0f2 + border-bottom solid 1px var(--faceDivider) border-radius 6px 6px 0 0 box-shadow none &:hover - background isDark ? #16181d : #f6f6f6 + box-shadow 0 0 0 100px inset rgba(0, 0, 0, 0.05) - &:disabled - color #ccc + &:active + box-shadow 0 0 0 100px inset rgba(0, 0, 0, 0.1) > .conversation > * - border-bottom 1px solid isDark ? #1c2023 : #eef0f2 + border-bottom 1px solid var(--faceDivider) > .renote - color #9dbb00 - background isDark ? linear-gradient(to bottom, #314027 0%, #282c37 100%) : linear-gradient(to bottom, #edfde2 0%, #fff 100%) + color var(--renoteText) + background linear-gradient(to bottom, var(--renoteGradient) 0%, var(--face) 100%) > p margin 0 @@ -277,7 +291,7 @@ root(isDark) padding-top 8px > .reply-to - border-bottom 1px solid isDark ? #1c2023 : #eef0f2 + border-bottom 1px solid var(--faceDivider) > article padding 14px 16px 9px 16px @@ -310,7 +324,7 @@ root(isDark) > .name display inline-block margin .4em 0 - color isDark ? #fff : #627079 + color var(--noteHeaderName) font-size 16px font-weight bold text-align left @@ -323,53 +337,66 @@ root(isDark) display block text-align left margin 0 - color isDark ? #606984 : #ccc + color var(--noteHeaderAcct) > .body padding 8px 0 - > .text + > .cw + cursor default display block margin 0 padding 0 overflow-wrap break-word - font-size 16px - color isDark ? #fff : #717171 + color var(--noteText) - @media (min-width 500px) - font-size 24px + > .text + margin-right 8px - > .renote - margin 8px 0 + > .content - > .mk-note-preview - padding 16px - border dashed 1px #c0dac6 - border-radius 8px + > .text + display block + margin 0 + padding 0 + overflow-wrap break-word + font-size 16px + color var(--noteText) - > .location - margin 4px 0 - font-size 12px - color #ccc + @media (min-width 500px) + font-size 24px - > .map - width 100% - height 200px + > .renote + margin 8px 0 - &:empty - display none + > * + padding 16px + border dashed 1px var(--quoteBorder) + border-radius 8px - > .mk-url-preview - margin-top 8px + > .location + margin 4px 0 + font-size 12px + color #ccc - > .media - > img - display block - max-width 100% + > .map + width 100% + height 200px + + &:empty + display none + + > .mk-url-preview + margin-top 8px + + > .files + > img + display block + max-width 100% > .time font-size 16px - color isDark ? #606984 : #c0c0c0 + color var(--noteHeaderInfo) > footer font-size 1.2em @@ -381,14 +408,14 @@ root(isDark) border none box-shadow none font-size 1em - color isDark ? #606984 : #ddd + color var(--noteActions) cursor pointer &:not(:last-child) margin-right 28px &:hover - color isDark ? #9198af : #666 + color var(--noteActionsHover) > .count display inline @@ -396,16 +423,10 @@ root(isDark) color #999 &.reacted - color $theme-color + color var(--primary) > .replies > * - border-top 1px solid isDark ? #1c2023 : #eef0f2 - -.mk-note-detail[data-darkmode] - root(true) - -.mk-note-detail:not([data-darkmode]) - root(false) + border-top 1px solid var(--faceDivider) </style> diff --git a/src/client/app/mobile/views/components/note-preview.vue b/src/client/app/mobile/views/components/note-preview.vue index 5d56d2d326..525f54998e 100644 --- a/src/client/app/mobile/views/components/note-preview.vue +++ b/src/client/app/mobile/views/components/note-preview.vue @@ -1,10 +1,16 @@ <template> -<div class="mk-note-preview" :class="{ smart: $store.state.device.postStyle == 'smart' }"> +<div class="yohlumlkhizgfkvvscwfcrcggkotpvry" :class="{ smart: $store.state.device.postStyle == 'smart' }"> <mk-avatar class="avatar" :user="note.user" v-if="$store.state.device.postStyle != 'smart'"/> <div class="main"> <mk-note-header class="header" :note="note" :mini="true"/> <div class="body"> - <mk-sub-note-content class="text" :note="note"/> + <p v-if="note.cw != null" class="cw"> + <span class="text" v-if="note.cw != ''">{{ note.cw }}</span> + <mk-cw-button v-model="showContent"/> + </p> + <div class="content" v-show="note.cw == null || showContent"> + <mk-sub-note-content class="text" :note="note"/> + </div> </div> </div> </div> @@ -14,12 +20,23 @@ import Vue from 'vue'; export default Vue.extend({ - props: ['note'] + props: { + note: { + type: Object, + required: true + } + }, + + data() { + return { + showContent: false + }; + } }); </script> <style lang="stylus" scoped> -root(isDark) +.yohlumlkhizgfkvvscwfcrcggkotpvry display flex margin 0 padding 0 @@ -65,16 +82,22 @@ root(isDark) > .body - > .text + > .cw cursor default + display block margin 0 padding 0 - color isDark ? #959ba7 : #717171 + overflow-wrap break-word + color var(--noteText) -.mk-note-preview[data-darkmode] - root(true) + > .text + margin-right 8px -.mk-note-preview:not([data-darkmode]) - root(false) + > .content + > .text + cursor default + margin 0 + padding 0 + color var(--subNoteText) </style> diff --git a/src/client/app/mobile/views/components/note.sub.vue b/src/client/app/mobile/views/components/note.sub.vue index a68aec40a1..24f5be160c 100644 --- a/src/client/app/mobile/views/components/note.sub.vue +++ b/src/client/app/mobile/views/components/note.sub.vue @@ -1,10 +1,16 @@ <template> -<div class="sub" :class="{ smart: $store.state.device.postStyle == 'smart' }"> +<div class="zlrxdaqttccpwhpaagdmkawtzklsccam" :class="{ smart: $store.state.device.postStyle == 'smart' }"> <mk-avatar class="avatar" :user="note.user" v-if="$store.state.device.postStyle != 'smart'"/> <div class="main"> <mk-note-header class="header" :note="note" :mini="true"/> <div class="body"> - <mk-sub-note-content class="text" :note="note"/> + <p v-if="note.cw != null" class="cw"> + <span class="text" v-if="note.cw != ''">{{ note.cw }}</span> + <mk-cw-button v-model="showContent"/> + </p> + <div class="content" v-show="note.cw == null || showContent"> + <mk-sub-note-content class="text" :note="note"/> + </div> </div> </div> </div> @@ -24,16 +30,22 @@ export default Vue.extend({ type: Boolean, default: true } + }, + + data() { + return { + showContent: false + }; } }); </script> <style lang="stylus" scoped> -root(isDark) +.zlrxdaqttccpwhpaagdmkawtzklsccam display flex padding 16px font-size 10px - background isDark ? #21242d : #fcfcfc + background var(--subNoteBg) @media (min-width 350px) font-size 12px @@ -77,20 +89,25 @@ root(isDark) margin-bottom 2px > .body - - > .text + > .cw + cursor default + display block margin 0 padding 0 - color isDark ? #959ba7 : #717171 + overflow-wrap break-word + color var(--noteText) - pre - max-height 120px - font-size 80% + > .text + margin-right 8px -.sub[data-darkmode] - root(true) + > .content + > .text + margin 0 + padding 0 + color var(--subNoteText) -.sub:not([data-darkmode]) - root(false) + pre + max-height 120px + font-size 80% </style> diff --git a/src/client/app/mobile/views/components/note.vue b/src/client/app/mobile/views/components/note.vue index d0cea135f9..f370fbf874 100644 --- a/src/client/app/mobile/views/components/note.vue +++ b/src/client/app/mobile/views/components/note.vue @@ -18,7 +18,7 @@ <div class="body"> <p v-if="p.cw != null" class="cw"> <span class="text" v-if="p.cw != ''">{{ p.cw }}</span> - <span class="toggle" @click="showContent = !showContent">{{ showContent ? '%i18n:@less%' : '%i18n:@more%' }}</span> + <mk-cw-button v-model="showContent"/> </p> <div class="content" v-show="p.cw == null || showContent"> <div class="text"> @@ -28,20 +28,18 @@ <misskey-flavored-markdown v-if="p.text" :text="p.text" :i="$store.state.i" :class="$style.text"/> <a class="rp" v-if="p.renote != null">RP:</a> </div> - <div class="media" v-if="p.media.length > 0"> - <mk-media-list :media-list="p.media"/> + <div class="files" v-if="p.files.length > 0"> + <mk-media-list :media-list="p.files"/> </div> <mk-poll v-if="p.poll" :note="p" ref="pollViewer"/> <mk-url-preview v-for="url in urls" :url="url" :key="url"/> <a class="location" v-if="p.geo" :href="`https://maps.google.com/maps?q=${p.geo.coordinates[1]},${p.geo.coordinates[0]}`" target="_blank">%fa:map-marker-alt% %i18n:@location%</a> <div class="map" v-if="p.geo" ref="map"></div> - <div class="renote" v-if="p.renote"> - <mk-note-preview :note="p.renote"/> - </div> + <div class="renote" v-if="p.renote"><mk-note-preview :note="p.renote"/></div> </div> <span class="app" v-if="p.app">via <b>{{ p.app.name }}</b></span> </div> - <footer> + <footer v-if="p.deletedAt == null"> <mk-reactions-viewer :note="p" ref="reactionsViewer"/> <button @click="reply"> <template v-if="p.reply">%fa:reply-all%</template> @@ -70,19 +68,21 @@ import parse from '../../../../../mfm/parse'; import MkNoteMenu from '../../../common/views/components/note-menu.vue'; import MkReactionPicker from '../../../common/views/components/reaction-picker.vue'; import XSub from './note.sub.vue'; +import { sum } from '../../../../../prelude/array'; +import noteSubscriber from '../../../common/scripts/note-subscriber'; export default Vue.extend({ components: { XSub }, + mixins: [noteSubscriber('note')], + props: ['note'], data() { return { - showContent: false, - connection: null, - connectionId: null + showContent: false }; }, @@ -90,7 +90,7 @@ export default Vue.extend({ isRenote(): boolean { return (this.note.renote && this.note.text == null && - this.note.mediaIds.length == 0 && + this.note.fileIds.length == 0 && this.note.poll == null); }, @@ -100,9 +100,7 @@ export default Vue.extend({ reactionsCount(): number { return this.p.reactionCounts - ? Object.keys(this.p.reactionCounts) - .map(key => this.p.reactionCounts[key]) - .reduce((a, b) => a + b) + ? sum(Object.values(this.p.reactionCounts)) : 0; }, @@ -118,82 +116,7 @@ export default Vue.extend({ } }, - created() { - if (this.$store.getters.isSignedIn) { - this.connection = (this as any).os.stream.getConnection(); - this.connectionId = (this as any).os.stream.use(); - } - }, - - mounted() { - this.capture(true); - - if (this.$store.getters.isSignedIn) { - this.connection.on('_connected_', this.onStreamConnected); - } - - // Draw map - if (this.p.geo) { - const shouldShowMap = this.$store.getters.isSignedIn ? this.$store.state.settings.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]); - const map = new maps.Map(this.$refs.map, { - center: uluru, - zoom: 15 - }); - new maps.Marker({ - position: uluru, - map: map - }); - }); - } - } - }, - - beforeDestroy() { - this.decapture(true); - - if (this.$store.getters.isSignedIn) { - this.connection.off('_connected_', this.onStreamConnected); - (this as any).os.stream.dispose(this.connectionId); - } - }, - methods: { - capture(withHandler = false) { - if (this.$store.getters.isSignedIn) { - this.connection.send({ - type: 'capture', - id: this.p.id - }); - if (withHandler) this.connection.on('note-updated', this.onStreamNoteUpdated); - } - }, - - decapture(withHandler = false) { - if (this.$store.getters.isSignedIn) { - this.connection.send({ - type: 'decapture', - id: this.p.id - }); - if (withHandler) this.connection.off('note-updated', this.onStreamNoteUpdated); - } - }, - - onStreamConnected() { - this.capture(); - }, - - onStreamNoteUpdated(data) { - const note = data.note; - if (note.id == this.note.id) { - this.$emit('update:note', note); - } else if (note.id == this.note.renoteId) { - this.note.renote = note; - } - }, - reply() { (this as any).apis.post({ reply: this.p @@ -227,11 +150,9 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> -@import '~const.styl' - -root(isDark) +.note font-size 12px - border-bottom solid 1px isDark ? #1c2023 : #eaeaea + border-bottom solid 1px var(--faceDivider) &:last-of-type border-bottom none @@ -255,8 +176,8 @@ root(isDark) padding 8px 16px line-height 28px white-space pre - color #9dbb00 - background isDark ? linear-gradient(to bottom, #314027 0%, #282c37 100%) : linear-gradient(to bottom, #edfde2 0%, #fff 100%) + color var(--renoteText) + background linear-gradient(to bottom, var(--renoteGradient) 0%, var(--face) 100%) @media (min-width 500px) padding 16px @@ -348,24 +269,11 @@ root(isDark) margin 0 padding 0 overflow-wrap break-word - color isDark ? #fff : #717171 + color var(--noteText) > .text margin-right 8px - > .toggle - display inline-block - padding 4px 8px - font-size 0.7em - color isDark ? #393f4f : #fff - background isDark ? #687390 : #b1b9c1 - border-radius 2px - cursor pointer - user-select none - - &:hover - background isDark ? #707b97 : #bbc4ce - > .content > .text @@ -373,7 +281,7 @@ root(isDark) margin 0 padding 0 overflow-wrap break-word - color isDark ? #fff : #717171 + color var(--noteText) >>> .title display block @@ -381,7 +289,7 @@ root(isDark) padding 4px font-size 90% text-align center - background isDark ? #2f3944 : #eef1f3 + background var(--mfmTitleBg) border-radius 4px >>> .code @@ -390,31 +298,31 @@ root(isDark) >>> .quote margin 8px padding 6px 12px - color isDark ? #6f808e : #aaa - border-left solid 3px isDark ? #637182 : #eee + color var(--mfmQuote) + border-left solid 3px var(--mfmQuoteLine) > .reply margin-right 8px - color isDark ? #99abbf : #717171 + color var(--noteText) > .rp margin-left 4px font-style oblique - color #a0bf46 + color var(--renoteText) [data-is-me]:after content "you" padding 0 4px margin-left 4px font-size 80% - color $theme-color-foreground - background $theme-color + color var(--primaryForeground) + background var(--primary) border-radius 4px .mk-url-preview margin-top 8px - > .media + > .files > img display block max-width 100% @@ -437,9 +345,9 @@ root(isDark) > .renote margin 8px 0 - > .mk-note-preview + > * padding 16px - border dashed 1px isDark ? #4e945e : #c0dac6 + border dashed 1px var(--quoteBorder) border-radius 8px > .app @@ -454,14 +362,14 @@ root(isDark) border none box-shadow none font-size 1em - color isDark ? #606984 : #ddd + color var(--noteActions) cursor pointer &:not(:last-child) margin-right 28px &:hover - color isDark ? #9198af : #666 + color var(--noteActionsHover) > .count display inline @@ -469,17 +377,7 @@ root(isDark) color #999 &.reacted - color $theme-color - - &.menu - @media (max-width 350px) - display none - -.note[data-darkmode] - root(true) - -.note:not([data-darkmode]) - root(false) + color var(--primary) </style> diff --git a/src/client/app/mobile/views/components/notes.vue b/src/client/app/mobile/views/components/notes.vue index 714e521c0f..8f0a1ef196 100644 --- a/src/client/app/mobile/views/components/notes.vue +++ b/src/client/app/mobile/views/components/notes.vue @@ -14,8 +14,7 @@ </div> <!-- トランジションを有効にするとなぜかメモリリークする --> - <!-- <transition-group name="mk-notes" class="transition"> --> - <div class="transition"> + <component :is="!$store.state.device.reduceMotion ? 'transition-group' : 'div'" name="mk-notes" class="transition" tag="div"> <template v-for="(note, i) in _notes"> <mk-note :note="note" :key="note.id" @update:note="onNoteUpdated(i, $event)"/> <p class="date" :key="note.id + '_date'" v-if="i != notes.length - 1 && note._date != _notes[i + 1]._date"> @@ -23,8 +22,7 @@ <span>%fa:angle-down%{{ _notes[i + 1]._datetext }}</span> </p> </template> - </div> - <!-- </transition-group> --> + </component> <footer v-if="more"> <button @click="loadMore" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }"> @@ -125,7 +123,7 @@ export default Vue.extend({ prepend(note, silent = false) { //#region 弾く const isMyNote = note.userId == this.$store.state.i.id; - const isPureRenote = note.renoteId != null && note.text == null && note.mediaIds.length == 0 && note.poll == null; + const isPureRenote = note.renoteId != null && note.text == null && note.fileIds.length == 0 && note.poll == null; if (this.$store.state.settings.showMyRenotes === false) { if (isMyNote && isPureRenote) { @@ -219,11 +217,9 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> -@import '~const.styl' - -root(isDark) +.mk-notes overflow hidden - background isDark ? #282C37 : #fff + background var(--face) border-radius 8px box-shadow 0 0 2px rgba(#000, 0.1) @@ -245,9 +241,9 @@ root(isDark) 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 + color var(--dateDividerFg) + background var(--dateDividerBg) + border-bottom solid 1px var(--faceDivider) span margin 0 16px @@ -278,7 +274,7 @@ root(isDark) > footer text-align center - border-top solid 1px isDark ? #1c2023 : #eaeaea + border-top solid 1px var(--faceDivider) &:empty display none @@ -295,10 +291,4 @@ root(isDark) &: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 ee90c6b46b..4a09104341 100644 --- a/src/client/app/mobile/views/components/notification.vue +++ b/src/client/app/mobile/views/components/notification.vue @@ -105,7 +105,7 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> -root(isDark) +.mk-notification > .notification padding 16px font-size 12px @@ -154,14 +154,14 @@ root(isDark) > .mk-time margin-left auto - color isDark ? #606984 : #c0c0c0 + color var(--noteHeaderInfo) font-size 0.9em > .note-preview - color isDark ? #fff : #717171 + color var(--noteText) > .note-ref - color isDark ? #fff : #717171 + color var(--noteText) [data-fa] font-size 1em @@ -182,10 +182,4 @@ root(isDark) > div > header i color #888 -.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 9f20c3fb22..e1a2967071 100644 --- a/src/client/app/mobile/views/components/notifications.vue +++ b/src/client/app/mobile/views/components/notifications.vue @@ -1,8 +1,7 @@ <template> <div class="mk-notifications"> <!-- トランジションを有効にするとなぜかメモリリークする --> - <!-- <transition-group name="mk-notifications" class="transition notifications"> --> - <div class="transition notifications"> + <component :is="!$store.state.device.reduceMotion ? 'transition-group' : 'div'" name="mk-notifications" class="transition notifications"> <template v-for="(notification, i) in _notifications"> <mk-notification :notification="notification" :key="notification.id"/> <p class="date" :key="notification.id + '_date'" v-if="i != notifications.length - 1 && notification._date != _notifications[i + 1]._date"> @@ -10,8 +9,7 @@ <span>%fa:angle-down%{{ _notifications[i + 1]._datetext }}</span> </p> </template> - </div> - <!-- </transition-group> --> + </component> <button class="more" v-if="moreNotifications" @click="fetchMoreNotifications" :disabled="fetchingMoreNotifications"> <template v-if="fetchingMoreNotifications">%fa:spinner .pulse .fw%</template> @@ -25,6 +23,7 @@ <script lang="ts"> import Vue from 'vue'; + export default Vue.extend({ data() { return { @@ -32,10 +31,10 @@ export default Vue.extend({ fetchingMoreNotifications: false, notifications: [], moreNotifications: false, - connection: null, - connectionId: null + connection: null }; }, + computed: { _notifications(): any[] { return (this.notifications as any).map(notification => { @@ -47,9 +46,9 @@ export default Vue.extend({ }); } }, + mounted() { - this.connection = (this as any).os.stream.getConnection(); - this.connectionId = (this as any).os.stream.use(); + this.connection = (this as any).os.stream.useSharedConnection('main'); this.connection.on('notification', this.onNotification); @@ -68,10 +67,11 @@ export default Vue.extend({ this.$emit('fetched'); }); }, + beforeDestroy() { - this.connection.off('notification', this.onNotification); - (this as any).os.stream.dispose(this.connectionId); + this.connection.dispose(); }, + methods: { fetchMoreNotifications() { this.fetchingMoreNotifications = true; @@ -92,10 +92,11 @@ export default Vue.extend({ this.fetchingMoreNotifications = false; }); }, + onNotification(notification) { // TODO: ユーザーが画面を見てないと思われるとき(ブラウザやタブがアクティブじゃないなど)は送信しない this.connection.send({ - type: 'read_notification', + type: 'readNotification', id: notification.id }); @@ -106,9 +107,9 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> -root(isDark) +.mk-notifications margin 0 auto - background isDark ? #282C37 :#fff + background var(--face) border-radius 8px box-shadow 0 0 2px rgba(#000, 0.1) overflow hidden @@ -128,7 +129,7 @@ root(isDark) > .notifications > .mk-notification:not(:last-child) - border-bottom solid 1px isDark ? #1c2023 : #eaeaea + border-bottom solid 1px var(--faceDivider) > .date display block @@ -136,9 +137,9 @@ root(isDark) line-height 32px text-align center font-size 0.8em - color isDark ? #666b79 : #aaa - background isDark ? #242731 : #fdfdfd - border-bottom solid 1px isDark ? #1c2023 : #eaeaea + color var(--dateDividerFg) + background var(--dateDividerBg) + border-bottom solid 1px var(--faceDivider) span margin 0 16px @@ -171,10 +172,4 @@ root(isDark) > [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/notify.vue b/src/client/app/mobile/views/components/notify.vue index 6d4a481dbe..5f94b91ddd 100644 --- a/src/client/app/mobile/views/components/notify.vue +++ b/src/client/app/mobile/views/components/notify.vue @@ -1,6 +1,8 @@ <template> -<div class="mk-notify"> - <mk-notification-preview :notification="notification"/> +<div class="mk-notify" :class="pos"> + <div> + <mk-notification-preview :notification="notification"/> + </div> </div> </template> @@ -10,11 +12,16 @@ import * as anime from 'animejs'; export default Vue.extend({ props: ['notification'], + computed: { + pos() { + return this.$store.state.device.mobileNotificationPosition; + } + }, mounted() { this.$nextTick(() => { anime({ targets: this.$el, - bottom: '0px', + [this.pos]: '0px', duration: 500, easing: 'easeOutQuad' }); @@ -22,10 +29,10 @@ export default Vue.extend({ setTimeout(() => { anime({ targets: this.$el, - bottom: '-64px', + [this.pos]: `-${this.$el.offsetHeight}px`, duration: 500, easing: 'easeOutQuad', - complete: () => this.$destroy() + complete: () => this.destroyDom() }); }, 6000); }); @@ -35,15 +42,32 @@ export default Vue.extend({ <style lang="stylus" scoped> .mk-notify + $height = 78px + position fixed - z-index 1024 - bottom -64px + z-index 10000 left 0 + right 0 width 100% - height 64px + max-width 500px + height $height + margin 0 auto + padding 8px pointer-events none - -webkit-backdrop-filter blur(2px) - backdrop-filter blur(2px) - background-color rgba(#000, 0.5) + font-size 80% + + &.bottom + bottom -($height) + + &.top + top -($height) + + > div + height 100% + -webkit-backdrop-filter blur(2px) + backdrop-filter blur(2px) + background-color rgba(#000, 0.5) + border-radius 7px + overflow hidden </style> diff --git a/src/client/app/mobile/views/components/post-form-dialog.vue b/src/client/app/mobile/views/components/post-form-dialog.vue new file mode 100644 index 0000000000..15b36db945 --- /dev/null +++ b/src/client/app/mobile/views/components/post-form-dialog.vue @@ -0,0 +1,126 @@ +<template> +<div class="ulveipglmagnxfgvitaxyszerjwiqmwl"> + <div class="bg" ref="bg"></div> + <div class="main" ref="main"> + <mk-post-form ref="form" + :reply="reply" + :renote="renote" + :initial-text="initialText" + :instant="instant" + @posted="onPosted" + @cancel="onCanceled"/> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import * as anime from 'animejs'; + +export default Vue.extend({ + props: { + reply: { + type: Object, + required: false + }, + renote: { + type: Object, + required: false + }, + initialText: { + type: String, + required: false + }, + instant: { + type: Boolean, + required: false, + default: false + } + }, + + mounted() { + this.$nextTick(() => { + (this.$refs.bg as any).style.pointerEvents = 'auto'; + anime({ + targets: this.$refs.bg, + opacity: 1, + duration: 100, + easing: 'linear' + }); + + anime({ + targets: this.$refs.main, + opacity: 1, + translateY: [-16, 0], + duration: 300, + easing: 'easeOutQuad' + }); + }); + }, + + methods: { + focus() { + this.$refs.form.focus(); + }, + + close() { + (this.$refs.bg as any).style.pointerEvents = 'none'; + anime({ + targets: this.$refs.bg, + opacity: 0, + duration: 300, + easing: 'linear' + }); + + (this.$refs.main as any).style.pointerEvents = 'none'; + anime({ + targets: this.$refs.main, + opacity: 0, + translateY: 16, + duration: 300, + easing: 'easeOutQuad', + complete: () => this.destroyDom() + }); + }, + + onPosted() { + this.$emit('posted'); + this.close(); + }, + + onCanceled() { + this.$emit('cancel'); + this.close(); + } + } +}); +</script> + +<style lang="stylus" scoped> +.ulveipglmagnxfgvitaxyszerjwiqmwl + > .bg + display block + position fixed + z-index 10000 + top 0 + left 0 + width 100% + height 100% + background rgba(#000, 0.7) + opacity 0 + pointer-events none + + > .main + display block + position fixed + z-index 10000 + top 0 + left 0 + right 0 + height 100% + overflow auto + margin 0 auto 0 auto + opacity 0 + transform translateY(-16px) + +</style> diff --git a/src/client/app/mobile/views/components/post-form.vue b/src/client/app/mobile/views/components/post-form.vue index a74df67c0a..3de920cf22 100644 --- a/src/client/app/mobile/views/components/post-form.vue +++ b/src/client/app/mobile/views/components/post-form.vue @@ -4,14 +4,14 @@ <header> <button class="cancel" @click="cancel">%fa:times%</button> <div> - <span class="text-count" :class="{ over: text.length > 1000 }">{{ 1000 - text.length }}</span> + <span class="text-count" :class="{ over: trimmedLength(text) > 1000 }">{{ 1000 - trimmedLength(text) }}</span> <span class="geo" v-if="geo">%fa:map-marker-alt%</span> <button class="submit" :disabled="!canPost" @click="post">{{ submitText }}</button> </div> </header> <div class="form"> - <mk-note-preview v-if="reply" :note="reply"/> - <mk-note-preview v-if="renote" :note="renote"/> + <mk-note-preview class="preview" v-if="reply" :note="reply"/> + <mk-note-preview class="preview" v-if="renote" :note="renote"/> <div v-if="visibility == 'specified'" class="visibleUsers"> <span v-for="u in visibleUsers">{{ u | userName }}<a @click="removeVisibleUser(u)">[x]</a></span> <a @click="addVisibleUser">+%i18n:@add-visible-user%</a> @@ -42,7 +42,7 @@ <span v-if="visibility === 'private'">%fa:lock%</span> </button> </footer> - <input ref="file" class="file" type="file" accept="image/*" multiple="multiple" @change="onChangeFile"/> + <input ref="file" class="file" type="file" multiple="multiple" @change="onChangeFile"/> </div> </div> <div class="hashtags" v-if="recentHashtags.length > 0 && $store.state.settings.suggestRecentHashtags"> @@ -59,6 +59,9 @@ import MkVisibilityChooser from '../../../common/views/components/visibility-cho import getFace from '../../../common/scripts/get-face'; import parse from '../../../../../mfm/parse'; import { host } from '../../../config'; +import { erase, unique } from '../../../../../prelude/array'; +import { length } from 'stringz'; +import parseAcct from '../../../../../misc/acct/parse'; export default Vue.extend({ components: { @@ -94,7 +97,7 @@ export default Vue.extend({ files: [], poll: false, geo: null, - visibility: this.$store.state.device.visibility || 'public', + visibility: this.$store.state.settings.rememberNoteVisibility ? (this.$store.state.device.visibility || this.$store.state.settings.defaultNoteVisibility) : this.$store.state.settings.defaultNoteVisibility, visibleUsers: [], useCw: false, cw: null, @@ -105,9 +108,9 @@ export default Vue.extend({ computed: { draftId(): string { return this.renote - ? 'renote:' + this.renote.id + ? `renote:${this.renote.id}` : this.reply - ? 'reply:' + this.reply.id + ? `reply:${this.reply.id}` : 'note'; }, @@ -170,12 +173,30 @@ export default Vue.extend({ }); } + // 公開以外へのリプライ時は元の公開範囲を引き継ぐ + if (this.reply && ['home', 'followers', 'specified', 'private'].includes(this.reply.visibility)) { + this.visibility = this.reply.visibility; + } + + // ダイレクトへのリプライはリプライ先ユーザーを初期設定 + if (this.reply && this.reply.visibility === 'specified') { + (this as any).api('users/show', { userId: this.reply.userId }).then(user => { + this.visibleUsers.push(user); + }); + } + + this.focus(); + this.$nextTick(() => { this.focus(); }); }, methods: { + trimmedLength(text: string) { + return length(text.trim()); + }, + addTag(tag: string) { insertTextAtCursor(this.$refs.text, ` #${tag} `); }, @@ -198,12 +219,12 @@ export default Vue.extend({ attachMedia(driveFile) { this.files.push(driveFile); - this.$emit('change-attached-media', this.files); + this.$emit('change-attached-files', this.files); }, detachMedia(file) { this.files = this.files.filter(x => x.id != file.id); - this.$emit('change-attached-media', this.files); + this.$emit('change-attached-files', this.files); }, onChangeFile() { @@ -227,7 +248,7 @@ export default Vue.extend({ navigator.geolocation.getCurrentPosition(pos => { this.geo = pos.coords; }, err => { - alert('%i18n:@error%: ' + err.message); + alert(`%i18n:@error%: ${err.message}`); }, { enableHighAccuracy: true }); @@ -250,24 +271,23 @@ export default Vue.extend({ addVisibleUser() { (this as any).apis.input({ title: '%i18n:@username-prompt%' - }).then(username => { - (this as any).api('users/show', { - username - }).then(user => { + }).then(acct => { + if (acct.startsWith('@')) acct = acct.substr(1); + (this as any).api('users/show', parseAcct(acct)).then(user => { this.visibleUsers.push(user); }); }); }, removeVisibleUser(user) { - this.visibleUsers = this.visibleUsers.filter(u => u != user); + this.visibleUsers = erase(user, this.visibleUsers); }, clear() { this.text = ''; this.files = []; this.poll = false; - this.$emit('change-attached-media'); + this.$emit('change-attached-files'); }, post() { @@ -275,7 +295,7 @@ export default Vue.extend({ const viaMobile = this.$store.state.settings.disableViaMobile !== true; (this as any).api('notes/create', { text: this.text == '' ? undefined : this.text, - mediaIds: this.files.length > 0 ? this.files.map(f => f.id) : undefined, + fileIds: this.files.length > 0 ? this.files.map(f => f.id) : undefined, replyId: this.reply ? this.reply.id : undefined, renoteId: this.renote ? this.renote.id : undefined, poll: this.poll ? (this.$refs.poll as any).get() : undefined, @@ -293,9 +313,6 @@ export default Vue.extend({ viaMobile: viaMobile }).then(data => { this.$emit('posted'); - this.$nextTick(() => { - this.$destroy(); - }); }).catch(err => { this.posting = false; }); @@ -303,13 +320,12 @@ export default Vue.extend({ if (this.text && this.text != '') { const hashtags = parse(this.text).filter(x => x.type == 'hashtag').map(x => x.hashtag); const history = JSON.parse(localStorage.getItem('hashtags') || '[]') as string[]; - localStorage.setItem('hashtags', JSON.stringify(hashtags.concat(history).reduce((a, c) => a.includes(c) ? a : [...a, c], []))); + localStorage.setItem('hashtags', JSON.stringify(unique(hashtags.concat(history)))); } }, cancel() { this.$emit('cancel'); - this.$destroy(); }, kao() { @@ -320,9 +336,7 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> -@import '~const.styl' - -root(isDark) +.mk-post-form max-width 500px width calc(100% - 16px) margin 8px auto @@ -338,27 +352,27 @@ root(isDark) margin 32px auto > .form - background isDark ? #282C37 : #fff + background var(--face) border-radius 8px box-shadow 0 0 2px rgba(#000, 0.1) > header z-index 1000 height 50px - box-shadow 0 1px 0 0 isDark ? rgba(#000, 0.2) : rgba(#000, 0.1) + box-shadow 0 1px 0 0 var(--mobilePostFormDivider) > .cancel padding 0 width 50px line-height 50px font-size 24px - color isDark ? #9baec8 : #555 + color var(--text) > div position absolute top 0 right 0 - color #657786 + color var(--text) > .text-count line-height 50px @@ -372,8 +386,8 @@ root(isDark) padding 0 16px line-height 34px vertical-align bottom - color $theme-color-foreground - background $theme-color + color var(--primaryForeground) + background var(--primary) border-radius 4px &:disabled @@ -383,7 +397,7 @@ root(isDark) max-width 500px margin 0 auto - > .mk-note-preview + > .preview padding 16px > .visibleUsers @@ -392,7 +406,7 @@ root(isDark) > span margin-right 16px - color isDark ? #fff : #666 + color var(--text) > input z-index 1 @@ -404,11 +418,11 @@ root(isDark) margin 0 width 100% font-size 16px - color isDark ? #fff : #333 - background isDark ? #191d23 : #fff + color var(--inputText) + background var(--mobilePostFormTextareaBg) border none border-radius 0 - box-shadow 0 1px 0 0 isDark ? rgba(#000, 0.2) : rgba(#000, 0.1) + box-shadow 0 1px 0 0 var(--mobilePostFormDivider) &:disabled opacity 0.5 @@ -464,7 +478,7 @@ root(isDark) width 48px height 48px font-size 20px - color #657786 + color var(--mobilePostFormButton) background transparent outline none border none @@ -477,10 +491,4 @@ root(isDark) > * margin-right 8px -.mk-post-form[data-darkmode] - root(true) - -.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 a4ce49786e..2238edf278 100644 --- a/src/client/app/mobile/views/components/sub-note-content.vue +++ b/src/client/app/mobile/views/components/sub-note-content.vue @@ -7,9 +7,9 @@ <misskey-flavored-markdown v-if="note.text" :text="note.text" :i="$store.state.i"/> <a class="rp" v-if="note.renoteId">RP: ...</a> </div> - <details v-if="note.media.length > 0"> - <summary>({{ '%i18n:@media-count%'.replace('{}', note.media.length) }})</summary> - <mk-media-list :media-list="note.media"/> + <details v-if="note.files.length > 0"> + <summary>({{ '%i18n:@media-count%'.replace('{}', note.files.length) }})</summary> + <mk-media-list :media-list="note.files"/> </details> <details v-if="note.poll"> <summary>%i18n:@poll%</summary> @@ -37,7 +37,7 @@ export default Vue.extend({ > .rp margin-left 4px font-style oblique - color #a0bf46 + color var(--renoteText) mk-poll font-size 80% diff --git a/src/client/app/mobile/views/components/ui.header.vue b/src/client/app/mobile/views/components/ui.header.vue index a616586c56..9793d03a8c 100644 --- a/src/client/app/mobile/views/components/ui.header.vue +++ b/src/client/app/mobile/views/components/ui.header.vue @@ -1,9 +1,9 @@ <template> -<div class="header"> +<div class="header" ref="root"> + <p class="warn" v-if="env != 'production'">%i18n:common.do-not-use-in-production%</p> <mk-special-message/> <div class="main" ref="main"> <div class="backdrop"></div> - <p ref="welcomeback" v-if="$store.getters.isSignedIn">%i18n:@welcome-back%<b>{{ $store.state.i | userName }}</b>%i18n:@adjective%</p> <div class="content" ref="mainContainer"> <button class="nav" @click="$parent.isDrawerOpening = true">%fa:bars%</button> <template v-if="hasUnreadNotification || hasUnreadMessagingMessage || hasGameInvitation">%fa:circle%</template> @@ -20,93 +20,51 @@ <script lang="ts"> import Vue from 'vue'; import * as anime from 'animejs'; +import { env } from '../../../config'; export default Vue.extend({ props: ['func'], + data() { return { hasGameInvitation: false, connection: null, - connectionId: null + env: env }; }, + computed: { hasUnreadNotification(): boolean { return this.$store.getters.isSignedIn && this.$store.state.i.hasUnreadNotification; }, + hasUnreadMessagingMessage(): boolean { return this.$store.getters.isSignedIn && this.$store.state.i.hasUnreadMessagingMessage; } }, + mounted() { - this.$store.commit('setUiHeaderHeight', 48); + this.$store.commit('setUiHeaderHeight', this.$refs.root.offsetHeight); if (this.$store.getters.isSignedIn) { - this.connection = (this as any).os.stream.getConnection(); - this.connectionId = (this as any).os.stream.use(); + this.connection = (this as any).os.stream.useSharedConnection('main'); - this.connection.on('reversi_invited', this.onReversiInvited); + this.connection.on('reversiInvited', this.onReversiInvited); this.connection.on('reversi_no_invites', this.onReversiNoInvites); - - const ago = (new Date().getTime() - new Date(this.$store.state.i.lastUsedAt).getTime()) / 1000; - const isHisasiburi = ago >= 3600; - this.$store.state.i.lastUsedAt = new Date(); - - if (isHisasiburi) { - (this.$refs.welcomeback as any).style.display = 'block'; - (this.$refs.main as any).style.overflow = 'hidden'; - - anime({ - targets: this.$refs.welcomeback, - top: '0', - opacity: 1, - delay: 1000, - duration: 500, - easing: 'easeOutQuad' - }); - - anime({ - targets: this.$refs.mainContainer, - opacity: 0, - delay: 1000, - duration: 500, - easing: 'easeOutQuad' - }); - - setTimeout(() => { - anime({ - targets: this.$refs.welcomeback, - top: '-48px', - opacity: 0, - duration: 500, - complete: () => { - (this.$refs.welcomeback as any).style.display = 'none'; - (this.$refs.main as any).style.overflow = 'initial'; - }, - easing: 'easeInQuad' - }); - - anime({ - targets: this.$refs.mainContainer, - opacity: 1, - duration: 500, - easing: 'easeInQuad' - }); - }, 2500); - } } }, + beforeDestroy() { if (this.$store.getters.isSignedIn) { - this.connection.off('reversi_invited', this.onReversiInvited); - this.connection.off('reversi_no_invites', this.onReversiNoInvites); - (this as any).os.stream.dispose(this.connectionId); + this.connection.dispose(); } }, + methods: { onReversiInvited() { this.hasGameInvitation = true; }, + onReversiNoInvites() { this.hasGameInvitation = false; } @@ -115,9 +73,7 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> -@import '~const.styl' - -root(isDark) +.header $height = 48px position fixed @@ -131,10 +87,19 @@ root(isDark) > .indicator height 3px - background $theme-color + background var(--primary) + + > .warn + display block + margin 0 + padding 4px + text-align center + font-size 12px + background #f00 + color #fff > .main - color rgba(#fff, 0.9) + color var(--mobileHeaderFg) > .backdrop position absolute @@ -144,20 +109,7 @@ root(isDark) height $height -webkit-backdrop-filter blur(12px) backdrop-filter blur(12px) - //background-color rgba(#1b2023, 0.75) - background-color isDark ? #313543 : #595f6f - - > p - display none - position absolute - z-index 1002 - top $height - width 100% - line-height $height - margin 0 - text-align center - color #fff - opacity 0 + background-color var(--mobileHeaderBg) > .content z-index 1001 @@ -176,9 +128,6 @@ root(isDark) overflow hidden text-overflow ellipsis - [data-fa], [data-icon] - margin-right 4px - > img display inline-block vertical-align bottom @@ -207,7 +156,7 @@ root(isDark) left 8px pointer-events none font-size 10px - color $theme-color + color var(--primary) > button:last-child display block @@ -222,10 +171,4 @@ root(isDark) 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 39ea513b76..c9c0c082b2 100644 --- a/src/client/app/mobile/views/components/ui.nav.vue +++ b/src/client/app/mobile/views/components/ui.nav.vue @@ -34,6 +34,12 @@ <li @click="dark"><p><template v-if="$store.state.device.darkmode">%fa:moon%</template><template v-else>%fa:R moon%</template><span>%i18n:@darkmode%</span></p></li> </ul> </div> + <div class="announcements" v-if="announcements && announcements.length > 0"> + <article v-for="announcement in announcements"> + <span v-html="announcement.title" class="title"></span> + <div v-html="announcement.text"></div> + </article> + </div> <a :href="aboutUrl"><p class="about">%i18n:@about%</p></a> </div> </transition> @@ -46,50 +52,60 @@ import { lang } from '../../../config'; export default Vue.extend({ props: ['isOpen'], + data() { return { hasGameInvitation: false, connection: null, - connectionId: null, - aboutUrl: `/docs/${lang}/about` + aboutUrl: `/docs/${lang}/about`, + announcements: [] }; }, + computed: { hasUnreadNotification(): boolean { return this.$store.getters.isSignedIn && this.$store.state.i.hasUnreadNotification; }, + hasUnreadMessagingMessage(): boolean { return this.$store.getters.isSignedIn && this.$store.state.i.hasUnreadMessagingMessage; } }, + mounted() { + (this as any).os.getMeta().then(meta => { + this.announcements = meta.broadcasts; + }); + if (this.$store.getters.isSignedIn) { - this.connection = (this as any).os.stream.getConnection(); - this.connectionId = (this as any).os.stream.use(); + this.connection = (this as any).os.stream.useSharedConnection('main'); - this.connection.on('reversi_invited', this.onReversiInvited); + this.connection.on('reversiInvited', this.onReversiInvited); this.connection.on('reversi_no_invites', this.onReversiNoInvites); } }, + beforeDestroy() { if (this.$store.getters.isSignedIn) { - this.connection.off('reversi_invited', this.onReversiInvited); - this.connection.off('reversi_no_invites', this.onReversiNoInvites); - (this as any).os.stream.dispose(this.connectionId); + this.connection.dispose(); } }, + methods: { search() { const query = window.prompt('%i18n:@search%'); if (query == null || query == '') return; - this.$router.push('/search?q=' + encodeURIComponent(query)); + this.$router.push(`/search?q=${encodeURIComponent(query)}`); }, + onReversiInvited() { this.hasGameInvitation = true; }, + onReversiNoInvites() { this.hasGameInvitation = false; }, + dark() { this.$store.commit('device/set', { key: 'darkmode', @@ -101,10 +117,8 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> -@import '~const.styl' - -root(isDark) - $color = isDark ? #c9d2e0 : #777 +.nav + $color = var(--text) .backdrop position fixed @@ -113,7 +127,7 @@ root(isDark) z-index 1025 width 100% height 100% - background isDark ? rgba(#000, 0.7) : rgba(#000, 0.2) + background var(--mobileNavBackdrop) .body position fixed @@ -124,7 +138,7 @@ root(isDark) height 100% overflow auto -webkit-overflow-scrolling touch - background isDark ? #16191f : #fff + background var(--secondary) .me display block @@ -178,11 +192,11 @@ root(isDark) text-decoration none &[data-active] - color $theme-color-foreground - background $theme-color + color var(--primaryForeground) + background var(--primary) > [data-fa]:last-child - color $theme-color-foreground + color var(--primaryForeground) > [data-fa]:first-child margin-right 0.5em @@ -192,7 +206,7 @@ root(isDark) > [data-fa].circle margin-left 6px font-size 10px - color $theme-color + color var(--primary) > [data-fa]:last-child position absolute @@ -204,6 +218,17 @@ root(isDark) color $color opacity 0.5 + .announcements + > article + background var(--mobileAnnouncement) + color var(--mobileAnnouncementFg) + padding 16px + margin 8px 0 + font-size 12px + + > .title + font-weight bold + .about margin 0 0 8px 0 padding 1em 0 @@ -234,10 +259,4 @@ root(isDark) opacity: 0; } -.nav[data-darkmode] - root(true) - -.nav:not([data-darkmode]) - root(false) - </style> diff --git a/src/client/app/mobile/views/components/ui.vue b/src/client/app/mobile/views/components/ui.vue index 7e2d39f259..b16c246b10 100644 --- a/src/client/app/mobile/views/components/ui.vue +++ b/src/client/app/mobile/views/components/ui.vue @@ -23,33 +23,43 @@ export default Vue.extend({ XHeader, XNav }, + props: ['title'], + data() { return { isDrawerOpening: false, - connection: null, - connectionId: null + connection: null }; }, + + watch: { + '$store.state.uiHeaderHeight'() { + this.$el.style.paddingTop = this.$store.state.uiHeaderHeight + 'px'; + } + }, + mounted() { + this.$el.style.paddingTop = this.$store.state.uiHeaderHeight + 'px'; + if (this.$store.getters.isSignedIn) { - this.connection = (this as any).os.stream.getConnection(); - this.connectionId = (this as any).os.stream.use(); + this.connection = (this as any).os.stream.useSharedConnection('main'); this.connection.on('notification', this.onNotification); } }, + beforeDestroy() { if (this.$store.getters.isSignedIn) { - this.connection.off('notification', this.onNotification); - (this as any).os.stream.dispose(this.connectionId); + this.connection.dispose(); } }, + methods: { onNotification(notification) { // TODO: ユーザーが画面を見てないと思われるとき(ブラウザやタブがアクティブじゃないなど)は送信しない this.connection.send({ - type: 'read_notification', + type: 'readNotification', id: notification.id }); diff --git a/src/client/app/mobile/views/components/user-list-timeline.vue b/src/client/app/mobile/views/components/user-list-timeline.vue index 9b3f11f5c2..97200eb5b3 100644 --- a/src/client/app/mobile/views/components/user-list-timeline.vue +++ b/src/client/app/mobile/views/components/user-list-timeline.vue @@ -6,7 +6,6 @@ <script lang="ts"> import Vue from 'vue'; -import { UserListStream } from '../../../common/scripts/streaming/user-list'; const fetchLimit = 10; diff --git a/src/client/app/mobile/views/components/user-timeline.vue b/src/client/app/mobile/views/components/user-timeline.vue index 6be675c0a7..7cd23d6655 100644 --- a/src/client/app/mobile/views/components/user-timeline.vue +++ b/src/client/app/mobile/views/components/user-timeline.vue @@ -41,7 +41,7 @@ export default Vue.extend({ (this.$refs.timeline as any).init(() => new Promise((res, rej) => { (this as any).api('users/notes', { userId: this.user.id, - withMedia: this.withMedia, + withFiles: this.withMedia, limit: fetchLimit + 1 }).then(notes => { if (notes.length == fetchLimit + 1) { @@ -62,7 +62,7 @@ export default Vue.extend({ const promise = (this as any).api('users/notes', { userId: this.user.id, - withMedia: this.withMedia, + withFiles: this.withMedia, limit: fetchLimit + 1, untilId: (this.$refs.timeline as any).tail().id }); diff --git a/src/client/app/mobile/views/components/users-list.vue b/src/client/app/mobile/views/components/users-list.vue index a57b821293..f06f5245b8 100644 --- a/src/client/app/mobile/views/components/users-list.vue +++ b/src/client/app/mobile/views/components/users-list.vue @@ -65,7 +65,7 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> -@import '~const.styl' + .mk-users-list @@ -87,8 +87,8 @@ export default Vue.extend({ &[data-active] font-weight bold - color $theme-color - border-color $theme-color + color var(--primary) + border-color var(--primary) > span display inline-block diff --git a/src/client/app/mobile/views/components/widget-container.vue b/src/client/app/mobile/views/components/widget-container.vue index a713a10621..2a4025002b 100644 --- a/src/client/app/mobile/views/components/widget-container.vue +++ b/src/client/app/mobile/views/components/widget-container.vue @@ -25,8 +25,8 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> -root(isDark) - background isDark ? #21242f : #eee +.mk-widget-container + background var(--face) border-radius 8px box-shadow 0 4px 16px rgba(#000, 0.1) overflow hidden @@ -35,17 +35,14 @@ root(isDark) background transparent !important box-shadow none !important - &.hideHeader - background isDark ? #21242f : #fff - > header > .title margin 0 padding 8px 10px font-size 15px font-weight normal - color isDark ? #b8c5cc : #465258 - background isDark ? #282c37 : #fff + color var(--faceHeaderText) + background var(--faceHeader) border-radius 8px 8px 0 0 > [data-fa] @@ -65,10 +62,4 @@ root(isDark) font-size 15px color #465258 -.mk-widget-container[data-darkmode] - root(true) - -.mk-widget-container:not([data-darkmode]) - root(false) - </style> diff --git a/src/client/app/mobile/views/pages/drive.vue b/src/client/app/mobile/views/pages/drive.vue index c7cbe0f72e..bf02adca9d 100644 --- a/src/client/app/mobile/views/pages/drive.vue +++ b/src/client/app/mobile/views/pages/drive.vue @@ -1,9 +1,9 @@ <template> <mk-ui> <span slot="header"> - <template v-if="folder">%fa:R folder-open%{{ folder.name }}</template> - <template v-if="file"><mk-file-type-icon data-icon :type="file.type"/>{{ file.name }}</template> - <template v-if="!folder && !file">%fa:cloud%%i18n:@drive%</template> + <template v-if="folder"><span style="margin-right:4px;">%fa:R folder-open%</span>{{ folder.name }}</template> + <template v-if="file"><mk-file-type-icon data-icon :type="file.type" style="margin-right:4px;"/>{{ file.name }}</template> + <template v-if="!folder && !file"><span style="margin-right:4px;">%fa:cloud%</span>%i18n:@drive%</template> </span> <template slot="func"><button @click="fn">%fa:ellipsis-h%</button></template> <mk-drive @@ -11,7 +11,7 @@ :init-folder="initFolder" :init-file="initFile" :is-naked="true" - :top="48" + :top="$store.state.uiHeaderHeight" @begin-fetch="Progress.start()" @fetched-mid="Progress.set(0.5)" @fetched="Progress.done()" @@ -44,7 +44,6 @@ export default Vue.extend({ }, mounted() { document.title = `${(this as any).os.instanceName} Drive`; - document.documentElement.style.background = '#fff'; }, beforeDestroy() { window.removeEventListener('popstate', this.onPopState); @@ -80,7 +79,7 @@ export default Vue.extend({ if (!silent) { // Rewrite URL - history.pushState(null, title, '/i/drive/folder/' + folder.id); + history.pushState(null, title, `/i/drive/folder/${folder.id}`); } document.title = title; @@ -93,7 +92,7 @@ export default Vue.extend({ if (!silent) { // Rewrite URL - history.pushState(null, title, '/i/drive/file/' + file.id); + history.pushState(null, title, `/i/drive/file/${file.id}`); } document.title = title; diff --git a/src/client/app/mobile/views/pages/favorites.vue b/src/client/app/mobile/views/pages/favorites.vue index 6b9aec6a0c..a25f70147b 100644 --- a/src/client/app/mobile/views/pages/favorites.vue +++ b/src/client/app/mobile/views/pages/favorites.vue @@ -1,6 +1,6 @@ <template> <mk-ui> - <span slot="header">%fa:star%%i18n:@title%</span> + <span slot="header"><span style="margin-right:4px;">%fa:star%</span>%i18n:@title%</span> <main> <template v-for="favorite in favorites"> @@ -71,7 +71,7 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> -@import '~const.styl' + main width 100% diff --git a/src/client/app/mobile/views/pages/followers.vue b/src/client/app/mobile/views/pages/followers.vue index 421c150856..601f6670c1 100644 --- a/src/client/app/mobile/views/pages/followers.vue +++ b/src/client/app/mobile/views/pages/followers.vue @@ -49,7 +49,7 @@ export default Vue.extend({ this.user = user; this.fetching = false; - document.title = '%i18n:@followers-of%'.replace('{}', this.name) + ' | ' + (this as any).os.instanceName; + document.title = `${'%i18n:@followers-of%'.replace('{}', this.name)} | ${(this as any).os.instanceName}`; }); }, onLoaded() { diff --git a/src/client/app/mobile/views/pages/following.vue b/src/client/app/mobile/views/pages/following.vue index ff201ff2bd..0efac6110e 100644 --- a/src/client/app/mobile/views/pages/following.vue +++ b/src/client/app/mobile/views/pages/following.vue @@ -48,7 +48,7 @@ export default Vue.extend({ this.user = user; this.fetching = false; - document.title = '%i18n:@followers-of%'.replace('{}', this.name) + ' | ' + (this as any).os.instanceName; + document.title = `${'%i18n:@followers-of%'.replace('{}', this.name)} | ${(this as any).os.instanceName}`; }); }, onLoaded() { diff --git a/src/client/app/mobile/views/pages/games/reversi.vue b/src/client/app/mobile/views/pages/games/reversi.vue index d6849a1c11..7f8f919005 100644 --- a/src/client/app/mobile/views/pages/games/reversi.vue +++ b/src/client/app/mobile/views/pages/games/reversi.vue @@ -1,6 +1,6 @@ <template> <mk-ui> - <span slot="header">%fa:gamepad%%i18n:@reversi%</span> + <span slot="header"><span style="margin-right:4px;">%fa:gamepad%</span>%i18n:@reversi%</span> <mk-reversi :game-id="$route.params.game" @nav="nav" :self-nav="false"/> </mk-ui> </template> @@ -11,15 +11,14 @@ import Vue from 'vue'; export default Vue.extend({ mounted() { document.title = `${(this as any).os.instanceName} %i18n:@reversi%`; - document.documentElement.style.background = '#fff'; }, methods: { nav(game, actualNav) { if (actualNav) { - this.$router.push('/reversi/' + game.id); + this.$router.push(`/reversi/${game.id}`); } else { // TODO: https://github.com/vuejs/vue-router/issues/703 - this.$router.push('/reversi/' + game.id); + this.$router.push(`/reversi/${game.id}`); } } } diff --git a/src/client/app/mobile/views/pages/home.timeline.vue b/src/client/app/mobile/views/pages/home.timeline.vue index 416b006cd8..1979747bf7 100644 --- a/src/client/app/mobile/views/pages/home.timeline.vue +++ b/src/client/app/mobile/views/pages/home.timeline.vue @@ -21,6 +21,9 @@ export default Vue.extend({ src: { type: String, required: true + }, + tagTl: { + required: false } }, @@ -29,10 +32,17 @@ export default Vue.extend({ fetching: true, moreFetching: false, existMore: false, + streamManager: null, connection: null, - connectionId: null, unreadCount: 0, - date: null + date: null, + baseQuery: { + includeMyRenotes: this.$store.state.settings.showMyRenotes, + includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes, + includeLocalRenotes: this.$store.state.settings.showLocalRenotes + }, + query: {}, + endpoint: null }; }, @@ -41,49 +51,67 @@ export default Vue.extend({ return this.$store.state.i.followingCount == 0; }, - stream(): any { - switch (this.src) { - case 'home': return (this as any).os.stream; - case 'local': return (this as any).os.streams.localTimelineStream; - case 'hybrid': return (this as any).os.streams.hybridTimelineStream; - case 'global': return (this as any).os.streams.globalTimelineStream; - } - }, - - endpoint(): string { - switch (this.src) { - case 'home': return 'notes/timeline'; - case 'local': return 'notes/local-timeline'; - case 'hybrid': return 'notes/hybrid-timeline'; - case 'global': return 'notes/global-timeline'; - } - }, - canFetchMore(): boolean { return !this.moreFetching && !this.fetching && this.existMore; } }, mounted() { - this.connection = this.stream.getConnection(); - this.connectionId = this.stream.use(); + const prepend = note => { + (this.$refs.timeline as any).prepend(note); + }; - this.connection.on('note', this.onNote); - if (this.src == 'home') { - this.connection.on('follow', this.onChangeFollowing); - this.connection.on('unfollow', this.onChangeFollowing); + if (this.src == 'tag') { + this.endpoint = 'notes/search_by_tag'; + this.query = { + query: this.tagTl.query + }; + this.connection = (this as any).os.stream.connectToChannel('hashtag', { q: this.tagTl.query }); + this.connection.on('note', prepend); + } else if (this.src == 'home') { + this.endpoint = 'notes/timeline'; + const onChangeFollowing = () => { + this.fetch(); + }; + this.connection = (this as any).os.stream.useSharedConnection('homeTimeline'); + this.connection.on('note', prepend); + this.connection.on('follow', onChangeFollowing); + this.connection.on('unfollow', onChangeFollowing); + } else if (this.src == 'local') { + this.endpoint = 'notes/local-timeline'; + this.connection = (this as any).os.stream.useSharedConnection('localTimeline'); + this.connection.on('note', prepend); + } else if (this.src == 'hybrid') { + this.endpoint = 'notes/hybrid-timeline'; + this.connection = (this as any).os.stream.useSharedConnection('hybridTimeline'); + this.connection.on('note', prepend); + } else if (this.src == 'global') { + this.endpoint = 'notes/global-timeline'; + this.connection = (this as any).os.stream.useSharedConnection('globalTimeline'); + this.connection.on('note', prepend); + } else if (this.src == 'mentions') { + this.endpoint = 'notes/mentions'; + this.connection = (this as any).os.stream.useSharedConnection('main'); + this.connection.on('mention', prepend); + } else if (this.src == 'messages') { + this.endpoint = 'notes/mentions'; + this.query = { + visibility: 'specified' + }; + const onNote = note => { + if (note.visibility == 'specified') { + prepend(note); + } + }; + this.connection = (this as any).os.stream.useSharedConnection('main'); + this.connection.on('mention', onNote); } 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); + this.connection.dispose(); }, methods: { @@ -91,13 +119,10 @@ export default Vue.extend({ this.fetching = true; (this.$refs.timeline as any).init(() => new Promise((res, rej) => { - (this as any).api(this.endpoint, { + (this as any).api(this.endpoint, Object.assign({ limit: fetchLimit + 1, - untilDate: this.date ? this.date.getTime() : undefined, - includeMyRenotes: this.$store.state.settings.showMyRenotes, - includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes, - includeLocalRenotes: this.$store.state.settings.showLocalRenotes - }).then(notes => { + untilDate: this.date ? this.date.getTime() : undefined + }, this.baseQuery, this.query)).then(notes => { if (notes.length == fetchLimit + 1) { notes.pop(); this.existMore = true; @@ -114,13 +139,10 @@ export default Vue.extend({ this.moreFetching = true; - const promise = (this as any).api(this.endpoint, { + const promise = (this as any).api(this.endpoint, Object.assign({ limit: fetchLimit + 1, - untilId: (this.$refs.timeline as any).tail().id, - includeMyRenotes: this.$store.state.settings.showMyRenotes, - includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes, - includeLocalRenotes: this.$store.state.settings.showLocalRenotes - }); + untilId: (this.$refs.timeline as any).tail().id + }, this.baseQuery, this.query)); promise.then(notes => { if (notes.length == fetchLimit + 1) { @@ -135,15 +157,6 @@ export default Vue.extend({ return promise; }, - onNote(note) { - // Prepend a note - (this.$refs.timeline as any).prepend(note); - }, - - onChangeFollowing() { - this.fetch(); - }, - focus() { (this.$refs.timeline as any).focus(); }, diff --git a/src/client/app/mobile/views/pages/home.vue b/src/client/app/mobile/views/pages/home.vue index 706c9cd28b..edba8585bd 100644 --- a/src/client/app/mobile/views/pages/home.vue +++ b/src/client/app/mobile/views/pages/home.vue @@ -1,35 +1,46 @@ <template> <mk-ui> <span slot="header" @click="showNav = true"> - <span> + <span :class="$style.title"> <span v-if="src == 'home'">%fa:home%%i18n:@home%</span> <span v-if="src == 'local'">%fa:R comments%%i18n:@local%</span> <span v-if="src == 'hybrid'">%fa:share-alt%%i18n:@hybrid%</span> <span v-if="src == 'global'">%fa:globe%%i18n:@global%</span> + <span v-if="src == 'mentions'">%fa:at%%i18n:@mentions%</span> + <span v-if="src == 'messages'">%fa:envelope R%%i18n:@messages%</span> <span v-if="src == 'list'">%fa:list%{{ list.title }}</span> + <span v-if="src == 'tag'">%fa:hashtag%{{ tagTl.title }}</span> </span> <span style="margin-left:8px"> <template v-if="!showNav">%fa:angle-down%</template> <template v-else>%fa:angle-up%</template> </span> + <i :class="$style.badge" v-if="$store.state.i.hasUnreadMentions || $store.state.i.hasUnreadSpecifiedNotes">%fa:circle%</i> </span> <template slot="func"> <button @click="fn">%fa:pencil-alt%</button> </template> - <main :data-darkmode="$store.state.device.darkmode"> + <main> <div class="nav" v-if="showNav"> <div class="bg" @click="showNav = false"></div> + <div class="pointer"></div> <div class="body"> <div> <span :data-active="src == 'home'" @click="src = 'home'">%fa:home% %i18n:@home%</span> - <span :data-active="src == 'local'" @click="src = 'local'">%fa:R comments% %i18n:@local%</span> - <span :data-active="src == 'hybrid'" @click="src = 'hybrid'">%fa:share-alt% %i18n:@hybrid%</span> + <span :data-active="src == 'local'" @click="src = 'local'" v-if="enableLocalTimeline">%fa:R comments% %i18n:@local%</span> + <span :data-active="src == 'hybrid'" @click="src = 'hybrid'" v-if="enableLocalTimeline">%fa:share-alt% %i18n:@hybrid%</span> <span :data-active="src == 'global'" @click="src = 'global'">%fa:globe% %i18n:@global%</span> + <div class="hr"></div> + <span :data-active="src == 'mentions'" @click="src = 'mentions'">%fa:at% %i18n:@mentions%<i class="badge" v-if="$store.state.i.hasUnreadMentions">%fa:circle%</i></span> + <span :data-active="src == 'messages'" @click="src = 'messages'">%fa:envelope R% %i18n:@messages%<i class="badge" v-if="$store.state.i.hasUnreadSpecifiedNotes">%fa:circle%</i></span> <template v-if="lists"> + <div class="hr" v-if="lists.length > 0"></div> <span v-for="l in lists" :data-active="src == 'list' && list == l" @click="src = 'list'; list = l" :key="l.id">%fa:list% {{ l.title }}</span> </template> + <div class="hr" v-if="$store.state.settings.tagTimelines && $store.state.settings.tagTimelines.length > 0"></div> + <span v-for="tl in $store.state.settings.tagTimelines" :data-active="src == 'tag' && tagTl == tl" @click="src = 'tag'; tagTl = tl" :key="tl.id">%fa:hashtag% {{ tl.title }}</span> </div> </div> </div> @@ -39,6 +50,9 @@ <x-tl v-if="src == 'local'" ref="tl" key="local" src="local"/> <x-tl v-if="src == 'hybrid'" ref="tl" key="hybrid" src="hybrid"/> <x-tl v-if="src == 'global'" ref="tl" key="global" src="global"/> + <x-tl v-if="src == 'mentions'" ref="tl" key="mentions" src="mentions"/> + <x-tl v-if="src == 'messages'" ref="tl" key="messages" src="messages"/> + <x-tl v-if="src == 'tag'" ref="tl" key="tag" src="tag" :tag-tl="tagTl"/> <mk-user-list-timeline v-if="src == 'list'" ref="tl" :key="list.id" :list="list"/> </div> </main> @@ -60,7 +74,9 @@ export default Vue.extend({ src: 'home', list: null, lists: null, - showNav: false + tagTl: null, + showNav: false, + enableLocalTimeline: false }; }, @@ -70,9 +86,16 @@ export default Vue.extend({ this.saveSrc(); }, - list() { + list(x) { this.showNav = false; this.saveSrc(); + if (x != null) this.tagTl = null; + }, + + tagTl(x) { + this.showNav = false; + this.saveSrc(); + if (x != null) this.list = null; }, showNav(v) { @@ -85,10 +108,16 @@ export default Vue.extend({ }, created() { + (this as any).os.getMeta().then(meta => { + this.enableLocalTimeline = !meta.disableLocalTimeline; + }); + if (this.$store.state.device.tl) { this.src = this.$store.state.device.tl.src; if (this.src == 'list') { this.list = this.$store.state.device.tl.arg; + } else if (this.src == 'tag') { + this.tagTl = this.$store.state.device.tl.arg; } } else if (this.$store.state.i.followingCount == 0) { this.src = 'hybrid'; @@ -113,7 +142,7 @@ export default Vue.extend({ saveSrc() { this.$store.commit('device/setTl', { src: this.src, - arg: this.list + arg: this.src == 'list' ? this.list : this.tagTl }); }, @@ -125,10 +154,28 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> -@import '~const.styl' - -root(isDark) +main > .nav + > .pointer + position fixed + z-index 10002 + top 56px + left 0 + right 0 + + $size = 16px + + &:after + content "" + display block + position absolute + top -($size * 2) + left s('calc(50% - %s)', $size) + border-top solid $size transparent + border-left solid $size transparent + border-right solid $size transparent + border-bottom solid $size var(--popupBg) + > .bg position fixed z-index 10000 @@ -145,38 +192,37 @@ root(isDark) left 0 right 0 width 300px + max-height calc(100% - 70px) margin 0 auto - background isDark ? #272f3a : #fff + overflow auto + -webkit-overflow-scrolling touch + background var(--popupBg) border-radius 8px box-shadow 0 0 16px rgba(#000, 0.1) - $balloon-size = 16px - - &: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 - > div padding 8px 0 - > * + > .hr + margin 8px 0 + border-top solid 1px var(--faceDivider) + + > *:not(.hr) display block padding 8px 16px - color isDark ? #cdd0d8 : #666 + color var(--text) &[data-active] - color $theme-color-foreground - background $theme-color + color var(--primaryForeground) + background var(--primary) &:not([data-active]):hover - background isDark ? #353e4a : #eee + background var(--mobileHomeTlItemHover) + + > .badge + margin-left 6px + font-size 10px + color var(--primary) > .tl max-width 680px @@ -189,10 +235,17 @@ root(isDark) @media (min-width 600px) padding 32px -main[data-darkmode] - root(true) +</style> + +<style lang="stylus" module> +.title + i + margin-right 4px -main:not([data-darkmode]) - root(false) +.badge + margin-left 6px + font-size 10px + color var(--primary) + vertical-align middle </style> diff --git a/src/client/app/mobile/views/pages/messaging-room.vue b/src/client/app/mobile/views/pages/messaging-room.vue index 401397d856..750ba26294 100644 --- a/src/client/app/mobile/views/pages/messaging-room.vue +++ b/src/client/app/mobile/views/pages/messaging-room.vue @@ -1,7 +1,7 @@ <template> <mk-ui> <span slot="header"> - <template v-if="user">%fa:R comments%{{ user | userName }}</template> + <template v-if="user"><span style="margin-right:4px;">%fa:R comments%</span>{{ user | userName }}</template> <template v-else><mk-ellipsis/></template> </span> <mk-messaging-room v-if="!fetching" :user="user" :is-naked="true"/> diff --git a/src/client/app/mobile/views/pages/messaging.vue b/src/client/app/mobile/views/pages/messaging.vue index 3883505281..98ae79fe6c 100644 --- a/src/client/app/mobile/views/pages/messaging.vue +++ b/src/client/app/mobile/views/pages/messaging.vue @@ -1,6 +1,6 @@ <template> <mk-ui> - <span slot="header">%fa:R comments%%i18n:@messaging%</span> + <span slot="header"><span style="margin-right:4px;">%fa:R comments%</span>%i18n:@messaging%</span> <mk-messaging @navigate="navigate" :header-top="48"/> </mk-ui> </template> diff --git a/src/client/app/mobile/views/pages/note.vue b/src/client/app/mobile/views/pages/note.vue index fee60b350e..d7307c79a8 100644 --- a/src/client/app/mobile/views/pages/note.vue +++ b/src/client/app/mobile/views/pages/note.vue @@ -1,6 +1,6 @@ <template> <mk-ui> - <span slot="header">%fa:R sticky-note%%i18n:@title%</span> + <span slot="header"><span style="margin-right:4px;">%fa:R sticky-note%</span>%i18n:@title%</span> <main v-if="!fetching"> <div> <mk-note-detail :note="note"/> diff --git a/src/client/app/mobile/views/pages/notifications.vue b/src/client/app/mobile/views/pages/notifications.vue index 4d3c8ee534..ce33332faf 100644 --- a/src/client/app/mobile/views/pages/notifications.vue +++ b/src/client/app/mobile/views/pages/notifications.vue @@ -1,6 +1,6 @@ <template> <mk-ui> - <span slot="header">%fa:R bell%%i18n:@notifications%</span> + <span slot="header"><span style="margin-right:4px;">%fa:R bell%</span>%i18n:@notifications%</span> <template slot="func"><button @click="fn">%fa:check%</button></template> <main> @@ -34,7 +34,7 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> -@import '~const.styl' + main width 100% diff --git a/src/client/app/mobile/views/pages/received-follow-requests.vue b/src/client/app/mobile/views/pages/received-follow-requests.vue index 77938c3d60..beaf6bba57 100644 --- a/src/client/app/mobile/views/pages/received-follow-requests.vue +++ b/src/client/app/mobile/views/pages/received-follow-requests.vue @@ -52,8 +52,6 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> -@import '~const.styl' - main width 100% max-width 680px @@ -69,7 +67,7 @@ main > div display flex padding 16px - border solid 1px isDark ? #1c2023 : #eee + border solid 1px var(--faceDivider) border-radius 4px > span diff --git a/src/client/app/mobile/views/pages/selectdrive.vue b/src/client/app/mobile/views/pages/selectdrive.vue index 1a162b346c..c098b8c65e 100644 --- a/src/client/app/mobile/views/pages/selectdrive.vue +++ b/src/client/app/mobile/views/pages/selectdrive.vue @@ -5,7 +5,7 @@ <button class="upload" @click="upload">%fa:upload%</button> <button v-if="multiple" class="ok" @click="ok">%fa:check%</button> </header> - <mk-drive ref="browser" select-file :multiple="multiple" is-naked :top="42"/> + <mk-drive ref="browser" select-file :multiple="multiple" is-naked :top="$store.state.uiHeaderHeight"/> </div> </template> diff --git a/src/client/app/mobile/views/pages/settings.vue b/src/client/app/mobile/views/pages/settings.vue index 7437eb8b47..94fa38cec9 100644 --- a/src/client/app/mobile/views/pages/settings.vue +++ b/src/client/app/mobile/views/pages/settings.vue @@ -1,8 +1,8 @@ <template> <mk-ui> - <span slot="header">%fa:cog%%i18n:@settings%</span> - <main :data-darkmode="$store.state.device.darkmode"> - <div class="signin-as" v-html="'%i18n:@signed-in-as%'.replace('{}', '<b>' + name + '</b>')"></div> + <span slot="header"><span style="margin-right:4px;">%fa:cog%</span>%i18n:@settings%</span> + <main> + <div class="signin-as" v-html="'%i18n:@signed-in-as%'.replace('{}', `<b>${name}</b>`)"></div> <div> <x-profile/> @@ -10,80 +10,127 @@ <ui-card> <div slot="title">%fa:palette% %i18n:@design%</div> - <ui-switch v-model="darkmode">%i18n:@dark-mode%</ui-switch> - <ui-switch v-model="$store.state.settings.circleIcons" @change="onChangeCircleIcons">%i18n:@circle-icons%</ui-switch> - <ui-switch v-model="$store.state.settings.iLikeSushi" @change="onChangeILikeSushi">%i18n:common.i-like-sushi%</ui-switch> - <ui-switch v-model="$store.state.settings.disableAnimatedMfm" @change="onChangeDisableAnimatedMfm">%i18n:common.disable-animated-mfm%</ui-switch> - <ui-switch v-model="$store.state.settings.games.reversi.showBoardLabels" @change="onChangeReversiBoardLabels">%i18n:common.show-reversi-board-labels%</ui-switch> - <ui-switch v-model="$store.state.settings.games.reversi.useContrastStones" @change="onChangeUseContrastReversiStones">%i18n:common.use-contrast-reversi-stones%</ui-switch> + <section> + <ui-switch v-model="darkmode">%i18n:@dark-mode%</ui-switch> + <ui-switch v-model="circleIcons">%i18n:@circle-icons%</ui-switch> + <ui-switch v-model="reduceMotion">%i18n:common.reduce-motion% (%i18n:common.this-setting-is-this-device-only%)</ui-switch> + <ui-switch v-model="contrastedAcct">%i18n:@contrasted-acct%</ui-switch> + <ui-switch v-model="showFullAcct">%i18n:common.show-full-acct%</ui-switch> + <ui-switch v-model="iLikeSushi">%i18n:common.i-like-sushi%</ui-switch> + <ui-switch v-model="disableAnimatedMfm">%i18n:common.disable-animated-mfm%</ui-switch> + <ui-switch v-model="alwaysShowNsfw">%i18n:common.always-show-nsfw% (%i18n:common.this-setting-is-this-device-only%)</ui-switch> + <ui-switch v-model="games_reversi_showBoardLabels">%i18n:common.show-reversi-board-labels%</ui-switch> + <ui-switch v-model="games_reversi_useContrastStones">%i18n:common.use-contrast-reversi-stones%</ui-switch> + </section> - <div> - <div>%i18n:@timeline%</div> - <ui-switch v-model="$store.state.settings.showReplyTarget" @change="onChangeShowReplyTarget">%i18n:@show-reply-target%</ui-switch> - <ui-switch v-model="$store.state.settings.showMyRenotes" @change="onChangeShowMyRenotes">%i18n:@show-my-renotes%</ui-switch> - <ui-switch v-model="$store.state.settings.showRenotedMyNotes" @change="onChangeShowRenotedMyNotes">%i18n:@show-renoted-my-notes%</ui-switch> - <ui-switch v-model="$store.state.settings.showLocalRenotes" @change="onChangeShowLocalRenotes">%i18n:@show-local-renotes%</ui-switch> - </div> + <section> + <header>%i18n:@theme%</header> + <div> + <mk-theme/> + </div> + </section> - <div> - <div>%i18n:@post-style%</div> + <section> + <header>%i18n:@timeline%</header> + <div> + <ui-switch v-model="showReplyTarget">%i18n:@show-reply-target%</ui-switch> + <ui-switch v-model="showMyRenotes">%i18n:@show-my-renotes%</ui-switch> + <ui-switch v-model="showRenotedMyNotes">%i18n:@show-renoted-my-notes%</ui-switch> + <ui-switch v-model="showLocalRenotes">%i18n:@show-local-renotes%</ui-switch> + </div> + </section> + + <section> + <header>%i18n:@post-style%</header> <ui-radio v-model="postStyle" value="standard">%i18n:@post-style-standard%</ui-radio> <ui-radio v-model="postStyle" value="smart">%i18n:@post-style-smart%</ui-radio> - </div> + </section> + + <section> + <header>%i18n:@notification-position%</header> + <ui-radio v-model="mobileNotificationPosition" value="bottom">%i18n:@notification-position-bottom%</ui-radio> + <ui-radio v-model="mobileNotificationPosition" value="top">%i18n:@notification-position-top%</ui-radio> + </section> </ui-card> <ui-card> <div slot="title">%fa:cog% %i18n:@behavior%</div> - <ui-switch v-model="$store.state.settings.fetchOnScroll" @change="onChangeFetchOnScroll">%i18n:@fetch-on-scroll%</ui-switch> - <ui-switch v-model="$store.state.settings.disableViaMobile" @change="onChangeDisableViaMobile">%i18n:@disable-via-mobile%</ui-switch> - <ui-switch v-model="loadRawImages">%i18n:@load-raw-images%</ui-switch> - <ui-switch v-model="$store.state.settings.loadRemoteMedia" @change="onChangeLoadRemoteMedia">%i18n:@load-remote-media%</ui-switch> - <ui-switch v-model="lightmode">%i18n:@i-am-under-limited-internet%</ui-switch> + + <section> + <ui-switch v-model="fetchOnScroll">%i18n:@fetch-on-scroll%</ui-switch> + <ui-switch v-model="disableViaMobile">%i18n:@disable-via-mobile%</ui-switch> + <ui-switch v-model="loadRawImages">%i18n:@load-raw-images%</ui-switch> + <ui-switch v-model="loadRemoteMedia">%i18n:@load-remote-media%</ui-switch> + <ui-switch v-model="lightmode">%i18n:@i-am-under-limited-internet%</ui-switch> + </section> + + <section> + <header>%i18n:@note-visibility%</header> + <ui-switch v-model="rememberNoteVisibility">%i18n:@remember-note-visibility%</ui-switch> + <section> + <header>%i18n:@default-note-visibility%</header> + <ui-select v-model="defaultNoteVisibility"> + <option value="public">%i18n:common.note-visibility.public%</option> + <option value="home">%i18n:common.note-visibility.home%</option> + <option value="followers">%i18n:common.note-visibility.followers%</option> + <option value="specified">%i18n:common.note-visibility.specified%</option> + <option value="private">%i18n:common.note-visibility.private%</option> + </ui-select> + </section> + </section> </ui-card> <ui-card> <div slot="title">%fa:volume-up% %i18n:@sound%</div> - <ui-switch v-model="enableSounds">%i18n:@enable-sounds%</ui-switch> + <section> + <ui-switch v-model="enableSounds">%i18n:@enable-sounds%</ui-switch> + </section> </ui-card> <ui-card> <div slot="title">%fa:language% %i18n:@lang%</div> - <ui-select v-model="lang" placeholder="%i18n:@auto%"> - <optgroup label="%i18n:@recommended%"> - <option value="">%i18n:@auto%</option> - </optgroup> + <section class="fit-top"> + <ui-select v-model="lang" placeholder="%i18n:@auto%"> + <optgroup label="%i18n:@recommended%"> + <option value="">%i18n:@auto%</option> + </optgroup> - <optgroup label="%i18n:@specify-language%"> - <option v-for="x in langs" :value="x[0]" :key="x[0]">{{ x[1] }}</option> - </optgroup> - </ui-select> - <span>%fa:info-circle% %i18n:@lang-tip%</span> + <optgroup label="%i18n:@specify-language%"> + <option v-for="x in langs" :value="x[0]" :key="x[0]">{{ x[1] }}</option> + </optgroup> + </ui-select> + <span>%fa:info-circle% %i18n:@lang-tip%</span> + </section> </ui-card> <ui-card> <div slot="title">%fa:B twitter% %i18n:@twitter%</div> - <p class="account" v-if="$store.state.i.twitter"><a :href="`https://twitter.com/${$store.state.i.twitter.screenName}`" target="_blank">@{{ $store.state.i.twitter.screenName }}</a></p> - <p> - <a :href="`${apiUrl}/connect/twitter`" target="_blank">{{ $store.state.i.twitter ? '%i18n:@twitter-reconnect%' : '%i18n:@twitter-connect%' }}</a> - <span v-if="$store.state.i.twitter"> or </span> - <a :href="`${apiUrl}/disconnect/twitter`" target="_blank" v-if="$store.state.i.twitter">%i18n:@twitter-disconnect%</a> - </p> + <section> + <p class="account" v-if="$store.state.i.twitter"><a :href="`https://twitter.com/${$store.state.i.twitter.screenName}`" target="_blank">@{{ $store.state.i.twitter.screenName }}</a></p> + <p> + <a :href="`${apiUrl}/connect/twitter`" target="_blank">{{ $store.state.i.twitter ? '%i18n:@twitter-reconnect%' : '%i18n:@twitter-connect%' }}</a> + <span v-if="$store.state.i.twitter"> or </span> + <a :href="`${apiUrl}/disconnect/twitter`" target="_blank" v-if="$store.state.i.twitter">%i18n:@twitter-disconnect%</a> + </p> + </section> </ui-card> <ui-card> <div slot="title">%fa:sync-alt% %i18n:@update%</div> - <div>%i18n:@version% <i>{{ version }}</i></div> - <template v-if="latestVersion !== undefined"> - <div>%i18n:@latest-version% <i>{{ latestVersion ? latestVersion : version }}</i></div> - </template> - <ui-button @click="checkForUpdate" :disabled="checkingForUpdate"> - <template v-if="checkingForUpdate">%i18n:@update-checking%<mk-ellipsis/></template> - <template v-else>%i18n:@check-for-updates%</template> - </ui-button> + <section> + <div>%i18n:@version% <i>{{ version }}</i></div> + <template v-if="latestVersion !== undefined"> + <div>%i18n:@latest-version% <i>{{ latestVersion ? latestVersion : version }}</i></div> + </template> + <ui-button @click="checkForUpdate" :disabled="checkingForUpdate"> + <template v-if="checkingForUpdate">%i18n:@update-checking%<mk-ellipsis/></template> + <template v-else>%i18n:@check-for-updates%</template> + </ui-button> + </section> </ui-card> </div> @@ -129,11 +176,26 @@ export default Vue.extend({ set(value) { this.$store.commit('device/set', { key: 'darkmode', value }); } }, + reduceMotion: { + get() { return this.$store.state.device.reduceMotion; }, + set(value) { this.$store.commit('device/set', { key: 'reduceMotion', value }); } + }, + + alwaysShowNsfw: { + get() { return this.$store.state.device.alwaysShowNsfw; }, + set(value) { this.$store.commit('device/set', { key: 'alwaysShowNsfw', value }); } + }, + postStyle: { get() { return this.$store.state.device.postStyle; }, set(value) { this.$store.commit('device/set', { key: 'postStyle', value }); } }, + mobileNotificationPosition: { + get() { return this.$store.state.device.mobileNotificationPosition; }, + set(value) { this.$store.commit('device/set', { key: 'mobileNotificationPosition', value }); } + }, + lightmode: { get() { return this.$store.state.device.lightmode; }, set(value) { this.$store.commit('device/set', { key: 'lightmode', value }); } @@ -153,99 +215,95 @@ export default Vue.extend({ get() { return this.$store.state.device.enableSounds; }, set(value) { this.$store.commit('device/set', { key: 'enableSounds', value }); } }, - }, - mounted() { - document.title = '%i18n:@settings%'; - }, + fetchOnScroll: { + get() { return this.$store.state.settings.fetchOnScroll; }, + set(value) { this.$store.dispatch('settings/set', { key: 'fetchOnScroll', value }); } + }, - methods: { - signout() { - (this as any).os.signout(); + rememberNoteVisibility: { + get() { return this.$store.state.settings.rememberNoteVisibility; }, + set(value) { this.$store.dispatch('settings/set', { key: 'rememberNoteVisibility', value }); } }, - onChangeFetchOnScroll(v) { - this.$store.dispatch('settings/set', { - key: 'fetchOnScroll', - value: v - }); + disableViaMobile: { + get() { return this.$store.state.settings.disableViaMobile; }, + set(value) { this.$store.dispatch('settings/set', { key: 'disableViaMobile', value }); } }, - onChangeDisableViaMobile(v) { - this.$store.dispatch('settings/set', { - key: 'disableViaMobile', - value: v - }); + loadRemoteMedia: { + get() { return this.$store.state.settings.loadRemoteMedia; }, + set(value) { this.$store.dispatch('settings/set', { key: 'loadRemoteMedia', value }); } }, - onChangeLoadRemoteMedia(v) { - this.$store.dispatch('settings/set', { - key: 'loadRemoteMedia', - value: v - }); + circleIcons: { + get() { return this.$store.state.settings.circleIcons; }, + set(value) { this.$store.dispatch('settings/set', { key: 'circleIcons', value }); } }, - onChangeCircleIcons(v) { - this.$store.dispatch('settings/set', { - key: 'circleIcons', - value: v - }); + contrastedAcct: { + get() { return this.$store.state.settings.contrastedAcct; }, + set(value) { this.$store.dispatch('settings/set', { key: 'contrastedAcct', value }); } }, - onChangeILikeSushi(v) { - this.$store.dispatch('settings/set', { - key: 'iLikeSushi', - value: v - }); + showFullAcct: { + get() { return this.$store.state.settings.showFullAcct; }, + set(value) { this.$store.dispatch('settings/set', { key: 'showFullAcct', value }); } }, - onChangeReversiBoardLabels(v) { - this.$store.dispatch('settings/set', { - key: 'games.reversi.showBoardLabels', - value: v - }); + iLikeSushi: { + get() { return this.$store.state.settings.iLikeSushi; }, + set(value) { this.$store.dispatch('settings/set', { key: 'iLikeSushi', value }); } }, - onChangeUseContrastReversiStones(v) { - this.$store.dispatch('settings/set', { - key: 'games.reversi.useContrastStones', - value: v - }); + games_reversi_showBoardLabels: { + get() { return this.$store.state.settings.games.reversi.showBoardLabels; }, + set(value) { this.$store.dispatch('settings/set', { key: 'games.reversi.showBoardLabels', value }); } }, - onChangeDisableAnimatedMfm(v) { - this.$store.dispatch('settings/set', { - key: 'disableAnimatedMfm', - value: v - }); + games_reversi_useContrastStones: { + get() { return this.$store.state.settings.games.reversi.useContrastStones; }, + set(value) { this.$store.dispatch('settings/set', { key: 'games.reversi.useContrastStones', value }); } }, - onChangeShowReplyTarget(v) { - this.$store.dispatch('settings/set', { - key: 'showReplyTarget', - value: v - }); + disableAnimatedMfm: { + get() { return this.$store.state.settings.disableAnimatedMfm; }, + set(value) { this.$store.dispatch('settings/set', { key: 'disableAnimatedMfm', value }); } }, - onChangeShowMyRenotes(v) { - this.$store.dispatch('settings/set', { - key: 'showMyRenotes', - value: v - }); + showReplyTarget: { + get() { return this.$store.state.settings.showReplyTarget; }, + set(value) { this.$store.dispatch('settings/set', { key: 'showReplyTarget', value }); } }, - onChangeShowRenotedMyNotes(v) { - this.$store.dispatch('settings/set', { - key: 'showRenotedMyNotes', - value: v - }); + showMyRenotes: { + get() { return this.$store.state.settings.showMyRenotes; }, + set(value) { this.$store.dispatch('settings/set', { key: 'showMyRenotes', value }); } }, - onChangeShowLocalRenotes(v) { - this.$store.dispatch('settings/set', { - key: 'showLocalRenotes', - value: v - }); + showRenotedMyNotes: { + get() { return this.$store.state.settings.showRenotedMyNotes; }, + set(value) { this.$store.dispatch('settings/set', { key: 'showRenotedMyNotes', value }); } + }, + + showLocalRenotes: { + get() { return this.$store.state.settings.showLocalRenotes; }, + set(value) { this.$store.dispatch('settings/set', { key: 'showLocalRenotes', value }); } + }, + + defaultNoteVisibility: { + get() { return this.$store.state.settings.defaultNoteVisibility; }, + set(value) { this.$store.dispatch('settings/set', { key: 'defaultNoteVisibility', value }); } + }, + }, + + mounted() { + document.title = '%i18n:@settings%'; + }, + + methods: { + signout() { + (this as any).os.signout(); }, checkForUpdate() { @@ -271,36 +329,31 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> -root(isDark) +main margin 0 auto - max-width 500px + max-width 600px width 100% > .signin-as margin 16px padding 16px text-align center - color isDark ? #49ab63 : #2c662d - background isDark ? #273c34 : #fcfff5 + color var(--mobileSignedInAsFg) + background var(--mobileSignedInAsBg) box-shadow 0 3px 1px -2px rgba(#000, 0.2), 0 2px 2px 0 rgba(#000, 0.14), 0 1px 5px 0 rgba(#000, 0.12) > .signout margin 16px padding 16px text-align center - color isDark ? #ff5f56 : #cc2727 - background isDark ? #652222 : #fff6f5 + color var(--mobileSignedInAsFg) + background var(--mobileSignedInAsBg) box-shadow 0 3px 1px -2px rgba(#000, 0.2), 0 2px 2px 0 rgba(#000, 0.14), 0 1px 5px 0 rgba(#000, 0.12) > footer margin 16px text-align center - color isDark ? #c9d2e0 : #888 - -main[data-darkmode] - root(true) - -main:not([data-darkmode]) - root(false) + color var(--text) + opacity 0.7 </style> diff --git a/src/client/app/mobile/views/pages/settings/settings.profile.vue b/src/client/app/mobile/views/pages/settings/settings.profile.vue index 3b797cdde1..127f531902 100644 --- a/src/client/app/mobile/views/pages/settings/settings.profile.vue +++ b/src/client/app/mobile/views/pages/settings/settings.profile.vue @@ -2,47 +2,64 @@ <ui-card> <div slot="title">%fa:user% %i18n:@title%</div> - <ui-form :disabled="saving"> - <ui-input v-model="name" :max="30"> - <span>%i18n:@name%</span> - </ui-input> + <section class="fit-top"> + <ui-form :disabled="saving"> + <ui-input v-model="name" :max="30"> + <span>%i18n:@name%</span> + </ui-input> - <ui-input v-model="username" readonly> - <span>%i18n:@account%</span> - <span slot="prefix">@</span> - <span slot="suffix">@{{ host }}</span> - </ui-input> + <ui-input v-model="username" readonly> + <span>%i18n:@account%</span> + <span slot="prefix">@</span> + <span slot="suffix">@{{ host }}</span> + </ui-input> - <ui-input v-model="location"> - <span>%i18n:@location%</span> - <span slot="prefix">%fa:map-marker-alt%</span> - </ui-input> + <ui-input v-model="location"> + <span>%i18n:@location%</span> + <span slot="prefix">%fa:map-marker-alt%</span> + </ui-input> - <ui-input v-model="birthday" type="date"> - <span>%i18n:@birthday%</span> - <span slot="prefix">%fa:birthday-cake%</span> - </ui-input> + <ui-input v-model="birthday" type="date"> + <span>%i18n:@birthday%</span> + <span slot="prefix">%fa:birthday-cake%</span> + </ui-input> - <ui-textarea v-model="description" :max="500"> - <span>%i18n:@description%</span> - </ui-textarea> + <ui-textarea v-model="description" :max="500"> + <span>%i18n:@description%</span> + </ui-textarea> - <ui-input type="file" @change="onAvatarChange"> - <span>%i18n:@avatar%</span> - <span slot="icon">%fa:image%</span> - <span slot="text" v-if="avatarUploading">%i18n:@uploading%<mk-ellipsis/></span> - </ui-input> + <ui-input type="file" @change="onAvatarChange"> + <span>%i18n:@avatar%</span> + <span slot="icon">%fa:image%</span> + <span slot="text" v-if="avatarUploading">%i18n:@uploading%<mk-ellipsis/></span> + </ui-input> - <ui-input type="file" @change="onBannerChange"> - <span>%i18n:@banner%</span> - <span slot="icon">%fa:image%</span> - <span slot="text" v-if="bannerUploading">%i18n:@uploading%<mk-ellipsis/></span> - </ui-input> + <ui-input type="file" @change="onBannerChange"> + <span>%i18n:@banner%</span> + <span slot="icon">%fa:image%</span> + <span slot="text" v-if="bannerUploading">%i18n:@uploading%<mk-ellipsis/></span> + </ui-input> - <ui-switch v-model="isCat">%i18n:@is-cat%</ui-switch> + <ui-button @click="save(true)">%i18n:@save%</ui-button> + </ui-form> + </section> - <ui-button @click="save">%i18n:@save%</ui-button> - </ui-form> + <section> + <header>%i18n:@advanced%</header> + + <div> + <ui-switch v-model="isCat" @change="save(false)">%i18n:@is-cat%</ui-switch> + <ui-switch v-model="alwaysMarkNsfw">%i18n:common.always-mark-nsfw%</ui-switch> + </div> + </section> + + <section> + <header>%i18n:@privacy%</header> + + <div> + <ui-switch v-model="isLocked" @change="save(false)">%i18n:@is-locked%</ui-switch> + </div> + </section> </ui-card> </template> @@ -62,12 +79,20 @@ export default Vue.extend({ avatarId: null, bannerId: null, isCat: false, + isLocked: false, saving: false, avatarUploading: false, bannerUploading: false }; }, + computed: { + alwaysMarkNsfw: { + get() { return this.$store.state.i.settings.alwaysMarkNsfw; }, + set(value) { (this as any).api('i/update', { alwaysMarkNsfw: value }); } + }, + }, + created() { this.name = this.$store.state.i.name || ''; this.username = this.$store.state.i.username; @@ -77,6 +102,7 @@ export default Vue.extend({ this.avatarId = this.$store.state.i.avatarId; this.bannerId = this.$store.state.i.bannerId; this.isCat = this.$store.state.i.isCat; + this.isLocked = this.$store.state.i.isLocked; }, methods: { @@ -124,7 +150,7 @@ export default Vue.extend({ }); }, - save() { + save(notify) { this.saving = true; (this as any).api('i/update', { @@ -134,7 +160,8 @@ export default Vue.extend({ birthday: this.birthday || null, avatarId: this.avatarId, bannerId: this.bannerId, - isCat: this.isCat + isCat: this.isCat, + isLocked: this.isLocked }).then(i => { this.saving = false; this.$store.state.i.avatarId = i.avatarId; @@ -142,7 +169,9 @@ export default Vue.extend({ this.$store.state.i.bannerId = i.bannerId; this.$store.state.i.bannerUrl = i.bannerUrl; - alert('%i18n:@saved%'); + if (notify) { + alert('%i18n:@saved%'); + } }); } } diff --git a/src/client/app/mobile/views/pages/tag.vue b/src/client/app/mobile/views/pages/tag.vue index a545e2b839..3f963501e0 100644 --- a/src/client/app/mobile/views/pages/tag.vue +++ b/src/client/app/mobile/views/pages/tag.vue @@ -1,6 +1,6 @@ <template> <mk-ui> - <span slot="header">%fa:hashtag%{{ $route.params.tag }}</span> + <span slot="header"><span style="margin-right:4px;">%fa:hashtag%</span>{{ $route.params.tag }}</span> <main> <p v-if="!fetching && empty">%fa:search% {{ '%i18n:no-posts-found%'.split('{}')[0] }}{{ q }}{{ '%i18n:no-posts-found%'.split('{}')[1] }}</p> diff --git a/src/client/app/mobile/views/pages/user-list.vue b/src/client/app/mobile/views/pages/user-list.vue index 1c6a829cd5..f8c8aafa61 100644 --- a/src/client/app/mobile/views/pages/user-list.vue +++ b/src/client/app/mobile/views/pages/user-list.vue @@ -53,7 +53,7 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> -@import '~const.styl' + main width 100% diff --git a/src/client/app/mobile/views/pages/user-lists.vue b/src/client/app/mobile/views/pages/user-lists.vue index abd04c1496..fc80f5d1c6 100644 --- a/src/client/app/mobile/views/pages/user-lists.vue +++ b/src/client/app/mobile/views/pages/user-lists.vue @@ -43,7 +43,7 @@ export default Vue.extend({ title }); - this.$router.push('/i/lists/' + list.id); + this.$router.push(`/i/lists/${list.id}`); }); } } @@ -51,7 +51,7 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> -@import '~const.styl' + main width 100% diff --git a/src/client/app/mobile/views/pages/user.vue b/src/client/app/mobile/views/pages/user.vue index 8918847a8f..a2a6bd7a83 100644 --- a/src/client/app/mobile/views/pages/user.vue +++ b/src/client/app/mobile/views/pages/user.vue @@ -1,7 +1,7 @@ <template> <mk-ui> <template slot="header" v-if="!fetching"><img :src="user.avatarUrl" alt="">{{ user | userName }}</template> - <main v-if="!fetching" :data-darkmode="$store.state.device.darkmode"> + <main v-if="!fetching"> <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> @@ -16,7 +16,7 @@ </div> <div class="title"> <h1>{{ user | userName }}</h1> - <span class="username"><mk-acct :user="user"/></span> + <span class="username"><mk-acct :user="user" :detail="true" /></span> <span class="followed" v-if="user.isFollowed">%i18n:@follows-you%</span> </div> <div class="description"> @@ -107,7 +107,7 @@ export default Vue.extend({ this.fetching = false; Progress.done(); - document.title = Vue.filter('userName')(this.user) + ' | ' + (this as any).os.instanceName; + document.title = `${Vue.filter('userName')(this.user)} | ${(this as any).os.instanceName}`; }); } } @@ -115,10 +115,8 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> -@import '~const.styl' - -root(isDark) - $bg = isDark ? #22252f : #f7f7f7 +main + $bg = var(--face) > .is-suspended > .is-remote @@ -148,7 +146,7 @@ root(isDark) > .banner padding-bottom 33.3% - background-color isDark ? #5f7273 : #cacaca + background-color rgba(0, 0, 0, 0.1) background-size cover background-position center @@ -198,26 +196,26 @@ root(isDark) margin 0 line-height 22px font-size 20px - color isDark ? #fff : #757c82 + color var(--mobileUserPageName) > .username display inline-block line-height 20px font-size 16px font-weight bold - color isDark ? #657786 : #969ea5 + color var(--mobileUserPageAcct) > .followed margin-left 8px padding 2px 4px font-size 12px - color isDark ? #657786 : #fff - background isDark ? #f8f8f8 : #a7bec7 + color var(--mobileUserPageFollowedFg) + background var(--mobileUserPageFollowedBg) border-radius 4px > .description margin 8px 0 - color isDark ? #fff : #757c82 + color var(--mobileUserPageDescription) > .info margin 8px 0 @@ -225,14 +223,14 @@ root(isDark) > p display inline margin 0 16px 0 0 - color isDark ? #a9b9c1 : #90989c + color var(--text) > i margin-right 4px > .status > a - color isDark ? #657786 : #818a92 + color var(--text) &:not(:last-child) margin-right 16px @@ -240,7 +238,7 @@ root(isDark) > b margin-right 4px font-size 16px - color isDark ? #fff : #787e86 + color var(--mobileUserPageStatusHighlight) > i font-size 14px @@ -249,7 +247,7 @@ root(isDark) position -webkit-sticky position sticky top 47px - box-shadow 0 4px 4px isDark ? rgba(#000, 0.3) : rgba(#000, 0.07) + box-shadow 0 4px 4px var(--mobileUserPageHeaderShadow) background-color $bg z-index 2 @@ -266,7 +264,7 @@ root(isDark) line-height 48px font-size 12px text-decoration none - color isDark ? #657786 : #9ca1a5 + color var(--text) border-bottom solid 2px transparent @media (min-width 400px) @@ -275,8 +273,8 @@ root(isDark) &[data-active] font-weight bold - color $theme-color - border-color $theme-color + color var(--primary) + border-color var(--primary) > .body max-width 680px @@ -289,10 +287,4 @@ root(isDark) @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.photos.vue b/src/client/app/mobile/views/pages/user/home.photos.vue index 73ff1d5173..261a3f796c 100644 --- a/src/client/app/mobile/views/pages/user/home.photos.vue +++ b/src/client/app/mobile/views/pages/user/home.photos.vue @@ -4,7 +4,7 @@ <div class="stream" v-if="!fetching && images.length > 0"> <a v-for="image in images" class="img" - :style="`background-image: url(${image.media.url})`" + :style="`background-image: url(${image.media.thumbnailUrl})`" :href="image.note | notePage" ></a> </div> @@ -26,7 +26,7 @@ export default Vue.extend({ mounted() { (this as any).api('users/notes', { userId: this.user.id, - withMedia: true, + withFiles: true, limit: 6 }).then(notes => { notes.forEach(note => { diff --git a/src/client/app/mobile/views/pages/user/home.vue b/src/client/app/mobile/views/pages/user/home.vue index 8b57276b17..2c7134ed43 100644 --- a/src/client/app/mobile/views/pages/user/home.vue +++ b/src/client/app/mobile/views/pages/user/home.vue @@ -1,6 +1,6 @@ <template> <div class="root home"> - <mk-note-detail v-if="user.pinnedNote" :note="user.pinnedNote" :compact="true"/> + <mk-note-detail v-for="n in user.pinnedNotes" :key="n.id" :note="n" :compact="true"/> <section class="recent-notes"> <h2>%fa:R comments%%i18n:@recent-notes%</h2> <div> @@ -54,7 +54,7 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> -root(isDark) +.root.home max-width 600px margin 0 auto @@ -65,7 +65,7 @@ root(isDark) margin 0 0 16px 0 > section - background isDark ? #21242f : #eee + background var(--face) border-radius 8px box-shadow 0 4px 16px rgba(#000, 0.1) @@ -80,8 +80,8 @@ root(isDark) padding 8px 10px font-size 15px font-weight normal - color isDark ? #b8c5cc : #465258 - background isDark ? #282c37 : #fff + color var(--text) + background var(--faceHeader) border-radius 8px 8px 0 0 @media (min-width 500px) @@ -98,12 +98,6 @@ root(isDark) display block margin 16px text-align center - color isDark ? #cad2da : #929aa0 - -.root.home[data-darkmode] - root(true) - -.root.home:not([data-darkmode]) - root(false) + color var(--text) </style> diff --git a/src/client/app/mobile/views/pages/welcome.vue b/src/client/app/mobile/views/pages/welcome.vue index 49227790ff..32f74bfe3a 100644 --- a/src/client/app/mobile/views/pages/welcome.vue +++ b/src/client/app/mobile/views/pages/welcome.vue @@ -1,7 +1,9 @@ <template> -<div class="welcome"> +<div class="wgwfgvvimdjvhjfwxropcwksnzftjqes"> + <div class="banner" :style="{ backgroundImage: banner ? `url(${banner})` : null }"></div> + <div> - <img :src="$store.state.device.darkmode ? 'assets/title.dark.svg' : 'assets/title.light.svg'" :alt="name"> + <img svg-inline src="../../../../assets/title.svg" :alt="name"> <p class="host">{{ host }}</p> <div class="about"> <h2>{{ name }}</h2> @@ -15,12 +17,53 @@ <mk-welcome-timeline/> </div> <div class="hashtags"> - <router-link v-for="tag in tags" :key="tag" :to="`/tags/${ tag }`" :title="tag">#{{ tag }}</router-link> + <mk-tag-cloud/> + </div> + <div class="photos"> + <div v-for="photo in photos" :style="`background-image: url(${photo.thumbnailUrl})`"></div> </div> <div class="stats" v-if="stats"> <span>%fa:user% {{ stats.originalUsersCount | number }}</span> <span>%fa:pencil-alt% {{ stats.originalNotesCount | number }}</span> </div> + <div class="announcements" v-if="announcements && announcements.length > 0"> + <article v-for="announcement in announcements"> + <span class="title" v-html="announcement.title"></span> + <div v-html="announcement.text"></div> + </article> + </div> + <article class="about-misskey"> + <h1>%i18n:common.intro.title%</h1> + <p v-html="'%i18n:common.intro.about%'"></p> + <section> + <h2>%i18n:common.intro.features%</h2> + <section> + <h3>%i18n:common.intro.rich-contents%</h3> + <div class="image"><img src="/assets/about/post.png" alt=""></div> + <p v-html="'%i18n:common.intro.rich-contents-desc%'"></p> + </section> + <section> + <h3>%i18n:common.intro.reaction%</h3> + <div class="image"><img src="/assets/about/reaction.png" alt=""></div> + <p v-html="'%i18n:common.intro.reaction-desc%'"></p> + </section> + <section> + <h3>%i18n:common.intro.ui%</h3> + <div class="image"><img src="/assets/about/ui.png" alt=""></div> + <p v-html="'%i18n:common.intro.ui-desc%'"></p> + </section> + <section> + <h3>%i18n:common.intro.drive%</h3> + <div class="image"><img src="/assets/about/drive.png" alt=""></div> + <p v-html="'%i18n:common.intro.drive-desc%'"></p> + </section> + </section> + <p v-html="'%i18n:common.intro.outro%'"></p> + </article> + <div class="info" v-if="meta"> + <p>Version: <b>{{ meta.version }}</b></p> + <p>Maintainer: <b><a :href="meta.maintainer.url" target="_blank">{{ meta.maintainer.name }}</a></b></p> + </div> <footer> <small>{{ copyright }}</small> </footer> @@ -30,50 +73,87 @@ <script lang="ts"> import Vue from 'vue'; -import { apiUrl, copyright, host } from '../../../config'; +import { copyright, host } from '../../../config'; +import { concat } from '../../../../../prelude/array'; export default Vue.extend({ data() { return { - apiUrl, + meta: null, copyright, stats: null, + banner: null, host, name: 'Misskey', description: '', - tags: [] + photos: [], + announcements: [] }; }, created() { (this as any).os.getMeta().then(meta => { + this.meta = meta; this.name = meta.name; this.description = meta.description; + this.announcements = meta.broadcasts; + this.banner = meta.bannerUrl; }); (this as any).api('stats').then(stats => { this.stats = stats; }); - (this as any).api('hashtags/trend').then(stats => { - this.tags = stats.map(x => x.tag); + const image = [ + 'image/jpeg', + 'image/png', + 'image/gif' + ]; + + (this as any).api('notes/local-timeline', { + fileType: image, + excludeNsfw: true, + limit: 6 + }).then((notes: any[]) => { + const files = concat(notes.map((n: any): any[] => n.files)); + this.photos = files.filter(f => image.includes(f.type)).slice(0, 6); }); } }); </script> <style lang="stylus" scoped> -.welcome +.wgwfgvvimdjvhjfwxropcwksnzftjqes text-align center - //background #fff - > div + > .banner + position absolute + top 0 + left 0 + width 100% + height 300px + background-position center + background-size cover + opacity 0.7 + + &:after + content "" + display block + position absolute + bottom 0 + left 0 + width 100% + height 100px + background linear-gradient(transparent, var(--bg)) + + > div:not(.banner) padding 32px margin 0 auto max-width 500px - > img + > svg display block - max-width 200px + width 200px + height 50px margin 0 auto > .host @@ -89,8 +169,8 @@ export default Vue.extend({ > .about margin-top 16px padding 16px - color #555 - background #fff + color var(--text) + background var(--face) border-radius 6px > h2 @@ -138,27 +218,98 @@ export default Vue.extend({ -webkit-overflow-scrolling touch > .hashtags - padding 16px 0 - border solid 2px #ddd - border-radius 8px + padding 0 8px + height 200px - > * - margin 0 16px + > .photos + display grid + grid-template-rows 1fr 1fr 1fr + grid-template-columns 1fr 1fr + gap 8px + height 300px + margin-top 16px + + > div + border-radius 4px + background-position center center + background-size cover > .stats margin 16px 0 padding 8px font-size 14px - color #444 + color var(--text) background rgba(#000, 0.1) border-radius 6px > * margin 0 8px + > .announcements + margin 16px 0 + + > article + background var(--mobileAnnouncement) + border-radius 6px + color var(--mobileAnnouncementFg) + padding 16px + margin 8px 0 + font-size 12px + + > .title + font-weight bold + + > .about-misskey + margin 16px 0 + padding 32px + font-size 14px + background var(--face) + border-radius 6px + overflow hidden + color var(--text) + + > h1 + margin 0 + + & + p + margin-top 8px + + > p:last-child + margin-bottom 0 + + > section + > h2 + border-bottom 1px solid var(--faceDivider) + + > section + margin-bottom 16px + padding-bottom 16px + border-bottom 1px solid var(--faceDivider) + + > h3 + margin-bottom 8px + + > p + margin-bottom 0 + + > .image + > img + display block + width 100% + height 120px + object-fit cover + + > .info + padding 16px 0 + border solid 2px rgba(0, 0, 0, 0.1) + border-radius 8px + + > * + margin 0 16px + > footer text-align center - color #444 + color var(--text) > small display block diff --git a/src/client/app/mobile/views/pages/widgets.vue b/src/client/app/mobile/views/pages/widgets.vue index a83103632e..c649529c0e 100644 --- a/src/client/app/mobile/views/pages/widgets.vue +++ b/src/client/app/mobile/views/pages/widgets.vue @@ -1,6 +1,6 @@ <template> <mk-ui> - <span slot="header">%fa:home%%i18n:@dashboard%</span> + <span slot="header"><span style="margin-right:4px;">%fa:home%</span>%i18n:@dashboard%</span> <template slot="func"> <button @click="customizing = !customizing">%fa:cog%</button> </template> diff --git a/src/client/app/safe.js b/src/client/app/safe.js index 3d73fa1a9c..026fc66c6e 100644 --- a/src/client/app/safe.js +++ b/src/client/app/safe.js @@ -12,16 +12,6 @@ if (!('fetch' in window)) { 'To run Misskey, please update your browser to latest version or try other browsers.'); } -// Detect Edge -if (navigator.userAgent.toLowerCase().indexOf('edge') != -1) { - alert( - '現在、お使いのブラウザ(Microsoft Edge)ではMisskeyは正しく動作しません。' + - 'サポートしているブラウザ: Google Chrome, Mozilla Firefox, Apple Safari など' + - '\n\n' + - 'Currently, Misskey cannot run correctly on your browser (Microsoft Edge). ' + - 'Supported browsers: Google Chrome, Mozilla Firefox, Apple Safari, etc'); -} - // Check whether cookie enabled if (!navigator.cookieEnabled) { alert( diff --git a/src/client/app/store.ts b/src/client/app/store.ts index 469563495f..545261225a 100644 --- a/src/client/app/store.ts +++ b/src/client/app/store.ts @@ -4,18 +4,23 @@ import * as nestedProperty from 'nested-property'; import MiOS from './mios'; import { hostname } from './config'; +import { erase } from '../../prelude/array'; const defaultSettings = { home: null, mobileHome: [], deck: null, + tagTimelines: [], fetchOnScroll: true, showMaps: true, showPostFormOnTopOfTl: false, suggestRecentHashtags: true, showClockOnHeader: true, + useShadow: true, + roundedCorners: false, circleIcons: true, - gradientWindowHeader: false, + contrastedAcct: true, + showFullAcct: false, showReplyTarget: true, showMyRenotes: true, showRenotedMyNotes: true, @@ -24,6 +29,8 @@ const defaultSettings = { disableViaMobile: false, memo: null, iLikeSushi: false, + rememberNoteVisibility: false, + defaultNoteVisibility: 'public', games: { reversi: { showBoardLabels: false, @@ -33,9 +40,13 @@ const defaultSettings = { }; const defaultDeviceSettings = { + reduceMotion: false, apiViaStream: true, autoPopout: false, darkmode: false, + darkTheme: 'dark', + lightTheme: 'light', + themes: [], enableSounds: true, soundVolume: 0.5, lang: null, @@ -43,7 +54,9 @@ const defaultDeviceSettings = { debug: false, lightmode: false, loadRawImages: false, - postStyle: 'standard' + alwaysShowNsfw: false, + postStyle: 'standard', + mobileNotificationPosition: 'bottom' }; export default (os: MiOS) => new Vuex.Store({ @@ -194,7 +207,7 @@ export default (os: MiOS) => new Vuex.Store({ removeDeckColumn(state, id) { state.deck.columns = state.deck.columns.filter(c => c.id != id); - state.deck.layout = state.deck.layout.map(ids => ids.filter(x => x != id)); + state.deck.layout = state.deck.layout.map(ids => erase(id, ids)); state.deck.layout = state.deck.layout.filter(ids => ids.length > 0); }, @@ -265,7 +278,7 @@ export default (os: MiOS) => new Vuex.Store({ stackLeftDeckColumn(state, id) { const i = state.deck.layout.findIndex(ids => ids.indexOf(id) != -1); - state.deck.layout = state.deck.layout.map(ids => ids.filter(x => x != id)); + state.deck.layout = state.deck.layout.map(ids => erase(id, ids)); const left = state.deck.layout[i - 1]; if (left) state.deck.layout[i - 1].push(id); state.deck.layout = state.deck.layout.filter(ids => ids.length > 0); @@ -273,7 +286,7 @@ export default (os: MiOS) => new Vuex.Store({ popRightDeckColumn(state, id) { const i = state.deck.layout.findIndex(ids => ids.indexOf(id) != -1); - state.deck.layout = state.deck.layout.map(ids => ids.filter(x => x != id)); + state.deck.layout = state.deck.layout.map(ids => erase(id, ids)); state.deck.layout.splice(i + 1, 0, [id]); state.deck.layout = state.deck.layout.filter(ids => ids.length > 0); }, diff --git a/src/client/app/sw.js b/src/client/app/sw.js index ac7ea20acf..d381bfb7a5 100644 --- a/src/client/app/sw.js +++ b/src/client/app/sw.js @@ -3,6 +3,7 @@ */ import composeNotification from './common/scripts/compose-notification'; +import { erase } from '../../prelude/array'; // キャッシュするリソース const cachee = [ @@ -24,8 +25,7 @@ self.addEventListener('activate', ev => { // Clean up old caches ev.waitUntil( caches.keys().then(keys => Promise.all( - keys - .filter(key => key != _VERSION_) + erase(_VERSION_, keys) .map(key => caches.delete(key)) )) ); diff --git a/src/client/app/theme.ts b/src/client/app/theme.ts new file mode 100644 index 0000000000..9c5be74fa1 --- /dev/null +++ b/src/client/app/theme.ts @@ -0,0 +1,104 @@ +import * as tinycolor from 'tinycolor2'; + +export type Theme = { + id: string; + name: string; + author: string; + desc?: string; + base?: 'dark' | 'light'; + vars: { [key: string]: string }; + props: { [key: string]: string }; +}; + +export const lightTheme: Theme = require('../theme/light.json5'); +export const darkTheme: Theme = require('../theme/dark.json5'); +export const pinkTheme: Theme = require('../theme/pink.json5'); +export const blackTheme: Theme = require('../theme/black.json5'); +export const halloweenTheme: Theme = require('../theme/halloween.json5'); + +export const builtinThemes = [ + lightTheme, + darkTheme, + pinkTheme, + blackTheme, + halloweenTheme +]; + +export function applyTheme(theme: Theme, persisted = true) { + // Deep copy + const _theme = JSON.parse(JSON.stringify(theme)); + + if (_theme.base) { + const base = [lightTheme, darkTheme].find(x => x.id == _theme.base); + _theme.vars = Object.assign({}, base.vars, _theme.vars); + _theme.props = Object.assign({}, base.props, _theme.props); + } + + const props = compile(_theme); + + Object.entries(props).forEach(([k, v]) => { + document.documentElement.style.setProperty(`--${k}`, v.toString()); + }); + + if (persisted) { + localStorage.setItem('theme', JSON.stringify(props)); + } +} + +function compile(theme: Theme): { [key: string]: string } { + function getColor(code: string): tinycolor.Instance { + // ref + if (code[0] == '@') { + return getColor(theme.props[code.substr(1)]); + } + if (code[0] == '$') { + return getColor(theme.vars[code.substr(1)]); + } + + // func + if (code[0] == ':') { + const parts = code.split('<'); + const func = parts.shift().substr(1); + const arg = parseFloat(parts.shift()); + const color = getColor(parts.join('<')); + + switch (func) { + case 'darken': return color.darken(arg); + case 'lighten': return color.lighten(arg); + case 'alpha': return color.setAlpha(arg); + } + } + + return tinycolor(code); + } + + const props = {}; + + Object.entries(theme.props).forEach(([k, v]) => { + const c = getColor(v); + props[k] = genValue(c); + }); + + const primary = getColor(props['primary']); + + for (let i = 1; i < 10; i++) { + const color = primary.clone().setAlpha(i / 10); + props['primaryAlpha0' + i] = genValue(color); + } + + for (let i = 1; i < 100; i++) { + const color = primary.clone().lighten(i); + props['primaryLighten' + i] = genValue(color); + } + + for (let i = 1; i < 100; i++) { + const color = primary.clone().darken(i); + props['primaryDarken' + i] = genValue(color); + } + + return props; +} + +function genValue(c: tinycolor.Instance): string { + return c.toRgbString(); +} diff --git a/src/client/app/tsconfig.json b/src/client/app/tsconfig.json index e31b52dab1..4a05469673 100644 --- a/src/client/app/tsconfig.json +++ b/src/client/app/tsconfig.json @@ -14,7 +14,8 @@ "removeComments": false, "noLib": false, "strict": true, - "strictNullChecks": false + "strictNullChecks": false, + "experimentalDecorators": true }, "compileOnSave": false, "include": [ diff --git a/src/client/assets/code-highlight.css b/src/client/assets/code-highlight.css deleted file mode 100644 index f0807dc9c3..0000000000 --- a/src/client/assets/code-highlight.css +++ /dev/null @@ -1,93 +0,0 @@ -.hljs { - font-family: Consolas, 'Courier New', Courier, Monaco, monospace; -} - -.hljs, -.hljs-subst { - color: #444; -} - -.hljs-comment { - color: #888888; -} - -.hljs-keyword { - color: #2973b7; -} - -.hljs-number { - color: #ae81ff; -} - -.hljs-string { - color: #e96900; -} - -.hljs-regexp { - color: #e9003f; -} - -.hljs-attribute, -.hljs-selector-tag, -.hljs-meta-keyword, -.hljs-doctag, -.hljs-name { - font-weight: bold; -} - -.hljs-type, -.hljs-selector-id, -.hljs-selector-class, -.hljs-quote, -.hljs-template-tag, -.hljs-deletion { - color: #880000; -} - -.hljs-title, -.hljs-section { - color: #880000; - font-weight: bold; -} - -.hljs-symbol, -.hljs-variable, -.hljs-template-variable, -.hljs-link, -.hljs-selector-attr, -.hljs-selector-pseudo { - color: #BC6060; -} - -/* Language color: hue: 90; */ - -.hljs-literal { - color: #78A960; -} - -.hljs-built_in, -.hljs-bullet, -.hljs-code, -.hljs-addition { - color: #397300; -} - -/* Meta color: hue: 200 */ - -.hljs-meta { - color: #1f7199; -} - -.hljs-meta-string { - color: #4d99bf; -} - -/* Misc effects */ - -.hljs-emphasis { - font-style: italic; -} - -.hljs-strong { - font-weight: bold; -} diff --git a/src/client/assets/pointer.png b/src/client/assets/pointer.png Binary files differindex 0d03f75d2b..c9aaada5a3 100644 --- a/src/client/assets/pointer.png +++ b/src/client/assets/pointer.png diff --git a/src/client/assets/reactions/angry.png b/src/client/assets/reactions/angry.png Binary files differdeleted file mode 100644 index 7e32dd6809..0000000000 --- a/src/client/assets/reactions/angry.png +++ /dev/null diff --git a/src/client/assets/reactions/confused.png b/src/client/assets/reactions/confused.png Binary files differdeleted file mode 100644 index c791854183..0000000000 --- a/src/client/assets/reactions/confused.png +++ /dev/null diff --git a/src/client/assets/reactions/congrats.png b/src/client/assets/reactions/congrats.png Binary files differdeleted file mode 100644 index fdea27fcb9..0000000000 --- a/src/client/assets/reactions/congrats.png +++ /dev/null diff --git a/src/client/assets/reactions/hmm.png b/src/client/assets/reactions/hmm.png Binary files differdeleted file mode 100644 index 725fe3898d..0000000000 --- a/src/client/assets/reactions/hmm.png +++ /dev/null diff --git a/src/client/assets/reactions/laugh.png b/src/client/assets/reactions/laugh.png Binary files differdeleted file mode 100644 index 3b3c10a27a..0000000000 --- a/src/client/assets/reactions/laugh.png +++ /dev/null diff --git a/src/client/assets/reactions/like.png b/src/client/assets/reactions/like.png Binary files differdeleted file mode 100644 index 526b391f96..0000000000 --- a/src/client/assets/reactions/like.png +++ /dev/null diff --git a/src/client/assets/reactions/love.png b/src/client/assets/reactions/love.png Binary files differdeleted file mode 100644 index 9fe82cd070..0000000000 --- a/src/client/assets/reactions/love.png +++ /dev/null diff --git a/src/client/assets/reactions/pudding.png b/src/client/assets/reactions/pudding.png Binary files differdeleted file mode 100644 index e4d10a229d..0000000000 --- a/src/client/assets/reactions/pudding.png +++ /dev/null diff --git a/src/client/assets/reactions/rip.png b/src/client/assets/reactions/rip.png Binary files differdeleted file mode 100644 index 4800fdb91b..0000000000 --- a/src/client/assets/reactions/rip.png +++ /dev/null diff --git a/src/client/assets/reactions/surprise.png b/src/client/assets/reactions/surprise.png Binary files differdeleted file mode 100644 index aa55592ded..0000000000 --- a/src/client/assets/reactions/surprise.png +++ /dev/null diff --git a/src/client/assets/reactions/sushi.png b/src/client/assets/reactions/sushi.png Binary files differdeleted file mode 100644 index c30d44eb15..0000000000 --- a/src/client/assets/reactions/sushi.png +++ /dev/null diff --git a/src/client/assets/title.light.svg b/src/client/assets/title.light.svg deleted file mode 100644 index 95ad11c399..0000000000 --- a/src/client/assets/title.light.svg +++ /dev/null @@ -1,140 +0,0 @@ -<?xml version="1.0" encoding="UTF-8" standalone="no"?> -<!-- Created with Inkscape (http://www.inkscape.org/) --> - -<svg - xmlns:dc="http://purl.org/dc/elements/1.1/" - xmlns:cc="http://creativecommons.org/ns#" - xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" - xmlns:svg="http://www.w3.org/2000/svg" - xmlns="http://www.w3.org/2000/svg" - xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" - xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" - width="614.71039" - height="205.08009" - viewBox="0 0 162.64213 54.260776" - version="1.1" - id="svg8" - inkscape:version="0.92.1 r15371" - sodipodi:docname="misskey.svg" - inkscape:export-filename="C:\Users\Takumiya_Cho\Desktop\misskey.png" - inkscape:export-xdpi="96" - inkscape:export-ydpi="96"> - <defs - id="defs2"> - <inkscape:path-effect - effect="simplify" - id="path-effect5115" - is_visible="true" - steps="1" - threshold="0.000408163" - smooth_angles="360" - helper_size="0" - simplify_individual_paths="false" - simplify_just_coalesce="false" - simplifyindividualpaths="false" - simplifyJustCoalesce="false" /> - <inkscape:path-effect - effect="simplify" - id="path-effect5104" - is_visible="true" - steps="1" - threshold="0.000408163" - smooth_angles="360" - helper_size="0" - simplify_individual_paths="false" - simplify_just_coalesce="false" - simplifyindividualpaths="false" - simplifyJustCoalesce="false" /> - </defs> - <sodipodi:namedview - id="base" - pagecolor="#ffffff" - bordercolor="#666666" - borderopacity="1.0" - inkscape:pageopacity="0.0" - inkscape:pageshadow="2" - inkscape:zoom="0.9899495" - inkscape:cx="370.82839" - inkscape:cy="79.043895" - inkscape:document-units="mm" - inkscape:current-layer="layer1" - showgrid="false" - units="px" - inkscape:snap-bbox="true" - inkscape:bbox-nodes="true" - inkscape:snap-bbox-edge-midpoints="false" - inkscape:snap-smooth-nodes="true" - inkscape:snap-center="true" - inkscape:snap-page="true" - inkscape:window-width="1920" - inkscape:window-height="1017" - inkscape:window-x="-8" - inkscape:window-y="1072" - inkscape:window-maximized="1" - inkscape:object-paths="true" - inkscape:bbox-paths="true" - fit-margin-top="50" - fit-margin-left="50" - fit-margin-bottom="20" - fit-margin-right="50" /> - <metadata - id="metadata5"> - <rdf:RDF> - <cc:Work - rdf:about=""> - <dc:format>image/svg+xml</dc:format> - <dc:type - rdf:resource="http://purl.org/dc/dcmitype/StillImage" /> - <dc:title></dc:title> - </cc:Work> - </rdf:RDF> - </metadata> - <g - inkscape:label="レイヤー 1" - inkscape:groupmode="layer" - id="layer1" - transform="translate(-11.097531,-173.29664)"> - <g - transform="matrix(0.28612302,0,0,0.28612302,17.176981,141.74334)" - id="text4489-6" - style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:141.03404236px;line-height:476.69509888px;font-family:'OTADESIGN Rounded';-inkscape-font-specification:'OTADESIGN Rounded';letter-spacing:0px;word-spacing:0px;fill:#212d3a;fill-opacity:1;stroke:none;stroke-width:0.92471898px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" - aria-label="Mi"> - <path - sodipodi:nodetypes="zccssscssccscczzzccsccsscscsccz" - inkscape:connector-curvature="0" - id="path5210" - style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:'OTADESIGN Rounded';-inkscape-font-specification:'OTADESIGN Rounded';fill:#212d3a;fill-opacity:1;stroke-width:0.92471898px" - d="m 75.196381,231.17126 c -5.855419,0.0202 -10.885068,-3.50766 -13.2572,-7.61584 -1.266603,-1.79454 -3.772419,-2.43291 -3.807919,0 v 11.2332 c 0,4.51309 -1.645397,8.41504 -4.936191,11.70583 -3.196772,3.19677 -7.098714,4.79516 -11.705826,4.79516 -4.513089,0 -8.415031,-1.59839 -11.705825,-4.79516 -3.196772,-3.29079 -4.795158,-7.19274 -4.795158,-11.70583 v -61.7729 c 0,-3.47884 0.987238,-6.6286 2.961715,-9.44928 2.068499,-2.91471 4.701135,-4.9362 7.897906,-6.06447 1.786431,-0.65816 3.666885,-0.98724 5.641362,-0.98724 5.077225,0 9.308247,1.97448 12.693064,5.92343 1.786431,1.97448 2.820681,3.00873 3.102749,3.10275 0,0 13.408119,16.21319 13.78421,16.49526 0.376091,0.28206 1.480789,2.43848 4.127113,2.43848 2.646324,0 3.89218,-2.15642 4.26827,-2.43848 0.376091,-0.28207 13.784088,-16.49526 13.784088,-16.49526 0.09402,0.094 1.081261,-0.94022 2.961715,-3.10275 3.478837,-3.94895 7.756866,-5.92343 12.834096,-5.92343 1.88045,0 3.76091,0.32908 5.64136,0.98724 3.19677,1.12827 5.7824,3.14976 7.75688,6.06447 2.06849,2.82068 3.10274,5.97044 3.10274,9.44928 v 61.7729 c 0,4.51309 -1.6454,8.41504 -4.93619,11.70583 -3.19677,3.19677 -7.09871,4.79516 -11.70582,4.79516 -4.51309,0 -8.41504,-1.59839 -11.705828,-4.79516 -3.196772,-3.29079 -4.795158,-7.19274 -4.795158,-11.70583 v -11.2332 c -0.277898,-3.06563 -2.987588,-1.13379 -3.948953,0 -2.538613,4.70114 -7.401781,7.59567 -13.2572,7.61584 z" /> - <path - inkscape:connector-curvature="0" - id="path5212" - style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:'OTADESIGN Rounded';-inkscape-font-specification:'OTADESIGN Rounded';fill:#212d3a;fill-opacity:1;stroke-width:0.92471898px" - d="m 145.83461,185.00361 q -5.92343,0 -10.15445,-4.08999 -4.08999,-4.23102 -4.08999,-10.15445 0,-5.92343 4.08999,-10.01342 4.23102,-4.23102 10.15445,-4.23102 5.92343,0 10.15445,4.23102 4.23102,4.08999 4.23102,10.01342 0,5.92343 -4.23102,10.15445 -4.23102,4.08999 -10.15445,4.08999 z m 0.14103,2.82068 q 5.92343,0 10.01342,4.23102 4.23102,4.23102 4.23102,10.15445 v 34.83541 q 0,5.92343 -4.23102,10.15445 -4.08999,4.08999 -10.01342,4.08999 -5.92343,0 -10.15445,-4.08999 -4.23102,-4.23102 -4.23102,-10.15445 v -34.83541 q 0,-5.92343 4.23102,-10.15445 4.23102,-4.23102 10.15445,-4.23102 z" /> - </g> - <path - inkscape:connector-curvature="0" - id="path5199" - style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:136.34428406px;font-family:'OTADESIGN Rounded';-inkscape-font-specification:'OTADESIGN Rounded';letter-spacing:0px;word-spacing:0px;fill:#212d3a;fill-opacity:1;stroke:none;stroke-width:0.26458335px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" - d="m 72.022691,200.53715 q 0.968125,0.24203 2.420312,0.5244 2.420313,0.40339 3.791824,1.29083 2.581666,1.69422 2.581666,5.08266 0,2.74302 -1.815234,4.47758 -2.097604,2.01693 -5.849089,2.01693 -2.743021,0 -6.131458,-0.76644 -1.089141,-0.24203 -1.774896,-1.08914 -0.645417,-0.84711 -0.645417,-1.89591 0,-1.29083 0.887448,-2.17828 0.927786,-0.92779 2.178281,-0.92779 0.363047,0 0.685756,0.0807 1.169817,0.24203 4.477578,0.60508 0.443724,0 0.968125,-0.0403 0.201693,0 0.201693,-0.24203 0.04034,-0.20169 -0.242032,-0.28237 -1.37151,-0.24203 -2.541328,-0.5244 -1.331172,-0.28237 -1.895911,-0.48406 -1.12948,-0.32271 -1.895912,-0.84711 -2.581667,-1.69422 -2.622005,-5.08266 0,-2.70268 1.855573,-4.47758 2.258958,-2.17828 6.413828,-1.97659 2.783359,0.12102 5.566719,0.7261 1.048802,0.24203 1.734557,1.08914 0.685756,0.84711 0.685756,1.93625 0,1.25049 -0.927787,2.17828 -0.887448,0.88745 -2.178281,0.88745 -0.322709,0 -0.645417,-0.0807 -1.169818,-0.24203 -4.517917,-0.56474 -0.403385,-0.0403 -0.766432,0 -0.322708,0.0403 -0.322708,0.24203 0.04034,0.24203 0.322708,0.32271 z" /> - <path - inkscape:connector-curvature="0" - id="path5201" - style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:136.34428406px;font-family:'OTADESIGN Rounded';-inkscape-font-specification:'OTADESIGN Rounded';letter-spacing:0px;word-spacing:0px;fill:#212d3a;fill-opacity:1;stroke:none;stroke-width:0.26458335px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" - d="m 89.577027,200.53715 q 0.968125,0.24203 2.420312,0.5244 2.420313,0.40339 3.791823,1.29083 2.581667,1.69422 2.581667,5.08266 0,2.74302 -1.815234,4.47758 -2.097604,2.01693 -5.849089,2.01693 -2.743021,0 -6.131458,-0.76644 -1.089141,-0.24203 -1.774896,-1.08914 -0.645417,-0.84711 -0.645417,-1.89591 0,-1.29083 0.887448,-2.17828 0.927786,-0.92779 2.178281,-0.92779 0.363047,0 0.685755,0.0807 1.169818,0.24203 4.477579,0.60508 0.443724,0 0.968125,-0.0403 0.201692,0 0.201692,-0.24203 0.04034,-0.20169 -0.242031,-0.28237 -1.37151,-0.24203 -2.541328,-0.5244 -1.331172,-0.28237 -1.895912,-0.48406 -1.129479,-0.32271 -1.895911,-0.84711 -2.581667,-1.69422 -2.622005,-5.08266 0,-2.70268 1.855573,-4.47758 2.258958,-2.17828 6.413828,-1.97659 2.783359,0.12102 5.566719,0.7261 1.048802,0.24203 1.734557,1.08914 0.685755,0.84711 0.685755,1.93625 0,1.25049 -0.927786,2.17828 -0.887448,0.88745 -2.178281,0.88745 -0.322709,0 -0.645417,-0.0807 -1.169818,-0.24203 -4.517917,-0.56474 -0.403385,-0.0403 -0.766432,0 -0.322708,0.0403 -0.322708,0.24203 0.04034,0.24203 0.322708,0.32271 z" /> - <path - inkscape:connector-curvature="0" - id="path5203" - style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:136.34428406px;font-family:'OTADESIGN Rounded';-inkscape-font-specification:'OTADESIGN Rounded';letter-spacing:0px;word-spacing:0px;fill:#212d3a;fill-opacity:1;stroke:none;stroke-width:0.26458335px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" - d="m 115.65209,203.87137 q 0.12101,0.0807 2.86404,2.78336 1.25049,1.21016 1.25049,2.94471 0,1.61354 -1.16982,2.86404 -1.16982,1.21016 -2.90437,1.21016 -1.65388,0 -2.86404,-1.16982 l -4.03385,-3.91284 q -0.16136,-0.12102 -0.32271,-0.12102 -0.32271,0 -0.32271,1.21016 0,1.69422 -1.21016,2.90438 -1.21015,1.16981 -2.90437,1.16981 -1.69422,0 -2.90438,-1.16981 -1.169807,-1.21016 -1.169807,-2.90438 v -18.79776 q 0,-1.69422 1.169807,-2.86404 1.21016,-1.21015 2.90438,-1.21015 1.69422,0 2.90437,1.21015 1.21016,1.16982 1.21016,2.86404 v 6.29281 q 0,0.40339 0.28237,0.5244 0.24203,0.12102 0.5244,-0.0807 0.16135,-0.0807 4.84063,-3.18675 1.0488,-0.64542 2.25895,-0.64542 2.21862,0 3.42878,1.81524 0.64542,1.0488 0.64542,2.25896 0,2.21862 -1.81524,3.42877 l -2.54133,1.61354 v 0.0403 l -0.0807,0.0403 q -0.56474,0.36305 -0.0403,0.88745 z" /> - <path - inkscape:connector-curvature="0" - id="path5205" - style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:136.34428406px;font-family:'OTADESIGN Rounded';-inkscape-font-specification:'OTADESIGN Rounded';letter-spacing:0px;word-spacing:0px;fill:#212d3a;fill-opacity:1;stroke:none;stroke-width:0.26458335px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" - d="m 131.25181,213.92955 q -4.19521,0 -7.18026,-2.94472 -2.94472,-2.98505 -2.94472,-7.18026 0,-4.15487 2.94472,-7.09958 2.98505,-2.98505 7.18026,-2.98505 4.15487,0 6.97857,2.78335 0.92778,0.92779 0.92778,2.25896 0,1.33118 -0.92778,2.25896 l -4.67928,4.63893 q -1.00846,1.00847 -2.01692,1.00847 -1.45219,0 -2.25896,-0.80677 -0.80677,-0.80677 -0.80677,-2.13795 0,-1.29083 0.92778,-2.21862 l 0.80678,-0.84711 q 0.16135,-0.12101 0.0807,-0.24203 -0.12101,-0.0807 -0.32271,-0.0403 -0.80677,0.20169 -1.37151,0.80677 -1.12948,1.08914 -1.12948,2.622 0,1.5732 1.08915,2.70268 1.12947,1.08914 2.70268,1.08914 1.53286,0 2.622,-1.12947 0.92779,-0.92779 2.25896,-0.92779 1.33117,0 2.25896,0.92779 0.92779,0.92778 0.92779,2.25895 0,1.33118 -0.92779,2.25896 -2.98505,2.94472 -7.13992,2.94472 z" /> - <path - inkscape:connector-curvature="0" - id="path5207" - style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:136.34428406px;font-family:'OTADESIGN Rounded';-inkscape-font-specification:'OTADESIGN Rounded';letter-spacing:0px;word-spacing:0px;fill:#212d3a;fill-opacity:1;stroke:none;stroke-width:0.26458335px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" - d="m 160.51049,198.1433 v 5.60705 q 0,0.56474 -0.0807,1.21016 v 7.38195 q 0,4.51792 -2.74302,7.2206 -2.70268,2.70269 -7.30128,2.70269 -2.66234,0 -4.80028,-1.00847 -2.13795,-0.96812 -2.13795,-3.3481 0,-0.80677 0.36305,-1.53286 0.96812,-2.17828 3.3481,-2.17828 0.56474,0 1.5732,0.32271 1.00847,0.3227 1.65388,0.3227 1.69422,0 2.21862,-0.72609 0.20169,-0.28237 0.0807,-0.44372 -0.16136,-0.24204 -0.56474,-0.16136 -0.68576,0.12102 -1.49253,0.12102 -4.07419,0 -6.97856,-2.90438 -2.90438,-2.90437 -2.90438,-6.97857 v -5.60705 q 0,-1.69422 1.16982,-2.86404 1.21015,-1.21016 2.90437,-1.21016 1.69422,0 2.90438,1.21016 1.21015,1.16982 1.21015,2.86404 v 5.60705 q 0,0.68576 0.48407,1.21016 0.5244,0.48406 1.21015,0.48406 0.7261,0 1.21016,-0.48406 0.48406,-0.5244 0.48406,-1.21016 v -5.60705 q 0,-1.69422 1.21016,-2.86404 1.21015,-1.21016 2.90437,-1.21016 1.69422,0 2.86404,1.21016 1.21016,1.16982 1.21016,2.86404 z" /> - </g> -</svg> diff --git a/src/client/assets/title.dark.svg b/src/client/assets/title.svg index 10139024ad..0e4e0b8b3b 100644 --- a/src/client/assets/title.dark.svg +++ b/src/client/assets/title.svg @@ -97,44 +97,44 @@ <g transform="matrix(0.28612302,0,0,0.28612302,17.176981,141.74334)" id="text4489-6" - style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:141.03404236px;line-height:476.69509888px;font-family:'OTADESIGN Rounded';-inkscape-font-specification:'OTADESIGN Rounded';letter-spacing:0px;word-spacing:0px;fill:#fff;fill-opacity:1;stroke:none;stroke-width:0.92471898px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" + style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:141.03404236px;line-height:476.69509888px;font-family:'OTADESIGN Rounded';-inkscape-font-specification:'OTADESIGN Rounded';letter-spacing:0px;word-spacing:0px;fill-opacity:1;stroke:none;stroke-width:0.92471898px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" aria-label="Mi"> <path sodipodi:nodetypes="zccssscssccscczzzccsccsscscsccz" inkscape:connector-curvature="0" id="path5210" - style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:'OTADESIGN Rounded';-inkscape-font-specification:'OTADESIGN Rounded';fill:#fff;fill-opacity:1;stroke-width:0.92471898px" + style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:'OTADESIGN Rounded';-inkscape-font-specification:'OTADESIGN Rounded';fill-opacity:1;stroke-width:0.92471898px" d="m 75.196381,231.17126 c -5.855419,0.0202 -10.885068,-3.50766 -13.2572,-7.61584 -1.266603,-1.79454 -3.772419,-2.43291 -3.807919,0 v 11.2332 c 0,4.51309 -1.645397,8.41504 -4.936191,11.70583 -3.196772,3.19677 -7.098714,4.79516 -11.705826,4.79516 -4.513089,0 -8.415031,-1.59839 -11.705825,-4.79516 -3.196772,-3.29079 -4.795158,-7.19274 -4.795158,-11.70583 v -61.7729 c 0,-3.47884 0.987238,-6.6286 2.961715,-9.44928 2.068499,-2.91471 4.701135,-4.9362 7.897906,-6.06447 1.786431,-0.65816 3.666885,-0.98724 5.641362,-0.98724 5.077225,0 9.308247,1.97448 12.693064,5.92343 1.786431,1.97448 2.820681,3.00873 3.102749,3.10275 0,0 13.408119,16.21319 13.78421,16.49526 0.376091,0.28206 1.480789,2.43848 4.127113,2.43848 2.646324,0 3.89218,-2.15642 4.26827,-2.43848 0.376091,-0.28207 13.784088,-16.49526 13.784088,-16.49526 0.09402,0.094 1.081261,-0.94022 2.961715,-3.10275 3.478837,-3.94895 7.756866,-5.92343 12.834096,-5.92343 1.88045,0 3.76091,0.32908 5.64136,0.98724 3.19677,1.12827 5.7824,3.14976 7.75688,6.06447 2.06849,2.82068 3.10274,5.97044 3.10274,9.44928 v 61.7729 c 0,4.51309 -1.6454,8.41504 -4.93619,11.70583 -3.19677,3.19677 -7.09871,4.79516 -11.70582,4.79516 -4.51309,0 -8.41504,-1.59839 -11.705828,-4.79516 -3.196772,-3.29079 -4.795158,-7.19274 -4.795158,-11.70583 v -11.2332 c -0.277898,-3.06563 -2.987588,-1.13379 -3.948953,0 -2.538613,4.70114 -7.401781,7.59567 -13.2572,7.61584 z" /> <path inkscape:connector-curvature="0" id="path5212" - style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:'OTADESIGN Rounded';-inkscape-font-specification:'OTADESIGN Rounded';fill:#fff;fill-opacity:1;stroke-width:0.92471898px" + style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:'OTADESIGN Rounded';-inkscape-font-specification:'OTADESIGN Rounded';fill-opacity:1;stroke-width:0.92471898px" d="m 145.83461,185.00361 q -5.92343,0 -10.15445,-4.08999 -4.08999,-4.23102 -4.08999,-10.15445 0,-5.92343 4.08999,-10.01342 4.23102,-4.23102 10.15445,-4.23102 5.92343,0 10.15445,4.23102 4.23102,4.08999 4.23102,10.01342 0,5.92343 -4.23102,10.15445 -4.23102,4.08999 -10.15445,4.08999 z m 0.14103,2.82068 q 5.92343,0 10.01342,4.23102 4.23102,4.23102 4.23102,10.15445 v 34.83541 q 0,5.92343 -4.23102,10.15445 -4.08999,4.08999 -10.01342,4.08999 -5.92343,0 -10.15445,-4.08999 -4.23102,-4.23102 -4.23102,-10.15445 v -34.83541 q 0,-5.92343 4.23102,-10.15445 4.23102,-4.23102 10.15445,-4.23102 z" /> </g> <path inkscape:connector-curvature="0" id="path5199" - style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:136.34428406px;font-family:'OTADESIGN Rounded';-inkscape-font-specification:'OTADESIGN Rounded';letter-spacing:0px;word-spacing:0px;fill:#fff;fill-opacity:1;stroke:none;stroke-width:0.26458335px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" + style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:136.34428406px;font-family:'OTADESIGN Rounded';-inkscape-font-specification:'OTADESIGN Rounded';letter-spacing:0px;word-spacing:0px;fill-opacity:1;stroke:none;stroke-width:0.26458335px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" d="m 72.022691,200.53715 q 0.968125,0.24203 2.420312,0.5244 2.420313,0.40339 3.791824,1.29083 2.581666,1.69422 2.581666,5.08266 0,2.74302 -1.815234,4.47758 -2.097604,2.01693 -5.849089,2.01693 -2.743021,0 -6.131458,-0.76644 -1.089141,-0.24203 -1.774896,-1.08914 -0.645417,-0.84711 -0.645417,-1.89591 0,-1.29083 0.887448,-2.17828 0.927786,-0.92779 2.178281,-0.92779 0.363047,0 0.685756,0.0807 1.169817,0.24203 4.477578,0.60508 0.443724,0 0.968125,-0.0403 0.201693,0 0.201693,-0.24203 0.04034,-0.20169 -0.242032,-0.28237 -1.37151,-0.24203 -2.541328,-0.5244 -1.331172,-0.28237 -1.895911,-0.48406 -1.12948,-0.32271 -1.895912,-0.84711 -2.581667,-1.69422 -2.622005,-5.08266 0,-2.70268 1.855573,-4.47758 2.258958,-2.17828 6.413828,-1.97659 2.783359,0.12102 5.566719,0.7261 1.048802,0.24203 1.734557,1.08914 0.685756,0.84711 0.685756,1.93625 0,1.25049 -0.927787,2.17828 -0.887448,0.88745 -2.178281,0.88745 -0.322709,0 -0.645417,-0.0807 -1.169818,-0.24203 -4.517917,-0.56474 -0.403385,-0.0403 -0.766432,0 -0.322708,0.0403 -0.322708,0.24203 0.04034,0.24203 0.322708,0.32271 z" /> <path inkscape:connector-curvature="0" id="path5201" - style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:136.34428406px;font-family:'OTADESIGN Rounded';-inkscape-font-specification:'OTADESIGN Rounded';letter-spacing:0px;word-spacing:0px;fill:#fff;fill-opacity:1;stroke:none;stroke-width:0.26458335px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" + style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:136.34428406px;font-family:'OTADESIGN Rounded';-inkscape-font-specification:'OTADESIGN Rounded';letter-spacing:0px;word-spacing:0px;fill-opacity:1;stroke:none;stroke-width:0.26458335px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" d="m 89.577027,200.53715 q 0.968125,0.24203 2.420312,0.5244 2.420313,0.40339 3.791823,1.29083 2.581667,1.69422 2.581667,5.08266 0,2.74302 -1.815234,4.47758 -2.097604,2.01693 -5.849089,2.01693 -2.743021,0 -6.131458,-0.76644 -1.089141,-0.24203 -1.774896,-1.08914 -0.645417,-0.84711 -0.645417,-1.89591 0,-1.29083 0.887448,-2.17828 0.927786,-0.92779 2.178281,-0.92779 0.363047,0 0.685755,0.0807 1.169818,0.24203 4.477579,0.60508 0.443724,0 0.968125,-0.0403 0.201692,0 0.201692,-0.24203 0.04034,-0.20169 -0.242031,-0.28237 -1.37151,-0.24203 -2.541328,-0.5244 -1.331172,-0.28237 -1.895912,-0.48406 -1.129479,-0.32271 -1.895911,-0.84711 -2.581667,-1.69422 -2.622005,-5.08266 0,-2.70268 1.855573,-4.47758 2.258958,-2.17828 6.413828,-1.97659 2.783359,0.12102 5.566719,0.7261 1.048802,0.24203 1.734557,1.08914 0.685755,0.84711 0.685755,1.93625 0,1.25049 -0.927786,2.17828 -0.887448,0.88745 -2.178281,0.88745 -0.322709,0 -0.645417,-0.0807 -1.169818,-0.24203 -4.517917,-0.56474 -0.403385,-0.0403 -0.766432,0 -0.322708,0.0403 -0.322708,0.24203 0.04034,0.24203 0.322708,0.32271 z" /> <path inkscape:connector-curvature="0" id="path5203" - style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:136.34428406px;font-family:'OTADESIGN Rounded';-inkscape-font-specification:'OTADESIGN Rounded';letter-spacing:0px;word-spacing:0px;fill:#fff;fill-opacity:1;stroke:none;stroke-width:0.26458335px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" + style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:136.34428406px;font-family:'OTADESIGN Rounded';-inkscape-font-specification:'OTADESIGN Rounded';letter-spacing:0px;word-spacing:0px;fill-opacity:1;stroke:none;stroke-width:0.26458335px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" d="m 115.65209,203.87137 q 0.12101,0.0807 2.86404,2.78336 1.25049,1.21016 1.25049,2.94471 0,1.61354 -1.16982,2.86404 -1.16982,1.21016 -2.90437,1.21016 -1.65388,0 -2.86404,-1.16982 l -4.03385,-3.91284 q -0.16136,-0.12102 -0.32271,-0.12102 -0.32271,0 -0.32271,1.21016 0,1.69422 -1.21016,2.90438 -1.21015,1.16981 -2.90437,1.16981 -1.69422,0 -2.90438,-1.16981 -1.169807,-1.21016 -1.169807,-2.90438 v -18.79776 q 0,-1.69422 1.169807,-2.86404 1.21016,-1.21015 2.90438,-1.21015 1.69422,0 2.90437,1.21015 1.21016,1.16982 1.21016,2.86404 v 6.29281 q 0,0.40339 0.28237,0.5244 0.24203,0.12102 0.5244,-0.0807 0.16135,-0.0807 4.84063,-3.18675 1.0488,-0.64542 2.25895,-0.64542 2.21862,0 3.42878,1.81524 0.64542,1.0488 0.64542,2.25896 0,2.21862 -1.81524,3.42877 l -2.54133,1.61354 v 0.0403 l -0.0807,0.0403 q -0.56474,0.36305 -0.0403,0.88745 z" /> <path inkscape:connector-curvature="0" id="path5205" - style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:136.34428406px;font-family:'OTADESIGN Rounded';-inkscape-font-specification:'OTADESIGN Rounded';letter-spacing:0px;word-spacing:0px;fill:#fff;fill-opacity:1;stroke:none;stroke-width:0.26458335px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" + style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:136.34428406px;font-family:'OTADESIGN Rounded';-inkscape-font-specification:'OTADESIGN Rounded';letter-spacing:0px;word-spacing:0px;fill-opacity:1;stroke:none;stroke-width:0.26458335px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" d="m 131.25181,213.92955 q -4.19521,0 -7.18026,-2.94472 -2.94472,-2.98505 -2.94472,-7.18026 0,-4.15487 2.94472,-7.09958 2.98505,-2.98505 7.18026,-2.98505 4.15487,0 6.97857,2.78335 0.92778,0.92779 0.92778,2.25896 0,1.33118 -0.92778,2.25896 l -4.67928,4.63893 q -1.00846,1.00847 -2.01692,1.00847 -1.45219,0 -2.25896,-0.80677 -0.80677,-0.80677 -0.80677,-2.13795 0,-1.29083 0.92778,-2.21862 l 0.80678,-0.84711 q 0.16135,-0.12101 0.0807,-0.24203 -0.12101,-0.0807 -0.32271,-0.0403 -0.80677,0.20169 -1.37151,0.80677 -1.12948,1.08914 -1.12948,2.622 0,1.5732 1.08915,2.70268 1.12947,1.08914 2.70268,1.08914 1.53286,0 2.622,-1.12947 0.92779,-0.92779 2.25896,-0.92779 1.33117,0 2.25896,0.92779 0.92779,0.92778 0.92779,2.25895 0,1.33118 -0.92779,2.25896 -2.98505,2.94472 -7.13992,2.94472 z" /> <path inkscape:connector-curvature="0" id="path5207" - style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:136.34428406px;font-family:'OTADESIGN Rounded';-inkscape-font-specification:'OTADESIGN Rounded';letter-spacing:0px;word-spacing:0px;fill:#fff;fill-opacity:1;stroke:none;stroke-width:0.26458335px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" + style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:136.34428406px;font-family:'OTADESIGN Rounded';-inkscape-font-specification:'OTADESIGN Rounded';letter-spacing:0px;word-spacing:0px;fill-opacity:1;stroke:none;stroke-width:0.26458335px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" d="m 160.51049,198.1433 v 5.60705 q 0,0.56474 -0.0807,1.21016 v 7.38195 q 0,4.51792 -2.74302,7.2206 -2.70268,2.70269 -7.30128,2.70269 -2.66234,0 -4.80028,-1.00847 -2.13795,-0.96812 -2.13795,-3.3481 0,-0.80677 0.36305,-1.53286 0.96812,-2.17828 3.3481,-2.17828 0.56474,0 1.5732,0.32271 1.00847,0.3227 1.65388,0.3227 1.69422,0 2.21862,-0.72609 0.20169,-0.28237 0.0807,-0.44372 -0.16136,-0.24204 -0.56474,-0.16136 -0.68576,0.12102 -1.49253,0.12102 -4.07419,0 -6.97856,-2.90438 -2.90438,-2.90437 -2.90438,-6.97857 v -5.60705 q 0,-1.69422 1.16982,-2.86404 1.21015,-1.21016 2.90437,-1.21016 1.69422,0 2.90438,1.21016 1.21015,1.16982 1.21015,2.86404 v 5.60705 q 0,0.68576 0.48407,1.21016 0.5244,0.48406 1.21015,0.48406 0.7261,0 1.21016,-0.48406 0.48406,-0.5244 0.48406,-1.21016 v -5.60705 q 0,-1.69422 1.21016,-2.86404 1.21015,-1.21016 2.90437,-1.21016 1.69422,0 2.86404,1.21016 1.21016,1.16982 1.21016,2.86404 z" /> </g> </svg> diff --git a/src/client/const.styl b/src/client/const.styl deleted file mode 100644 index b6560701d9..0000000000 --- a/src/client/const.styl +++ /dev/null @@ -1,4 +0,0 @@ -json('../const.json') - -$theme-color = themeColor -$theme-color-foreground = themeColorForeground diff --git a/src/client/element.scss b/src/client/element.scss deleted file mode 100644 index 917198e024..0000000000 --- a/src/client/element.scss +++ /dev/null @@ -1,12 +0,0 @@ -/* Element variable definitons */ -/* SEE: http://element.eleme.io/#/en-US/component/custom-theme */ - -@import '../const.json'; - -/* theme color */ -$--color-primary: $themeColor; - -/* icon font path, required */ -$--font-path: '~element-ui/lib/theme-chalk/fonts'; - -@import "~element-ui/packages/theme-chalk/src/index"; diff --git a/src/client/style.styl b/src/client/style.styl index 6d1e53e5a6..8ebba2f15e 100644 --- a/src/client/style.styl +++ b/src/client/style.styl @@ -1,10 +1,8 @@ @charset 'utf-8' -@import "./const" - /* ::selection - background $theme-color + background var(--primary) color #fff */ @@ -24,10 +22,8 @@ html, body a text-decoration none - color $theme-color + color var(--primary) cursor pointer - tap-highlight-color rgba($theme-color, 0.7) !important - -webkit-tap-highlight-color rgba($theme-color, 0.7) !important &:hover text-decoration underline @@ -35,3 +31,9 @@ a * cursor pointer +@css { + a { + tap-highlight-color: var(--primaryAlpha07) !important; + -webkit-tap-highlight-color: var(--primaryAlpha07) !important; + } +} diff --git a/src/client/theme/black.json5 b/src/client/theme/black.json5 new file mode 100644 index 0000000000..91a812f88a --- /dev/null +++ b/src/client/theme/black.json5 @@ -0,0 +1,20 @@ +{ + id: 'bb5a8287-a072-4b0a-8ae5-ea2a0d33f4f2', + + name: 'Future', + author: 'syuilo', + + base: 'dark', + + vars: { + primary: 'rgb(94, 158, 185)', + secondary: 'rgb(22, 24, 30)', + text: 'rgb(214, 218, 224)', + }, + + props: { + renoteGradient: '#0a2d3c', + renoteText: '$primary', + quoteBorder: '$primary', + }, +} diff --git a/src/client/theme/dark.json5 b/src/client/theme/dark.json5 new file mode 100644 index 0000000000..4fa38a3ae0 --- /dev/null +++ b/src/client/theme/dark.json5 @@ -0,0 +1,209 @@ +{ + id: 'dark', + + name: 'Dark', + author: 'syuilo', + desc: 'Default dark theme', + kind: 'dark', + + vars: { + primary: '#fb4e4e', + secondary: '#282C37', + text: '#d6dae0', + }, + + props: { + primary: '$primary', + primaryForeground: '#fff', + secondary: '$secondary', + bg: ':darken<8<$secondary', + text: '$text', + + scrollbarTrack: ':darken<5<$secondary', + scrollbarHandle: ':lighten<5<$secondary', + scrollbarHandleHover: ':lighten<10<$secondary', + + face: '$secondary', + faceText: '#fff', + faceHeader: ':lighten<5<$secondary', + faceHeaderText: '#e3e5e8', + faceDivider: 'rgba(0, 0, 0, 0.3)', + faceTextButton: '$text', + faceTextButtonHover: ':lighten<10<$text', + faceTextButtonActive: ':darken<10<$text', + faceClearButtonHover: 'rgba(0, 0, 0, 0.1)', + faceClearButtonActive: 'rgba(0, 0, 0, 0.2)', + popupBg: ':lighten<5<$secondary', + popupFg: '#d6dce2', + + subNoteBg: 'rgba(0, 0, 0, 0.18)', + subNoteText: ':alpha<0.7<$text', + renoteGradient: '#314027', + renoteText: '#9dbb00', + quoteBorder: '#4e945e', + noteText: '#fff', + noteHeaderName: '#fff', + noteHeaderBadgeFg: '#758188', + noteHeaderBadgeBg: 'rgba(0, 0, 0, 0.25)', + noteHeaderAdminFg: '#f15f71', + noteHeaderAdminBg: '#5d282e', + noteHeaderAcct: ':alpha<0.65<$text', + noteHeaderInfo: ':alpha<0.5<$text', + + noteActions: ':alpha<0.45<$text', + noteActionsHover: ':alpha<0.6<$text', + noteActionsReplyHover: '#0af', + noteActionsRenoteHover: '#8d0', + noteActionsReactionHover: '#fa0', + noteActionsHighlighted: ':alpha<0.7<$text', + + noteAttachedFile: 'rgba(255, 255, 255, 0.1)', + + modalBackdrop: 'rgba(0, 0, 0, 0.5)', + + dateDividerBg: ':darken<2<$secondary', + dateDividerFg: ':alpha<0.7<$text', + + switchTrack: 'rgba(255, 255, 255, 0.15)', + radioBorder: 'rgba(255, 255, 255, 0.6)', + inputBorder: 'rgba(255, 255, 255, 0.7)', + inputLabel: 'rgba(255, 255, 255, 0.7)', + inputText: '#fff', + + buttonBg: 'rgba(255, 255, 255, 0.05)', + buttonHoverBg: 'rgba(255, 255, 255, 0.1)', + buttonActiveBg: 'rgba(255, 255, 255, 0.15)', + + autocompleteItemHoverBg: 'rgba(255, 255, 255, 0.1)', + autocompleteItemText: 'rgba(255, 255, 255, 0.8)', + autocompleteItemTextSub: 'rgba(255, 255, 255, 0.3)', + + cwButtonBg: '#687390', + cwButtonFg: '#393f4f', + cwButtonHoverBg: '#707b97', + + reactionPickerButtonHoverBg: 'rgba(255, 255, 255, 0.18)', + + reactionViewerBorder: 'rgba(255, 255, 255, 0.1)', + + pollEditorInputBg: 'rgba(0, 0, 0, 0.25)', + + pollChoiceText: '#fff', + pollChoiceBorder: 'rgba(255, 255, 255, 0.1)', + + urlPreviewBorder: 'rgba(0, 0, 0, 0.4)', + urlPreviewBorderHover: 'rgba(255, 255, 255, 0.2)', + urlPreviewTitle: '$text', + urlPreviewText: ':alpha<0.7<$text', + urlPreviewInfo: ':alpha<0.8<$text', + + calendarWeek: '#43d5dc', + calendarSaturdayOrSunday: '#ff6679', + calendarDay: '$text', + + materBg: 'rgba(0, 0, 0, 0.3)', + + chartCaption: ':alpha<0.6<$text', + + announcementsBg: '#253a50', + announcementsTitle: '#539eff', + announcementsText: '#fff', + + donationBg: '#5d5242', + donationFg: '#e4dbce', + + googleSearchBg: 'rgba(0, 0, 0, 0.2)', + googleSearchFg: '#dee4e8', + googleSearchBorder: 'rgba(255, 255, 255, 0.2)', + googleSearchHoverBorder: 'rgba(255, 255, 255, 0.3)', + googleSearchHoverButton: 'rgba(255, 255, 255, 0.1)', + + mfmTitleBg: 'rgba(0, 0, 0, 0.2)', + mfmQuote: ':alpha<0.7<$text', + mfmQuoteLine: ':alpha<0.6<$text', + + suspendedInfoBg: '#611d1d', + suspendedInfoFg: '#ffb4b4', + remoteInfoBg: '#42321c', + remoteInfoFg: '#ffbd3e', + + messagingRoomBg: '@bg', + messagingRoomInfo: '#fff', + messagingRoomDateDividerLine: 'rgba(255, 255, 255, 0.1)', + messagingRoomDateDividerText: 'rgba(255, 255, 255, 0.3)', + messagingRoomMessageInfo: 'rgba(255, 255, 255, 0.4)', + messagingRoomMessageBg: '$secondary', + messagingRoomMessageFg: '#fff', + + formButtonBorder: 'rgba(255, 255, 255, 0.1)', + formButtonHoverBg: ':alpha<0.2<$primary', + formButtonHoverBorder: ':alpha<0.5<$primary', + formButtonActiveBg: ':alpha<0.12<$primary', + + desktopHeaderBg: ':lighten<5<$secondary', + desktopHeaderFg: '$text', + desktopHeaderHoverFg: '#fff', + desktopHeaderSearchBg: 'rgba(0, 0, 0, 0.1)', + desktopHeaderSearchHoverBg: 'rgba(255, 255, 255, 0.04)', + desktopHeaderSearchFg: '#fff', + desktopNotificationBg: ':alpha<0.9<$secondary', + desktopNotificationFg: ':alpha<0.7<$text', + desktopNotificationShadow: 'rgba(0, 0, 0, 0.4)', + desktopPostFormBg: '@face', + desktopPostFormTextareaBg: 'rgba(0, 0, 0, 0.25)', + desktopPostFormTextareaFg: '#fff', + desktopPostFormTransparentButtonFg: '$primary', + desktopPostFormTransparentButtonActiveGradientStart: ':darken<8<$secondary', + desktopPostFormTransparentButtonActiveGradientEnd: ':darken<3<$secondary', + desktopRenoteFormFooter: ':lighten<5<$secondary', + desktopTimelineHeaderShadow: 'rgba(0, 0, 0, 0.15)', + desktopTimelineSrc: '@faceTextButton', + desktopTimelineSrcHover: '@faceTextButtonHover', + desktopWindowTitle: '@faceHeaderText', + desktopWindowShadow: 'rgba(0, 0, 0, 0.5)', + desktopDriveBg: '@bg', + desktopDriveFolderBg: ':alpha<0.2<$primary', + desktopDriveFolderHoverBg: ':alpha<0.3<$primary', + desktopDriveFolderActiveBg: ':alpha<0.3<:darken<10<$primary', + desktopDriveFolderFg: '#fff', + desktopSettingsNavItem: ':alpha<0.8<$text', + desktopSettingsNavItemHover: ':lighten<10<$text', + + deckAcrylicColumnBg: 'rgba(0, 0, 0, 0.25)', + + mobileHeaderBg: ':lighten<5<$secondary', + mobileHeaderFg: '$text', + mobileNavBackdrop: 'rgba(0, 0, 0, 0.7)', + mobilePostFormDivider: 'rgba(0, 0, 0, 0.2)', + mobilePostFormTextareaBg: 'rgba(0, 0, 0, 0.3)', + mobilePostFormButton: '$text', + mobileDriveNavBg: ':alpha<0.75<$secondary', + mobileHomeTlItemHover: 'rgba(255, 255, 255, 0.1)', + mobileUserPageName: '#fff', + mobileUserPageAcct: '$text', + mobileUserPageDescription: '$text', + mobileUserPageFollowedBg: 'rgba(0, 0, 0, 0.3)', + mobileUserPageFollowedFg: '$text', + mobileUserPageStatusHighlight: '#fff', + mobileUserPageHeaderShadow: 'rgba(0, 0, 0, 0.3)', + mobileAnnouncement: 'rgba(30, 129, 216, 0.2)', + mobileAnnouncementFg: '#fff', + mobileSignedInAsBg: '#273c34', + mobileSignedInAsFg: '#49ab63', + mobileSignoutBg: '#652222', + mobileSignoutFg: '#ff5f56', + + reversiBannerGradientStart: '#45730e', + reversiBannerGradientEnd: '#464300', + reversiDescBg: 'rgba(255, 255, 255, 0.1)', + reversiListItemShadow: 'rgba(0, 0, 0, 0.7)', + reversiMapSelectBorder: 'rgba(255, 255, 255, 0.1)', + reversiMapSelectHoverBorder: 'rgba(255, 255, 255, 0.2)', + reversiRoomFormShadow: 'rgba(0, 0, 0, 0.7)', + reversiRoomFooterBg: ':alpha<0.9<$secondary', + reversiGameHeaderLine: ':alpha<0.5<$secondary', + reversiGameEmptyCell: ':lighten<2<$secondary', + reversiGameEmptyCellMyTurn: ':lighten<5<$secondary', + reversiGameEmptyCellCanPut: ':lighten<4<$secondary', + }, +} diff --git a/src/client/theme/halloween.json5 b/src/client/theme/halloween.json5 new file mode 100644 index 0000000000..608105903a --- /dev/null +++ b/src/client/theme/halloween.json5 @@ -0,0 +1,21 @@ +{ + id: '42e4f09b-67d5-498c-af7d-29faa54745b0', + + name: 'Halloween', + author: 'syuilo', + desc: 'Hello, Happy Halloween!', + + base: 'dark', + + vars: { + primary: '#d67036', + secondary: '#1f1d30', + text: '#b1bee3', + }, + + props: { + renoteGradient: '#5d2d1a', + renoteText: '#ff6c00', + quoteBorder: '#c3631c', + }, +} diff --git a/src/client/theme/light.json5 b/src/client/theme/light.json5 new file mode 100644 index 0000000000..9f17a63dda --- /dev/null +++ b/src/client/theme/light.json5 @@ -0,0 +1,209 @@ +{ + id: 'light', + + name: 'Light', + author: 'syuilo', + desc: 'Default light theme', + kind: 'light', + + vars: { + primary: '#fb4e4e', + secondary: '#fff', + text: '#666', + }, + + props: { + primary: '$primary', + primaryForeground: '#fff', + secondary: '$secondary', + bg: ':darken<8<$secondary', + text: '$text', + + scrollbarTrack: '#fff', + scrollbarHandle: '#00000033', + scrollbarHandleHover: '#00000066', + + face: '$secondary', + faceText: '$text', + faceHeader: ':lighten<5<$secondary', + faceHeaderText: '$text', + faceDivider: 'rgba(0, 0, 0, 0.082)', + faceTextButton: ':alpha<0.7<$text', + faceTextButtonHover: ':alpha<0.7<:darken<7<$text', + faceTextButtonActive: ':alpha<0.7<:darken<10<$text', + faceClearButtonHover: 'rgba(0, 0, 0, 0.025)', + faceClearButtonActive: 'rgba(0, 0, 0, 0.05)', + popupBg: ':lighten<5<$secondary', + popupFg: '#586069', + + subNoteBg: 'rgba(0, 0, 0, 0.01)', + subNoteText: ':alpha<0.7<$text', + renoteGradient: '#edfde2', + renoteText: '#9dbb00', + quoteBorder: '#c0dac6', + noteText: '$text', + noteHeaderName: ':darken<2<$text', + noteHeaderBadgeFg: '#aaa', + noteHeaderBadgeBg: 'rgba(0, 0, 0, 0.05)', + noteHeaderAdminFg: '#f15f71', + noteHeaderAdminBg: '#ffdfdf', + noteHeaderAcct: ':alpha<0.7<@noteHeaderName', + noteHeaderInfo: ':alpha<0.7<@noteHeaderName', + + noteActions: ':alpha<0.3<$text', + noteActionsHover: ':alpha<0.9<$text', + noteActionsReplyHover: '#0af', + noteActionsRenoteHover: '#8d0', + noteActionsReactionHover: '#fa0', + noteActionsHighlighted: '#888', + + noteAttachedFile: 'rgba(0, 0, 0, 0.05)', + + modalBackdrop: 'rgba(0, 0, 0, 0.1)', + + dateDividerBg: ':darken<2<$secondary', + dateDividerFg: ':alpha<0.7<$text', + + switchTrack: 'rgba(0, 0, 0, 0.25)', + radioBorder: 'rgba(0, 0, 0, 0.4)', + inputBorder: 'rgba(0, 0, 0, 0.42)', + inputLabel: 'rgba(0, 0, 0, 0.54)', + inputText: '#000', + + buttonBg: 'rgba(0, 0, 0, 0.05)', + buttonHoverBg: 'rgba(0, 0, 0, 0.1)', + buttonActiveBg: 'rgba(0, 0, 0, 0.15)', + + autocompleteItemHoverBg: 'rgba(0, 0, 0, 0.1)', + autocompleteItemText: 'rgba(0, 0, 0, 0.8)', + autocompleteItemTextSub: 'rgba(0, 0, 0, 0.3)', + + cwButtonBg: '#b1b9c1', + cwButtonFg: '#fff', + cwButtonHoverBg: '#bbc4ce', + + reactionPickerButtonHoverBg: '#eee', + + reactionViewerBorder: 'rgba(0, 0, 0, 0.1)', + + pollEditorInputBg: '#fff', + + pollChoiceText: '#000', + pollChoiceBorder: 'rgba(0, 0, 0, 0.1)', + + urlPreviewBorder: 'rgba(0, 0, 0, 0.1)', + urlPreviewBorderHover: 'rgba(0, 0, 0, 0.2)', + urlPreviewTitle: '$text', + urlPreviewText: ':alpha<0.7<$text', + urlPreviewInfo: ':alpha<0.8<$text', + + calendarWeek: '#19a2a9', + calendarSaturdayOrSunday: '#ef95a0', + calendarDay: '$text', + + materBg: 'rgba(0, 0, 0, 0.1)', + + chartCaption: ':alpha<0.6<$text', + + announcementsBg: '#f3f9ff', + announcementsTitle: '#4078c0', + announcementsText: '#57616f', + + donationBg: '#fbead4', + donationFg: '#777d71', + + googleSearchBg: '#fff', + googleSearchFg: '#55595c', + googleSearchBorder: 'rgba(0, 0, 0, 0.2)', + googleSearchHoverBorder: 'rgba(0, 0, 0, 0.3)', + googleSearchHoverButton: 'rgba(0, 0, 0, 0.05)', + + mfmTitleBg: 'rgba(0, 0, 0, 0.07)', + mfmQuote: ':alpha<0.6<$text', + mfmQuoteLine: ':alpha<0.5<$text', + + suspendedInfoBg: '#ffdbdb', + suspendedInfoFg: '#570808', + remoteInfoBg: '#fff0db', + remoteInfoFg: '#573c08', + + messagingRoomBg: '#fff', + messagingRoomInfo: '#000', + messagingRoomDateDividerLine: 'rgba(0, 0, 0, 0.1)', + messagingRoomDateDividerText: 'rgba(0, 0, 0, 0.3)', + messagingRoomMessageInfo: 'rgba(0, 0, 0, 0.4)', + messagingRoomMessageBg: '#eee', + messagingRoomMessageFg: '#333', + + formButtonBorder: 'rgba(0, 0, 0, 0.1)', + formButtonHoverBg: ':alpha<0.12<$primary', + formButtonHoverBorder: ':alpha<0.3<$primary', + formButtonActiveBg: ':alpha<0.12<$primary', + + desktopHeaderBg: ':lighten<5<$secondary', + desktopHeaderFg: '$text', + desktopHeaderHoverFg: ':darken<7<$text', + desktopHeaderSearchBg: 'rgba(0, 0, 0, 0.05)', + desktopHeaderSearchHoverBg: 'rgba(0, 0, 0, 0.08)', + desktopHeaderSearchFg: '#000', + desktopNotificationBg: ':alpha<0.9<$secondary', + desktopNotificationFg: ':alpha<0.7<$text', + desktopNotificationShadow: 'rgba(0, 0, 0, 0.2)', + desktopPostFormBg: ':lighten<33<$primary', + desktopPostFormTextareaBg: '#fff', + desktopPostFormTextareaFg: '#333', + desktopPostFormTransparentButtonFg: ':alpha<0.5<$primary', + desktopPostFormTransparentButtonActiveGradientStart: ':lighten<30<$primary', + desktopPostFormTransparentButtonActiveGradientEnd: ':lighten<33<$primary', + desktopRenoteFormFooter: ':lighten<33<$primary', + desktopTimelineHeaderShadow: 'rgba(0, 0, 0, 0.08)', + desktopTimelineSrc: '$text', + desktopTimelineSrcHover: ':darken<7<$text', + desktopWindowTitle: '$text', + desktopWindowShadow: 'rgba(0, 0, 0, 0.2)', + desktopDriveBg: '#fff', + desktopDriveFolderBg: ':lighten<31<$primary', + desktopDriveFolderHoverBg: ':lighten<27<$primary', + desktopDriveFolderActiveBg: ':lighten<25<$primary', + desktopDriveFolderFg: ':darken<10<$primary', + desktopSettingsNavItem: ':alpha<0.8<$text', + desktopSettingsNavItemHover: ':darken<10<$text', + + deckAcrylicColumnBg: 'rgba(0, 0, 0, 0.1)', + + mobileHeaderBg: ':lighten<5<$secondary', + mobileHeaderFg: '$text', + mobileNavBackdrop: 'rgba(0, 0, 0, 0.2)', + mobilePostFormDivider: 'rgba(0, 0, 0, 0.1)', + mobilePostFormTextareaBg: '#fff', + mobilePostFormButton: '$text', + mobileDriveNavBg: ':alpha<0.75<$secondary', + mobileHomeTlItemHover: 'rgba(0, 0, 0, 0.05)', + mobileUserPageName: '#757c82', + mobileUserPageAcct: '#969ea5', + mobileUserPageDescription: '#757c82', + mobileUserPageFollowedBg: '#a7bec7', + mobileUserPageFollowedFg: '#fff', + mobileUserPageStatusHighlight: '#787e86', + mobileUserPageHeaderShadow: 'rgba(0, 0, 0, 0.07)', + mobileAnnouncement: 'rgba(155, 196, 232, 0.2)', + mobileAnnouncementFg: '#3f4967', + mobileSignedInAsBg: '#fcfff5', + mobileSignedInAsFg: '#2c662d', + mobileSignoutBg: '#fff6f5', + mobileSignoutFg: '#cc2727', + + reversiBannerGradientStart: '#8bca3e', + reversiBannerGradientEnd: '#d6cf31', + reversiDescBg: 'rgba(0, 0, 0, 0.1)', + reversiListItemShadow: 'rgba(0, 0, 0, 0.15)', + reversiMapSelectBorder: 'rgba(0, 0, 0, 0.1)', + reversiMapSelectHoverBorder: 'rgba(0, 0, 0, 0.2)', + reversiRoomFormShadow: 'rgba(0, 0, 0, 0.1)', + reversiRoomFooterBg: ':alpha<0.9<$secondary', + reversiGameHeaderLine: '#c4cdd4', + reversiGameEmptyCell: 'rgba(0, 0, 0, 0.06)', + reversiGameEmptyCellMyTurn: 'rgba(0, 0, 0, 0.12)', + reversiGameEmptyCellCanPut: 'rgba(0, 0, 0, 0.9)', + }, +} diff --git a/src/client/theme/pink.json5 b/src/client/theme/pink.json5 new file mode 100644 index 0000000000..71e963dc91 --- /dev/null +++ b/src/client/theme/pink.json5 @@ -0,0 +1,20 @@ +{ + id: 'e9c8c01d-9c15-48d0-9b5c-3d00843b5b36', + + name: 'Lavender', + author: 'sokuyuku & syuilo', + + base: 'light', + + vars: { + primary: 'rgb(206, 147, 191)', + secondary: 'rgb(253, 242, 243)', + text: 'rgb(161, 139, 146)', + }, + + props: { + renoteGradient: '#f7e4ec', + renoteText: '$primary', + quoteBorder: '$primary', + }, +} diff --git a/src/config/load.ts b/src/config/load.ts index 8929cf8d3e..3a1bac3201 100644 --- a/src/config/load.ts +++ b/src/config/load.ts @@ -7,6 +7,7 @@ import { URL } from 'url'; import * as yaml from 'js-yaml'; import { Source, Mixin } from './types'; import isUrl = require('is-url'); +const pkg = require('../../package.json'); /** * Path of configuration directory @@ -43,6 +44,7 @@ export default function load() { mixin.stats_url = `${mixin.scheme}://${mixin.host}/stats`; mixin.status_url = `${mixin.scheme}://${mixin.host}/status`; mixin.drive_url = `${mixin.scheme}://${mixin.host}/files`; + mixin.user_agent = `Misskey/${pkg.version} (${config.url})`; if (config.localDriveCapacityMb == null) config.localDriveCapacityMb = 256; if (config.remoteDriveCapacityMb == null) config.remoteDriveCapacityMb = 8; diff --git a/src/config/types.ts b/src/config/types.ts index a1dc9a5bd4..003185accd 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -114,6 +114,7 @@ export type Mixin = { status_url: string; dev_url: string; drive_url: string; + user_agent: string; }; export type Config = Source & Mixin; diff --git a/src/const.json b/src/const.json index b93226b2d2..af9a22bce8 100644 --- a/src/const.json +++ b/src/const.json @@ -1,5 +1,5 @@ { "copyright": "Copyright (c) 2014-2018 syuilo", - "themeColor": "#f6584f", + "themeColor": "#fb4e4e", "themeColorForeground": "#fff" } diff --git a/src/daemons/notes-stats.ts b/src/daemons/notes-stats.ts index 3d2c4820a6..bddb54cfa5 100644 --- a/src/daemons/notes-stats.ts +++ b/src/daemons/notes-stats.ts @@ -16,7 +16,7 @@ export default function() { }); ev.on('requestNotesStatsLog', id => { - ev.emit('notesStatsLog:' + id, log.toArray()); + ev.emit(`notesStatsLog:${id}`, log.toArray()); }); process.on('exit', code => { diff --git a/src/daemons/server-stats.ts b/src/daemons/server-stats.ts index 4a653f81f4..9bb43fe84e 100644 --- a/src/daemons/server-stats.ts +++ b/src/daemons/server-stats.ts @@ -16,7 +16,7 @@ export default function() { const log = new Deque<any>(); ev.on('requestServerStatsLog', x => { - ev.emit('serverStatsLog:' + x.id, log.toArray().slice(0, x.length || 50)); + ev.emit(`serverStatsLog:${x.id}`, log.toArray().slice(0, x.length || 50)); }); async function tick() { diff --git a/src/db/elasticsearch.ts b/src/db/elasticsearch.ts index 4acff40793..ee5769d1d4 100644 --- a/src/db/elasticsearch.ts +++ b/src/db/elasticsearch.ts @@ -4,6 +4,12 @@ import config from '../config'; const index = { settings: { analysis: { + normalizer: { + lowercase_normalizer: { + type: 'custom', + filter: ['lowercase'] + } + }, analyzer: { bigram: { tokenizer: 'bigram_tokenizer' @@ -24,7 +30,8 @@ const index = { text: { type: 'text', index: true, - analyzer: 'bigram' + analyzer: 'bigram', + normalizer: 'lowercase_normalizer' } } } diff --git a/src/docs/api/entities/note.yaml b/src/docs/api/entities/note.yaml index cae9a53f82..6654be2b02 100644 --- a/src/docs/api/entities/note.yaml +++ b/src/docs/api/entities/note.yaml @@ -33,19 +33,19 @@ props: ja-JP: "投稿の本文" en-US: "The text of this note" - mediaIds: + fileIds: type: "id(DriveFile)[]" optional: true desc: - ja-JP: "添付されているメディアのID (なければレスポンスでは空配列)" - en-US: "The IDs of the attached media (empty array for response if no media is attached)" + ja-JP: "添付されているファイルのID (なければレスポンスでは空配列)" + en-US: "The IDs of the attached files (empty array for response if no files is attached)" - media: + files: type: "entity(DriveFile)[]" optional: true desc: - ja-JP: "添付されているメディア" - en-US: "The attached media" + ja-JP: "添付されているファイル" + en-US: "The attached files" userId: type: "id(User)" diff --git a/src/docs/api/entities/user.yaml b/src/docs/api/entities/user.yaml index c90b55ee88..e3755d8585 100644 --- a/src/docs/api/entities/user.yaml +++ b/src/docs/api/entities/user.yaml @@ -101,15 +101,15 @@ props: ja-JP: "投稿の数" en-US: "The number of the notes of this user" - pinnedNote: - type: "entity(Note)" + pinnedNotes: + type: "entity(Note)[]" optional: true desc: ja-JP: "ピン留めされた投稿" en-US: "The pinned note of this user" - pinnedNoteId: - type: "id(Note)" + pinnedNoteIds: + type: "id(Note)[]" optional: true desc: ja-JP: "ピン留めされた投稿のID" diff --git a/src/docs/base.pug b/src/docs/base.pug index 26f19ddf09..41eb80a64e 100644 --- a/src/docs/base.pug +++ b/src/docs/base.pug @@ -9,7 +9,7 @@ html(lang= lang) link(rel="stylesheet" href="/docs/assets/style.css") link(rel="stylesheet" href="//cdnjs.cloudflare.com/ajax/libs/highlight.js/9.12.0/styles/default.min.css") script(src="//cdnjs.cloudflare.com/ajax/libs/highlight.js/9.12.0/highlight.min.js") - link(rel="stylesheet" href="https://use.fontawesome.com/releases/v5.1.0/css/all.css" integrity="sha384-lKuwvrZot6UHsBSfcMvOkWwlCMgc0TaWr+30HWe3a4ltaBwTZhyTEggF5tJv8tbt" crossorigin="anonymous") + link(rel="stylesheet" href="https://use.fontawesome.com/releases/v5.3.1/css/all.css" integrity="sha384-mzrmE5qonljUremFsqc01SB46JvROS7bZs3IO2EmfFsd15uHvIt+Y8vEf7N7fWAU" crossorigin="anonymous") block meta body diff --git a/src/docs/keyboard-shortcut.ja-JP.md b/src/docs/keyboard-shortcut.ja-JP.md new file mode 100644 index 0000000000..264387242c --- /dev/null +++ b/src/docs/keyboard-shortcut.ja-JP.md @@ -0,0 +1,97 @@ +# キーボードショートカット + +## グローバル +これらのショートカットは基本的にどこでも使えます。 +<table> + <thead> + <tr><th>ショートカット</th><th>効果</th><th>由来</th></tr> + </thead> + <tbody> + <tr><td><kbd class="key">P</kbd>, <kbd class="key">N</kbd></td><td>新規投稿</td><td><b>P</b>ost, <b>N</b>ew, <b>N</b>ote</td></tr> + <tr><td><kbd class="key">T</kbd></td><td>タイムラインの最も新しい投稿にフォーカス</td><td><b>T</b>imeline, <b>T</b>op</td></tr> + <tr><td><kbd class="group"><kbd class="key">Shift</kbd> + <kbd class="key">N</kbd></kbd></td><td>通知を表示/隠す</td><td><b>N</b>otifications</td></tr> + <tr><td><kbd class="key">A</kbd>, <kbd class="key">M</kbd></td><td>アカウントメニューを表示/隠す</td><td><b>A</b>ccount, <b>M</b>y, <b>M</b>e, <b>M</b>enu</td></tr> + <tr><td><kbd class="key">D</kbd></td><td>ダークモード切り替え</td><td><b>D</b>ark</td></tr> + <tr><td><kbd class="key">Z</kbd></td><td>上部のバーを隠す</td><td><b>Z</b>en</td></tr> + <tr><td><kbd class="key">H</kbd>, <kbd class="key">?</kbd></td><td>ヘルプを表示</td><td><b>H</b>elp</td></tr> + </tbody> +</table> + +## 投稿にフォーカスされた状態 +<table> + <thead> + <tr><th>ショートカット</th><th>効果</th><th>由来</th></tr> + </thead> + <tbody> + <tr><td><kbd class="key">↑</kbd>, <kbd class="key">K</kbd>, <kbd class="group"><kbd class="key">Shift</kbd> + <kbd class="key">Tab</kbd></kbd></td><td>上の投稿にフォーカスを移動</td><td>-</td></tr> + <tr><td><kbd class="key">↓</kbd>, <kbd class="key">J</kbd>, <kbd class="key">Tab</kbd></td><td>下の投稿にフォーカスを移動</td><td>-</td></tr> + <tr><td><kbd class="key">←</kbd>, <kbd class="key">R</kbd></td><td>返信フォームを開く</td><td><b>R</b>eply</td></tr> + <tr><td><kbd class="key">→</kbd>, <kbd class="key">Q</kbd></td><td>Renoteフォームを開く</td><td><b>Q</b>uote</td></tr> + <tr><td><kbd class="group"><kbd class="key">Ctrl</kbd> + <kbd class="key">→</kbd></kbd>, <kbd class="group"><kbd class="key">Ctrl</kbd> + <kbd class="key">Q</kbd></kbd></td><td>即刻Renoteする(フォームを開かずに)</td><td>-</td></tr> + <tr><td><kbd class="key">E</kbd>, <kbd class="key">A</kbd>, <kbd class="key">+</kbd></td><td>リアクションフォームを開く</td><td><b>E</b>mote, re<b>A</b>ction</td></tr> + <tr><td><kbd class="key">0</kbd>~<kbd class="key">9</kbd></td><td>数字に対応したリアクションをする(対応については後述)</td><td>-</td></tr> + <tr><td><kbd class="key">M</kbd>, <kbd class="key">O</kbd></td><td>投稿に対するメニューを開く</td><td><b>M</b>ore, <b>O</b>ther</td></tr> + <tr><td><kbd class="key">S</kbd></td><td>CWで隠された部分を表示 or 隠す</td><td><b>S</b>how, <b>S</b>ee</td></tr> + <tr><td><kbd class="key">Esc</kbd></td><td>フォーカスを外す</td><td>-</td></tr> + </tbody> +</table> + +## Renoteフォーム +<table> + <thead> + <tr><th>ショートカット</th><th>効果</th><th>由来</th></tr> + </thead> + <tbody> + <tr><td><kbd class="key">Enter</kbd></td><td>Renoteする</td><td>-</td></tr> + <tr><td><kbd class="key">Q</kbd></td><td>フォームを展開する</td><td><b>Q</b>uote</td></tr> + <tr><td><kbd class="key">Esc</kbd></td><td>フォームを閉じる</td><td>-</td></tr> + </tbody> +</table> + +## リアクションフォーム +デフォルトで「👍」にフォーカスが当たっている状態です。 +<table> + <thead> + <tr><th>ショートカット</th><th>効果</th><th>由来</th></tr> + </thead> + <tbody> + <tr><td><kbd class="key">↑</kbd>, <kbd class="key">K</kbd></td><td>上のリアクションにフォーカスを移動</td><td>-</td></tr> + <tr><td><kbd class="key">↓</kbd>, <kbd class="key">J</kbd></td><td>下のリアクションにフォーカスを移動</td><td>-</td></tr> + <tr><td><kbd class="key">←</kbd>, <kbd class="key">H</kbd>, <kbd class="group"><kbd class="key">Shift</kbd> + <kbd class="key">Tab</kbd></kbd></td><td>左のリアクションにフォーカスを移動</td><td>-</td></tr> + <tr><td><kbd class="key">→</kbd>, <kbd class="key">L</kbd>, <kbd class="key">Tab</kbd></td><td>右のリアクションにフォーカスを移動</td><td>-</td></tr> + <tr><td><kbd class="key">Enter</kbd>, <kbd class="key">Space</kbd>, <kbd class="key">+</kbd></td><td>リアクション確定</td><td>-</td></tr> + <tr><td><kbd class="key">0</kbd>~<kbd class="key">9</kbd></td><td>数字に対応したリアクションで確定(対応については後述)</td><td>-</td></tr> + <tr><td><kbd class="key">Esc</kbd></td><td>リアクションするのをやめる</td><td>-</td></tr> + </tbody> +</table> + +## リアクションと数字キーの対応 +<table> + <thead> + <tr><th>数字キー</th><th>リアクション</th></tr> + </thead> + <tbody> + <tr><td><kbd class="key">1</kbd></td><td>👍</td></tr> + <tr><td><kbd class="key">2</kbd></td><td>❤️</td></tr> + <tr><td><kbd class="key">3</kbd></td><td>😆</td></tr> + <tr><td><kbd class="key">4</kbd></td><td>🤔</td></tr> + <tr><td><kbd class="key">5</kbd></td><td>😮</td></tr> + <tr><td><kbd class="key">6</kbd></td><td>🎉</td></tr> + <tr><td><kbd class="key">7</kbd></td><td>💢</td></tr> + <tr><td><kbd class="key">8</kbd></td><td>😥</td></tr> + <tr><td><kbd class="key">9</kbd></td><td>😇</td></tr> + <tr><td><kbd class="key">0</kbd></td><td>🍮 or 🍣</td></tr> + </tbody> +</table> + +# 例 +<table> + <thead> + <tr><th>ショートカット</th><th>動作</th></tr> + </thead> + <tbody> + <tr><td><kbd class="key">t</kbd><kbd class="key">+</kbd><kbd class="key">+</kbd></td><td>タイムラインの最新の投稿に👍する</td></tr> + <tr><td><kbd class="key">t</kbd><kbd class="key">1</kbd></td><td>タイムラインの最新の投稿に👍する</td></tr> + <tr><td><kbd class="key">t</kbd><kbd class="key">0</kbd></td><td>タイムラインの最新の投稿に🍮する</td></tr> + </tbody> +</table> diff --git a/src/docs/stream.ja-JP.md b/src/docs/stream.ja-JP.md index c720299932..a8b0eb0cdc 100644 --- a/src/docs/stream.ja-JP.md +++ b/src/docs/stream.ja-JP.md @@ -55,7 +55,7 @@ APIへリクエストすると、レスポンスがストリームから次の ```json { - type: 'api-res:xxxxxxxxxxxxxxxx', + type: 'api:xxxxxxxxxxxxxxxx', body: { ... } @@ -95,7 +95,7 @@ Misskeyは投稿のキャプチャと呼ばれる仕組みを提供していま ```json { - type: 'note-updated', + type: 'noteUpdated', body: { note: { ... @@ -108,7 +108,7 @@ Misskeyは投稿のキャプチャと呼ばれる仕組みを提供していま --- -このように、投稿の情報が更新されると、`note-updated`イベントが流れてくるようになります。`note-updated`イベントが発生するのは、以下の場合です: +このように、投稿の情報が更新されると、`noteUpdated`イベントが流れてくるようになります。`noteUpdated`イベントが発生するのは、以下の場合です: - 投稿にリアクションが付いた - 投稿に添付されたアンケートに投票がされた @@ -153,7 +153,7 @@ Misskeyは投稿のキャプチャと呼ばれる仕組みを提供していま `body`プロパティの中に、投稿情報が含まれています。 -### `read_all_notifications` +### `readAllNotifications` 自分宛ての通知がすべて既読になったことを表すイベントです。このイベントを利用して、「通知があることを示すアイコン」のようなものをオフにしたりする等のケースが想定されます。 diff --git a/src/docs/style.styl b/src/docs/style.styl index b01fe493ac..70d77b5499 100644 --- a/src/docs/style.styl +++ b/src/docs/style.styl @@ -128,3 +128,24 @@ pre > code display block padding 16px + +kbd.group + display inline-block + padding 4px + background #fbfbfb + border 1px solid #d6d6d6 + border-radius 4px + box-shadow 0 1px 1px rgba(0, 0, 0, 0.1) + +kbd.key + display inline-block + padding 6px 8px + background #fff + border solid 1px #cecece + border-radius 4px + box-shadow 0 1px 1px rgba(0, 0, 0, 0.1) + +td + > kbd.group, + > kbd.key + margin 4px diff --git a/src/games/reversi/core.ts b/src/games/reversi/core.ts index 92b7c3799c..e724917fbf 100644 --- a/src/games/reversi/core.ts +++ b/src/games/reversi/core.ts @@ -1,3 +1,5 @@ +import { count, concat } from "../../prelude/array"; + // MISSKEY REVERSI ENGINE /** @@ -88,8 +90,8 @@ export default class Reversi { //#endregion // ゲームが始まった時点で片方の色の石しかないか、始まった時点で勝敗が決定するようなマップの場合がある - if (this.canPutSomewhere(BLACK).length == 0) { - if (this.canPutSomewhere(WHITE).length == 0) { + if (!this.canPutSomewhere(BLACK)) { + if (!this.canPutSomewhere(WHITE)) { this.turn = null; } else { this.turn = WHITE; @@ -101,14 +103,14 @@ export default class Reversi { * 黒石の数 */ public get blackCount() { - return this.board.filter(x => x === BLACK).length; + return count(BLACK, this.board); } /** * 白石の数 */ public get whiteCount() { - return this.board.filter(x => x === WHITE).length; + return count(WHITE, this.board); } /** @@ -170,9 +172,9 @@ export default class Reversi { private calcTurn() { // ターン計算 - if (this.canPutSomewhere(!this.prevColor).length > 0) { + if (this.canPutSomewhere(!this.prevColor)) { this.turn = !this.prevColor; - } else if (this.canPutSomewhere(this.prevColor).length > 0) { + } else if (this.canPutSomewhere(this.prevColor)) { this.turn = this.prevColor; } else { this.turn = null; @@ -204,14 +206,15 @@ export default class Reversi { /** * 打つことができる場所を取得します */ - public canPutSomewhere(color: Color): number[] { - const result: number[] = []; - - this.board.forEach((x, i) => { - if (this.canPut(color, i)) result.push(i); - }); + public puttablePlaces(color: Color): number[] { + return Array.from(this.board.keys()).filter(i => this.canPut(color, i)); + } - return result; + /** + * 打つことができる場所があるかどうかを取得します + */ + public canPutSomewhere(color: Color): boolean { + return this.puttablePlaces(color).length > 0; } /** @@ -235,87 +238,55 @@ export default class Reversi { /** * 指定のマスに石を置いた時の、反転させられる石を取得します * @param color 自分の色 - * @param pos 位置 + * @param initPos 位置 */ - public effects(color: Color, pos: number): number[] { + public effects(color: Color, initPos: number): number[] { const enemyColor = !color; - // ひっくり返せる石(の位置)リスト - let stones: number[] = []; - - const initPos = pos; + const diffVectors: [number, number][] = [ + [ 0, -1], // 上 + [ +1, -1], // 右上 + [ +1, 0], // 右 + [ +1, +1], // 右下 + [ 0, +1], // 下 + [ -1, +1], // 左下 + [ -1, 0], // 左 + [ -1, -1] // 左上 + ]; - // 走査 - const iterate = (fn: (i: number) => number[]) => { - let i = 1; - const found = []; + const effectsInLine = ([dx, dy]: [number, number]): number[] => { + const nextPos = (x: number, y: number): [number, number] => [x + dx, y + dy]; + const found: number[] = []; // 挟めるかもしれない相手の石を入れておく配列 + let [x, y] = this.transformPosToXy(initPos); while (true) { - let [x, y] = fn(i); + [x, y] = nextPos(x, y); // 座標が指し示す位置がボード外に出たとき if (this.opts.loopedBoard) { - if (x < 0 ) x = this.mapWidth - ((-x) % this.mapWidth); - if (y < 0 ) y = this.mapHeight - ((-y) % this.mapHeight); - if (x >= this.mapWidth ) x = x % this.mapWidth; - if (y >= this.mapHeight) y = y % this.mapHeight; + x = ((x % this.mapWidth) + this.mapWidth) % this.mapWidth; + y = ((y % this.mapHeight) + this.mapHeight) % this.mapHeight; - // for debug - //if (x < 0 || y < 0 || x >= this.mapWidth || y >= this.mapHeight) { - // console.log(x, y); - //} - - // 一周して自分に帰ってきたら if (this.transformXyToPos(x, y) == initPos) { - // ↓のコメントアウトを外すと、「現時点で自分の石が隣接していないが、 - // そこに置いたとするとループして最終的に挟んだことになる」というケースを有効化します。(Test4のマップで違いが分かります) - // このケースを有効にした方が良いのか無効にした方が良いのか判断がつかなかったためとりあえず無効としておきます - // (あと無効な方がゲームとしておもしろそうだった) - stones = stones.concat(found); - break; + // 盤面の境界でループし、自分が石を置く位置に戻ってきたとき、挟めるようにしている (ref: Test4のマップ) + return found; } } else { - if (x == -1 || y == -1 || x == this.mapWidth || y == this.mapHeight) break; + if (x == -1 || y == -1 || x == this.mapWidth || y == this.mapHeight) { + return []; // 挟めないことが確定 (盤面外に到達) + } } const pos = this.transformXyToPos(x, y); - - //#region 「配置不能」マスに当たった場合走査終了 - const pixel = this.mapDataGet(pos); - if (pixel == 'null') break; - //#endregion - - // 石取得 + if (this.mapDataGet(pos) === 'null') return []; // 挟めないことが確定 (配置不可能なマスに到達) const stone = this.board[pos]; - - // 石が置かれていないマスなら走査終了 - if (stone === null) break; - - // 相手の石なら「ひっくり返せるかもリスト」に入れておく - if (stone === enemyColor) found.push(pos); - - // 自分の石なら「ひっくり返せるかもリスト」を「ひっくり返せるリスト」に入れ、走査終了 - if (stone === color) { - stones = stones.concat(found); - break; - } - - i++; + if (stone === null) return []; // 挟めないことが確定 (石が置かれていないマスに到達) + if (stone === enemyColor) found.push(pos); // 挟めるかもしれない (相手の石を発見) + if (stone === color) return found; // 挟めることが確定 (対となる自分の石を発見) } }; - const [x, y] = this.transformPosToXy(pos); - - iterate(i => [x , y - i]); // 上 - iterate(i => [x + i, y - i]); // 右上 - iterate(i => [x + i, y ]); // 右 - iterate(i => [x + i, y + i]); // 右下 - iterate(i => [x , y + i]); // 下 - iterate(i => [x - i, y + i]); // 左下 - iterate(i => [x - i, y ]); // 左 - iterate(i => [x - i, y - i]); // 左上 - - return stones; + return concat(diffVectors.map(effectsInLine)); } /** diff --git a/src/index.ts b/src/index.ts index 470699eab9..ed23ff7e72 100644 --- a/src/index.ts +++ b/src/index.ts @@ -20,7 +20,6 @@ import Logger from './misc/logger'; import ProgressBar from './misc/cli/progressbar'; import EnvironmentInfo from './misc/environmentInfo'; import MachineInfo from './misc/machineInfo'; -import DependencyInfo from './misc/dependencyInfo'; import serverStats from './daemons/server-stats'; import notesStats from './daemons/notes-stats'; import loadConfig from './config/load'; @@ -116,7 +115,6 @@ async function init(): Promise<Config> { new Logger('Deps').info(`Node.js ${process.version}`); MachineInfo.show(); EnvironmentInfo.show(); - new DependencyInfo().showAll(); const configLogger = new Logger('Config'); let config; diff --git a/src/mfm/html-to-mfm.ts b/src/mfm/html-to-mfm.ts index daa228ec51..aa887c5560 100644 --- a/src/mfm/html-to-mfm.ts +++ b/src/mfm/html-to-mfm.ts @@ -1,4 +1,5 @@ const parse5 = require('parse5'); +import { URL } from 'url'; export default function(html: string): string { if (html == null) return null; @@ -33,26 +34,27 @@ export default function(html: string): string { case 'a': const txt = getText(node); + const rel = node.attrs.find((x: any) => x.name == 'rel'); + const href = node.attrs.find((x: any) => x.name == 'href'); + // ハッシュタグ / hrefがない / txtがURL + if ((rel && rel.value.match('tag') !== null) || !href || href.value == txt) { + text += txt; // メンション - if (txt.startsWith('@')) { + } else if (txt.startsWith('@')) { const part = txt.split('@'); if (part.length == 2) { //#region ホスト名部分が省略されているので復元する - const href = new URL(node.attrs.find((x: any) => x.name == 'href').value); - const acct = txt + '@' + href.hostname; + const acct = `${txt}@${(new URL(href.value)).hostname}`; text += acct; - break; //#endregion } else if (part.length == 3) { text += txt; - break; } - } - - if (node.childNodes) { - node.childNodes.forEach((n: any) => analyze(n)); + // その他 + } else { + text += `[${txt}](${href.value})`; } break; diff --git a/src/mfm/html.ts b/src/mfm/html.ts index c798ee410a..df9959dc4b 100644 --- a/src/mfm/html.ts +++ b/src/mfm/html.ts @@ -4,10 +4,7 @@ const { JSDOM } = jsdom; import config from '../config'; import { INote } from '../models/note'; import { TextElement } from './parse'; - -function intersperse<T>(sep: T, xs: T[]): T[] { - return [].concat(...xs.map(x => [sep, x])).slice(1); -} +import { intersperse } from '../prelude/array'; const handlers: { [key: string]: (window: any, token: any, mentionedRemoteUsers: INote['mentionedRemoteUsers']) => void } = { bold({ document }, { bold }) { @@ -44,8 +41,8 @@ const handlers: { [key: string]: (window: any, token: any, mentionedRemoteUsers: hashtag({ document }, { hashtag }) { const a = document.createElement('a'); - a.href = config.url + '/tags/' + hashtag; - a.textContent = '#' + hashtag; + a.href = `${config.url}/tags/${hashtag}`; + a.textContent = `#${hashtag}`; a.setAttribute('rel', 'tag'); document.body.appendChild(a); }, @@ -85,8 +82,12 @@ const handlers: { [key: string]: (window: any, token: any, mentionedRemoteUsers: text({ document }, { content }) { const nodes = (content as string).split('\n').map(x => document.createTextNode(x)); - for (const x of intersperse(document.createElement('br'), nodes)) { - document.body.appendChild(x); + for (const x of intersperse('br', nodes)) { + if (x === 'br') { + document.body.appendChild(document.createElement('br')); + } else { + document.body.appendChild(x); + } } }, diff --git a/src/mfm/parse/core/syntax-highlighter.ts b/src/mfm/parse/core/syntax-highlighter.ts index 2b13608d2b..83aac89f1b 100644 --- a/src/mfm/parse/core/syntax-highlighter.ts +++ b/src/mfm/parse/core/syntax-highlighter.ts @@ -1,3 +1,5 @@ +import { capitalize, toUpperCase } from "../../../prelude/string"; + function escape(text: string) { return text .replace(/>/g, '>') @@ -89,8 +91,8 @@ const _keywords = [ ]; const keywords = _keywords - .concat(_keywords.map(k => k[0].toUpperCase() + k.substr(1))) - .concat(_keywords.map(k => k.toUpperCase())) + .concat(_keywords.map(capitalize)) + .concat(_keywords.map(toUpperCase)) .sort((a, b) => b.length - a.length); const symbols = [ diff --git a/src/mfm/parse/elements/hashtag.ts b/src/mfm/parse/elements/hashtag.ts index f4b6a78fa8..339026228a 100644 --- a/src/mfm/parse/elements/hashtag.ts +++ b/src/mfm/parse/elements/hashtag.ts @@ -9,9 +9,9 @@ export type TextElementHashtag = { }; export default function(text: string, i: number) { - if (!(/^\s#[^\s]+/.test(text) || (i == 0 && /^#[^\s]+/.test(text)))) return null; + if (!(/^\s#[^\s\.,]+/.test(text) || (i == 0 && /^#[^\s\.,]+/.test(text)))) return null; const isHead = text.startsWith('#'); - const hashtag = text.match(/^\s?#[^\s]+/)[0]; + const hashtag = text.match(/^\s?#[^\s\.,]+/)[0]; const res: any[] = !isHead ? [{ type: 'text', content: text[0] diff --git a/src/mfm/parse/elements/quote.ts b/src/mfm/parse/elements/quote.ts index ea99240d5f..994ce98ca8 100644 --- a/src/mfm/parse/elements/quote.ts +++ b/src/mfm/parse/elements/quote.ts @@ -8,13 +8,20 @@ export type TextElementQuote = { quote: string }; -export default function(text: string) { - const match = text.match(/^"([\s\S]+?)\n"/); +export default function(text: string, index: number) { + const match = text.match(/^"([\s\S]+?)\n"/) || text.match(/^\n>([\s\S]+?)(\n\n|$)/) || + (index == 0 ? text.match(/^>([\s\S]+?)(\n\n|$)/) : null); + if (!match) return null; - const quote = match[0]; + + const quote = match[1] + .split('\n') + .map(line => line.replace(/^>+/g, '').trim()) + .join('\n'); + return { type: 'quote', - content: quote, - quote: match[1].trim(), + content: match[0], + quote: quote, } as TextElementQuote; } diff --git a/src/misc/dependencyInfo.ts b/src/misc/dependencyInfo.ts deleted file mode 100644 index 09d2828222..0000000000 --- a/src/misc/dependencyInfo.ts +++ /dev/null @@ -1,32 +0,0 @@ -import Logger from './logger'; -import { execSync } from 'child_process'; - -export default class { - private logger: Logger; - - constructor() { - this.logger = new Logger('Deps'); - } - - public showAll(): void { - this.show('MongoDB', 'mongo --version', x => x.match(/^MongoDB shell version:? v(.*)\r?\n/)); - this.show('Redis', 'redis-server --version', x => x.match(/v=([0-9\.]*)/)); - } - - public show(serviceName: string, command: string, transform: (x: string) => RegExpMatchArray): void { - try { - // ステータス0以外のときにexecSyncはstderrをコンソール上に出力してしまうので - // プロセスからのstderrをすべて無視するように stdio オプションをセット - const x = execSync(command, { stdio: ['pipe', 'pipe', 'ignore'] }); - const ver = transform(x.toString()); - if (ver != null) { - this.logger.succ(`${serviceName} ${ver[1]} found`); - } else { - this.logger.warn(`${serviceName} not found`); - this.logger.warn(`Regexp used for version check of ${serviceName} is probably messed up`); - } - } catch (e) { - this.logger.warn(`${serviceName} not found`); - } - } -} diff --git a/src/misc/fa.ts b/src/misc/fa.ts index 8be06362c3..5405255ac7 100644 --- a/src/misc/fa.ts +++ b/src/misc/fa.ts @@ -2,12 +2,12 @@ * Replace fontawesome symbols */ -import * as fontawesome from '@fortawesome/fontawesome'; -import regular from '@fortawesome/fontawesome-free-regular'; -import solid from '@fortawesome/fontawesome-free-solid'; -import brands from '@fortawesome/fontawesome-free-brands'; +import * as fontawesome from '@fortawesome/fontawesome-svg-core'; +import { far } from '@fortawesome/free-regular-svg-icons'; +import { fas } from '@fortawesome/free-solid-svg-icons'; +import { fab } from '@fortawesome/free-brands-svg-icons'; -fontawesome.library.add(regular, solid, brands); +fontawesome.library.add(far, fas, fab); export const pattern = /%fa:(.+?)%/g; @@ -26,7 +26,7 @@ export const replacement = (match: string, key: string) => { arg == 'B' ? 'fab' : ''; } else if (arg.startsWith('.')) { - classes.push('fa-' + arg.substr(1)); + classes.push(`fa-${arg.substr(1)}`); } else if (arg.startsWith('-')) { transform = arg.substr(1).split('|').join(' '); } else { diff --git a/src/misc/get-note-summary.ts b/src/misc/get-note-summary.ts index ec7c74cf9f..3c6f2dd3d6 100644 --- a/src/misc/get-note-summary.ts +++ b/src/misc/get-note-summary.ts @@ -16,9 +16,9 @@ const summarize = (note: any): string => { // 本文 summary += note.text ? note.text : ''; - // メディアが添付されているとき - if (note.media.length != 0) { - summary += ` (${note.media.length}つのメディア)`; + // ファイルが添付されているとき + if (note.files.length != 0) { + summary += ` (${note.files.length}つのファイル)`; } // 投票が添付されているとき diff --git a/src/misc/is-quote.ts b/src/misc/is-quote.ts index 420f03a489..a99b8f6434 100644 --- a/src/misc/is-quote.ts +++ b/src/misc/is-quote.ts @@ -1,5 +1,5 @@ import { INote } from '../models/note'; export default function(note: INote): boolean { - return note.renoteId != null && (note.text != null || note.poll != null || (note.mediaIds != null && note.mediaIds.length > 0)); + return note.renoteId != null && (note.text != null || note.poll != null || (note.fileIds != null && note.fileIds.length > 0)); } diff --git a/src/misc/should-mute-this-note.ts b/src/misc/should-mute-this-note.ts new file mode 100644 index 0000000000..663e60af6d --- /dev/null +++ b/src/misc/should-mute-this-note.ts @@ -0,0 +1,21 @@ +import * as mongo from 'mongodb'; + +function toString(id: any) { + return mongo.ObjectID.prototype.isPrototypeOf(id) ? (id as mongo.ObjectID).toHexString() : id; +} + +export default function(note: any, mutedUserIds: string[]): boolean { + if (mutedUserIds.includes(toString(note.userId))) { + return true; + } + + if (note.reply != null && mutedUserIds.includes(toString(note.reply.userId))) { + return true; + } + + if (note.renote != null && mutedUserIds.includes(toString(note.renote.userId))) { + return true; + } + + return false; +} diff --git a/src/models/drive-file.ts b/src/models/drive-file.ts index dbbc1f1cd5..0d0886ad0b 100644 --- a/src/models/drive-file.ts +++ b/src/models/drive-file.ts @@ -92,7 +92,7 @@ export async function deleteDriveFile(driveFile: string | mongo.ObjectID | IDriv // このDriveFileを添付しているNoteをすべて削除 await Promise.all(( - await Note.find({ mediaIds: d._id }) + await Note.find({ fileIds: d._id }) ).map(x => deleteNote(x))); // このDriveFileを添付しているMessagingMessageをすべて削除 @@ -127,6 +127,15 @@ export async function deleteDriveFile(driveFile: string | mongo.ObjectID | IDriv }); } +export const packMany = async ( + files: any[], + options?: { + detail: boolean + } +) => { + return (await Promise.all(files.map(f => pack(f, options)))).filter(x => x != null); +}; + /** * Pack a drive file for API response */ @@ -155,7 +164,11 @@ export const pack = ( _file = deepcopy(file); } - if (!_file) return reject('invalid file arg.'); + // (データベースの欠損などで)ファイルがデータベース上に見つからなかったとき + if (_file == null) { + console.warn(`in packaging driveFile: driveFile not found on database: ${_file}`); + return resolve(null); + } // rendered target let _target: any = {}; @@ -193,5 +206,10 @@ export const pack = ( */ } + delete _target.withoutChunks; + delete _target.storage; + delete _target.storageProps; + delete _target.isRemote; + resolve(_target); }); diff --git a/src/models/favorite.ts b/src/models/favorite.ts index b2d2fc93e8..2c10674bcb 100644 --- a/src/models/favorite.ts +++ b/src/models/favorite.ts @@ -41,6 +41,13 @@ export async function deleteFavorite(favorite: string | mongo.ObjectID | IFavori }); } +export const packMany = async ( + favorites: any[], + me: any +) => { + return (await Promise.all(favorites.map(f => pack(f, me)))).filter(x => x != null); +}; + /** * Pack a favorite for API response */ @@ -70,5 +77,11 @@ export const pack = ( // Populate note _favorite.note = await packNote(_favorite.noteId, me); + // (データベースの不具合などで)投稿が見つからなかったら + if (_favorite.note == null) { + console.warn(`in packaging favorite: note not found on database: ${_favorite.noteId}`); + return resolve(null); + } + resolve(_favorite); }); diff --git a/src/models/messaging-message.ts b/src/models/messaging-message.ts index f46abd506d..d778164de0 100644 --- a/src/models/messaging-message.ts +++ b/src/models/messaging-message.ts @@ -4,6 +4,7 @@ import { pack as packUser } from './user'; import { pack as packFile } from './drive-file'; import db from '../db/mongodb'; import MessagingHistory, { deleteMessagingHistory } from './messaging-history'; +import { length } from 'stringz'; const MessagingMessage = db.get<IMessagingMessage>('messagingMessages'); export default MessagingMessage; @@ -19,7 +20,7 @@ export interface IMessagingMessage { } export function isValidText(text: string): boolean { - return text.length <= 1000 && text.trim() != ''; + return length(text.trim()) <= 1000 && text.trim() != ''; } /** diff --git a/src/models/meta.ts b/src/models/meta.ts index aef0163dfe..3c0347485c 100644 --- a/src/models/meta.ts +++ b/src/models/meta.ts @@ -4,12 +4,15 @@ const Meta = db.get<IMeta>('meta'); export default Meta; export type IMeta = { - broadcasts: any[]; - stats: { + broadcasts?: any[]; + stats?: { notesCount: number; originalNotesCount: number; usersCount: number; originalUsersCount: number; }; - disableRegistration: boolean; + disableRegistration?: boolean; + disableLocalTimeline?: boolean; + hidedTags?: string[]; + bannerUrl?: string; }; diff --git a/src/models/note-unread.ts b/src/models/note-unread.ts new file mode 100644 index 0000000000..62408d23b6 --- /dev/null +++ b/src/models/note-unread.ts @@ -0,0 +1,17 @@ +import * as mongo from 'mongodb'; +import db from '../db/mongodb'; + +const NoteUnread = db.get<INoteUnread>('noteUnreads'); +NoteUnread.createIndex(['userId', 'noteId'], { unique: true }); +export default NoteUnread; + +export interface INoteUnread { + _id: mongo.ObjectID; + noteId: mongo.ObjectID; + userId: mongo.ObjectID; + isSpecified: boolean; + + _note: { + userId: mongo.ObjectID; + }; +} diff --git a/src/models/note.ts b/src/models/note.ts index 9d2e23d901..6c16ab054b 100644 --- a/src/models/note.ts +++ b/src/models/note.ts @@ -2,11 +2,12 @@ import * as mongo from 'mongodb'; const deepcopy = require('deepcopy'); import rap from '@prezzemolo/rap'; import db from '../db/mongodb'; +import { length } from 'stringz'; import { IUser, pack as packUser } from './user'; import { pack as packApp } from './app'; import PollVote, { deletePollVote } from './poll-vote'; import Reaction, { deleteNoteReaction } from './note-reaction'; -import { pack as packFile } from './drive-file'; +import { packMany as packFileMany, IDriveFile } from './drive-file'; import NoteWatching, { deleteNoteWatching } from './note-watching'; import NoteReaction from './note-reaction'; import Favorite, { deleteFavorite } from './favorite'; @@ -16,25 +17,29 @@ import Following from './following'; const Note = db.get<INote>('notes'); Note.createIndex('uri', { sparse: true, unique: true }); Note.createIndex('userId'); +Note.createIndex('mentions'); +Note.createIndex('visibleUserIds'); Note.createIndex('tagsLower'); +Note.createIndex('_files._id'); +Note.createIndex('_files.contentType'); Note.createIndex({ createdAt: -1 }); export default Note; export function isValidText(text: string): boolean { - return text.length <= 1000 && text.trim() != ''; + return length(text.trim()) <= 1000 && text.trim() != ''; } export function isValidCw(text: string): boolean { - return text.length <= 100; + return length(text.trim()) <= 100; } export type INote = { _id: mongo.ObjectID; createdAt: Date; deletedAt: Date; - mediaIds: mongo.ObjectID[]; + fileIds: mongo.ObjectID[]; replyId: mongo.ObjectID; renoteId: mongo.ObjectID; poll: { @@ -92,6 +97,7 @@ export type INote = { inbox?: string; }; _replyIds?: mongo.ObjectID[]; + _files?: IDriveFile[]; }; /** @@ -160,6 +166,76 @@ export async function deleteNote(note: string | mongo.ObjectID | INote) { console.log(`Note: deleted ${n._id}`); } +export const hideNote = async (packedNote: any, meId: mongo.ObjectID) => { + let hide = false; + + // visibility が private かつ投稿者のIDが自分のIDではなかったら非表示 + if (packedNote.visibility == 'private' && (meId == null || !meId.equals(packedNote.userId))) { + hide = true; + } + + // visibility が specified かつ自分が指定されていなかったら非表示 + if (packedNote.visibility == 'specified') { + if (meId == null) { + hide = true; + } else if (meId.equals(packedNote.userId)) { + hide = false; + } else { + // 指定されているかどうか + const specified = packedNote.visibleUserIds.some((id: any) => meId.equals(id)); + + if (specified) { + hide = false; + } else { + hide = true; + } + } + } + + // visibility が followers かつ自分が投稿者のフォロワーでなかったら非表示 + if (packedNote.visibility == 'followers') { + if (meId == null) { + hide = true; + } else if (meId.equals(packedNote.userId)) { + hide = false; + } else { + // フォロワーかどうか + const following = await Following.findOne({ + followeeId: packedNote.userId, + followerId: meId + }); + + if (following == null) { + hide = true; + } else { + hide = false; + } + } + } + + if (hide) { + packedNote.fileIds = []; + packedNote.files = []; + packedNote.text = null; + packedNote.poll = null; + packedNote.cw = null; + packedNote.tags = []; + packedNote.geo = null; + packedNote.isHidden = true; + } +}; + +export const packMany = async ( + notes: (string | mongo.ObjectID | INote)[], + me?: string | mongo.ObjectID | IUser, + options?: { + detail?: boolean; + skipHide?: boolean; + } +) => { + return (await Promise.all(notes.map(n => pack(n, me, options)))).filter(x => x != null); +}; + /** * Pack a note for API response * @@ -172,11 +248,13 @@ export const pack = async ( note: string | mongo.ObjectID | INote, me?: string | mongo.ObjectID | IUser, options?: { - detail: boolean + detail?: boolean; + skipHide?: boolean; } ) => { const opts = Object.assign({ - detail: true + detail: true, + skipHide: false }, options); // Me @@ -203,52 +281,10 @@ export const pack = async ( _note = deepcopy(note); } - 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: mongo.ObjectID) => 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; - } - } + // 投稿がデータベース上に見つからなかったとき + if (_note == null) { + console.warn(`note not found on database: ${note}`); + return null; } const id = _note._id; @@ -257,10 +293,13 @@ export const pack = async ( _note.id = _note._id; delete _note._id; + delete _note.prev; + delete _note.next; + delete _note.tagsLower; delete _note._user; delete _note._reply; - delete _note.repost; - delete _note.mentions; + delete _note._renote; + delete _note._files; if (_note.geo) delete _note.geo.type; // Populate user @@ -271,10 +310,12 @@ export const pack = async ( _note.app = packApp(_note.appId); } - // Populate media - _note.media = hide ? [] : Promise.all(_note.mediaIds.map((fileId: mongo.ObjectID) => - packFile(fileId) - )); + // Populate files + _note.files = packFileMany(_note.fileIds || []); + + // 後方互換性のため + _note.mediaIds = _note.fileIds; + _note.media = _note.files; // When requested a detailed note data if (opts.detail) { @@ -298,7 +339,7 @@ export const pack = async ( } // Poll - if (meId && _note.poll && !hide) { + if (meId && _note.poll) { _note.poll = (async poll => { const vote = await PollVote .findOne({ @@ -339,19 +380,18 @@ export const pack = async ( // resolve promises in _note object _note = await rap(_note); + // (データベースの欠損などで)ユーザーがデータベース上に見つからなかったとき + if (_note.user == null) { + console.warn(`in packaging note: note user not found on database: note(${_note.id})`); + return null; + } + if (_note.user.isCat && _note.text) { _note.text = _note.text.replace(/な/g, 'にゃ').replace(/ナ/g, 'ニャ').replace(/ナ/g, 'ニャ'); } - if (hide) { - _note.mediaIds = []; - _note.text = null; - _note.poll = null; - _note.cw = null; - _note.tags = []; - _note.tagsLower = []; - _note.geo = null; - _note.isHidden = true; + if (!opts.skipHide) { + await hideNote(_note, meId); } return _note; diff --git a/src/models/notification.ts b/src/models/notification.ts index 835c89cd56..57be4bef10 100644 --- a/src/models/notification.ts +++ b/src/models/notification.ts @@ -77,6 +77,12 @@ export async function deleteNotification(notification: string | mongo.ObjectID | }); } +export const packMany = async ( + notifications: any[] +) => { + return (await Promise.all(notifications.map(n => pack(n)))).filter(x => x != null); +}; + /** * Pack a notification for API response */ @@ -123,6 +129,12 @@ export const pack = (notification: any) => new Promise<any>(async (resolve, reje case 'poll_vote': // Populate note _notification.note = await packNote(_notification.noteId, me); + + // (データベースの不具合などで)投稿が見つからなかったら + if (_notification.note == null) { + console.warn(`in packaging notification: note not found on database: ${_notification.noteId}`); + return resolve(null); + } break; default: console.error(`Unknown type: ${_notification.type}`); diff --git a/src/models/stats.ts b/src/models/stats.ts index 326bfacc80..492784555e 100644 --- a/src/models/stats.ts +++ b/src/models/stats.ts @@ -2,7 +2,7 @@ import * as mongo from 'mongodb'; import db from '../db/mongodb'; const Stats = db.get<IStats>('stats'); -Stats.dropIndex({ date: -1 }); // 後方互換性のため + Stats.createIndex({ span: -1, date: -1 }, { unique: true }); export default Stats; @@ -199,4 +199,30 @@ export interface IStats { decSize: number; }; }; + + /** + * ネットワークに関する統計 + */ + network: { + /** + * サーバーへのリクエスト数 + */ + requests: number; + + /** + * 応答時間の合計 + * TIP: (totalTime / requests) でひとつのリクエストに平均でどれくらいの時間がかかったか知れる + */ + totalTime: number; + + /** + * 合計受信データ量 + */ + incomingBytes: number; + + /** + * 合計送信データ量 + */ + outgoingBytes: number; + }; } diff --git a/src/models/user.ts b/src/models/user.ts index 31d09bc8f8..e0ce561421 100644 --- a/src/models/user.ts +++ b/src/models/user.ts @@ -3,7 +3,7 @@ const deepcopy = require('deepcopy'); const sequential = require('promise-sequential'); import rap from '@prezzemolo/rap'; import db from '../db/mongodb'; -import Note, { pack as packNote, deleteNote } from './note'; +import Note, { packMany as packNoteMany, deleteNote } from './note'; import Following, { deleteFollowing } from './following'; import Mute, { deleteMute } from './mute'; import { getFriendIds } from '../server/api/common/get-friends'; @@ -53,7 +53,7 @@ type IUserBase = { wallpaperUrl?: string; data: any; description: string; - pinnedNoteId: mongo.ObjectID; + pinnedNoteIds: mongo.ObjectID[]; /** * 凍結されているか否か @@ -102,7 +102,10 @@ export interface ILocalUser extends IUserBase { twoFactorEnabled: boolean; twoFactorTempSecret?: string; clientSettings: any; - settings: any; + settings: { + autoWatch: boolean; + alwaysMarkNsfw?: boolean; + }; hasUnreadNotification: boolean; hasUnreadMessagingMessage: boolean; } @@ -110,6 +113,7 @@ export interface ILocalUser extends IUserBase { export interface IRemoteUser extends IUserBase { inbox: string; sharedInbox?: string; + featured?: string; endpoints: string[]; uri: string; url?: string; @@ -323,7 +327,8 @@ export const pack = ( me?: string | mongo.ObjectID | IUser, options?: { detail?: boolean, - includeSecrets?: boolean + includeSecrets?: boolean, + includeHasUnreadNotes?: boolean } ) => new Promise<any>(async (resolve, reject) => { @@ -356,9 +361,11 @@ export const pack = ( _user = deepcopy(user); } - // TODO: ここでエラーにするのではなくダミーのユーザーデータを返す - // SEE: https://github.com/syuilo/misskey/issues/1432 - if (!_user) return reject('invalid user arg.'); + // (データベースの欠損などで)ユーザーがデータベース上に見つからなかったとき + if (_user == null) { + console.warn(`user not found on database: ${user}`); + return resolve(null); + } // Me const meId: mongo.ObjectID = me @@ -432,10 +439,10 @@ export const pack = ( followerId: _user.id, followeeId: meId }), - _user.isLocked ? FollowRequest.findOne({ + FollowRequest.findOne({ followerId: meId, followeeId: _user.id - }) : Promise.resolve(null), + }), FollowRequest.findOne({ followerId: _user.id, followeeId: meId @@ -461,9 +468,9 @@ export const pack = ( } if (opts.detail) { - if (_user.pinnedNoteId) { - // Populate pinned note - _user.pinnedNote = packNote(_user.pinnedNoteId, meId, { + if (_user.pinnedNoteIds) { + // Populate pinned notes + _user.pinnedNotes = packNoteMany(_user.pinnedNoteIds, meId, { detail: true }); } @@ -485,6 +492,11 @@ export const pack = ( } } + if (!opts.includeHasUnreadNotes) { + delete _user.hasUnreadSpecifiedNotes; + delete _user.hasUnreadMentions; + } + // resolve promises in _user object _user = await rap(_user); diff --git a/src/notify.ts b/src/notify.ts index ea7423655e..522f4c52dd 100644 --- a/src/notify.ts +++ b/src/notify.ts @@ -2,7 +2,7 @@ import * as mongo from 'mongodb'; import Notification from './models/notification'; import Mute from './models/mute'; import { pack } from './models/notification'; -import { publishUserStream } from './stream'; +import { publishMainStream } from './stream'; import User from './models/user'; import pushSw from './push-sw'; @@ -30,7 +30,7 @@ export default ( const packed = await pack(notification); // Publish notification event - publishUserStream(notifiee, 'notification', packed); + publishMainStream(notifiee, 'notification', packed); // Update flag User.update({ _id: notifiee }, { @@ -39,7 +39,7 @@ export default ( } }); - // 3秒経っても(今回作成した)通知が既読にならなかったら「未読の通知がありますよ」イベントを発行する + // 2秒経っても(今回作成した)通知が既読にならなかったら「未読の通知がありますよ」イベントを発行する setTimeout(async () => { const fresh = await Notification.findOne({ _id: notification._id }, { isRead: true }); if (!fresh.isRead) { @@ -54,9 +54,9 @@ export default ( } //#endregion - publishUserStream(notifiee, 'unread_notification', packed); + publishMainStream(notifiee, 'unreadNotification', packed); pushSw(notifiee, 'notification', packed); } - }, 3000); + }, 2000); }); diff --git a/src/prelude/README.md b/src/prelude/README.md new file mode 100644 index 0000000000..bb728cfb1b --- /dev/null +++ b/src/prelude/README.md @@ -0,0 +1,3 @@ +# Prelude +このディレクトリのコードはJavaScriptの表現能力を補うためのコードです。 +Misskey固有の処理とは独立したコードの集まりですが、Misskeyのコードを読みやすくすることを目的としています。 diff --git a/src/prelude/array.ts b/src/prelude/array.ts new file mode 100644 index 0000000000..54f7081712 --- /dev/null +++ b/src/prelude/array.ts @@ -0,0 +1,27 @@ +export function countIf<T>(f: (x: T) => boolean, xs: T[]): number { + return xs.filter(f).length; +} + +export function count<T>(x: T, xs: T[]): number { + return countIf(y => x === y, xs); +} + +export function concat<T>(xss: T[][]): T[] { + return ([] as T[]).concat(...xss); +} + +export function intersperse<T>(sep: T, xs: T[]): T[] { + return concat(xs.map(x => [sep, x])).slice(1); +} + +export function erase<T>(x: T, xs: T[]): T[] { + return xs.filter(y => x !== y); +} + +export function unique<T>(xs: T[]): T[] { + return [...new Set(xs)]; +} + +export function sum(xs: number[]): number { + return xs.reduce((a, b) => a + b, 0); +} diff --git a/src/prelude/math.ts b/src/prelude/math.ts new file mode 100644 index 0000000000..07b94bec30 --- /dev/null +++ b/src/prelude/math.ts @@ -0,0 +1,3 @@ +export function gcd(a: number, b: number): number { + return b === 0 ? a : gcd(b, a % b); +} diff --git a/src/prelude/string.ts b/src/prelude/string.ts new file mode 100644 index 0000000000..cae776bc3d --- /dev/null +++ b/src/prelude/string.ts @@ -0,0 +1,11 @@ +export function capitalize(s: string): string { + return toUpperCase(s.charAt(0)) + toLowerCase(s.slice(1)); +} + +export function toUpperCase(s: string): string { + return s.toUpperCase(); +} + +export function toLowerCase(s: string): string { + return s.toLowerCase(); +} diff --git a/src/queue/processors/http/deliver.ts b/src/queue/processors/http/deliver.ts index e14a162105..621219fec6 100644 --- a/src/queue/processors/http/deliver.ts +++ b/src/queue/processors/http/deliver.ts @@ -7,19 +7,18 @@ export default async (job: bq.Job, done: any): Promise<void> => { await request(job.data.user, job.data.to, job.data.content); done(); } catch (res) { - if (res == null || !res.hasOwnProperty('statusCode')) { - console.warn(`deliver failed (unknown): ${res}`); - return done(); - } - - if (res.statusCode == null) return done(); - if (res.statusCode >= 400 && res.statusCode < 500) { - // HTTPステータスコード4xxはクライアントエラーであり、それはつまり - // 何回再送しても成功することはないということなのでエラーにはしないでおく - done(); + if (res != null && res.hasOwnProperty('statusCode')) { + if (res.statusCode >= 400 && res.statusCode < 500) { + // HTTPステータスコード4xxはクライアントエラーであり、それはつまり + // 何回再送しても成功することはないということなのでエラーにはしないでおく + done(); + } else { + console.warn(`deliver failed: ${res.statusCode} ${res.statusMessage} to=${job.data.to}`); + done(res.statusMessage); + } } else { - console.warn(`deliver failed: ${res.statusMessage}`); - done(res.statusMessage); + console.warn(`deliver failed: ${res} to=${job.data.to}`); + done(); } } }; diff --git a/src/queue/processors/http/process-inbox.ts b/src/queue/processors/http/process-inbox.ts index c9c2fa72cb..8e6b3769de 100644 --- a/src/queue/processors/http/process-inbox.ts +++ b/src/queue/processors/http/process-inbox.ts @@ -5,7 +5,9 @@ const httpSignature = require('http-signature'); import parseAcct from '../../../misc/acct/parse'; import User, { IRemoteUser } from '../../../models/user'; import perform from '../../../remote/activitypub/perform'; -import { resolvePerson } from '../../../remote/activitypub/models/person'; +import { resolvePerson, updatePerson } from '../../../remote/activitypub/models/person'; +import { toUnicode } from 'punycode'; +import { URL } from 'url'; const log = debug('misskey:queue:inbox'); @@ -32,22 +34,51 @@ export default async (job: bq.Job, done: any): Promise<void> => { return; } - user = await User.findOne({ usernameLower: username, host: host.toLowerCase() }) as IRemoteUser; - - // アクティビティを送信してきたユーザーがまだMisskeyサーバーに登録されていなかったら登録する - if (user === null) { - user = await resolvePerson(activity.actor) as IRemoteUser; + // アクティビティ内のホストの検証 + try { + ValidateActivity(activity, host); + } catch (e) { + console.warn(e.message); + done(); + return; } + + user = await User.findOne({ usernameLower: username, host: host.toLowerCase() }) as IRemoteUser; } else { + // アクティビティ内のホストの検証 + const host = toUnicode(new URL(signature.keyId).hostname.toLowerCase()); + try { + ValidateActivity(activity, host); + } catch (e) { + console.warn(e.message); + done(); + return; + } + user = await User.findOne({ host: { $ne: null }, 'publicKey.id': signature.keyId }) as IRemoteUser; + } - // アクティビティを送信してきたユーザーがまだMisskeyサーバーに登録されていなかったら登録する - if (user === null) { - user = await resolvePerson(activity.actor) as IRemoteUser; + // Update activityの場合は、ここで署名検証/更新処理まで実施して終了 + if (activity.type === 'Update') { + if (activity.object && activity.object.type === 'Person') { + if (user == null) { + console.warn('Update activity received, but user not registed.'); + } else if (!httpSignature.verifySignature(signature, user.publicKey.publicKeyPem)) { + console.warn('Update activity received, but signature verification failed.'); + } else { + updatePerson(activity.actor, null, activity.object); + } } + done(); + return; + } + + // アクティビティを送信してきたユーザーがまだMisskeyサーバーに登録されていなかったら登録する + if (user === null) { + user = await resolvePerson(activity.actor) as IRemoteUser; } if (user === null) { @@ -69,3 +100,40 @@ export default async (job: bq.Job, done: any): Promise<void> => { done(e); } }; + +/** + * Validate host in activity + * @param activity Activity + * @param host Expect host + */ +function ValidateActivity(activity: any, host: string) { + // id (if exists) + if (typeof activity.id === 'string') { + const uriHost = toUnicode(new URL(activity.id).hostname.toLowerCase()); + if (host !== uriHost) { + const diag = activity.signature ? '. Has LD-Signature. Forwarded?' : ''; + throw new Error(`activity.id(${activity.id}) has different host(${host})${diag}`); + } + } + + // actor (if exists) + if (typeof activity.actor === 'string') { + const uriHost = toUnicode(new URL(activity.actor).hostname.toLowerCase()); + if (host !== uriHost) throw new Error('activity.actor has different host'); + } + + // For Create activity + if (activity.type === 'Create' && activity.object) { + // object.id (if exists) + if (typeof activity.object.id === 'string') { + const uriHost = toUnicode(new URL(activity.object.id).hostname.toLowerCase()); + if (host !== uriHost) throw new Error('activity.object.id has different host'); + } + + // object.attributedTo (if exists) + if (typeof activity.object.attributedTo === 'string') { + const uriHost = toUnicode(new URL(activity.object.attributedTo).hostname.toLowerCase()); + if (host !== uriHost) throw new Error('activity.object.attributedTo has different host'); + } + } +} diff --git a/src/remote/activitypub/kernel/add/index.ts b/src/remote/activitypub/kernel/add/index.ts new file mode 100644 index 0000000000..eb2dba5b21 --- /dev/null +++ b/src/remote/activitypub/kernel/add/index.ts @@ -0,0 +1,22 @@ +import { IRemoteUser } from '../../../../models/user'; +import { IAdd } from '../../type'; +import { resolveNote } from '../../models/note'; +import { addPinned } from '../../../../services/i/pin'; + +export default async (actor: IRemoteUser, activity: IAdd): Promise<void> => { + if ('actor' in activity && actor.uri !== activity.actor) { + throw new Error('invalid actor'); + } + + if (activity.target == null) { + throw new Error('target is null'); + } + + if (activity.target === actor.featured) { + const note = await resolveNote(activity.object); + await addPinned(actor, note._id); + return; + } + + throw new Error(`unknown target: ${activity.target}`); +}; diff --git a/src/remote/activitypub/kernel/index.ts b/src/remote/activitypub/kernel/index.ts index 752a9bd2e2..52b0efc730 100644 --- a/src/remote/activitypub/kernel/index.ts +++ b/src/remote/activitypub/kernel/index.ts @@ -8,6 +8,8 @@ import like from './like'; import announce from './announce'; import accept from './accept'; import reject from './reject'; +import add from './add'; +import remove from './remove'; const self = async (actor: IRemoteUser, activity: Object): Promise<void> => { switch (activity.type) { @@ -31,6 +33,14 @@ const self = async (actor: IRemoteUser, activity: Object): Promise<void> => { await reject(actor, activity); break; + case 'Add': + await add(actor, activity).catch(err => console.log(err)); + break; + + case 'Remove': + await remove(actor, activity).catch(err => console.log(err)); + break; + case 'Announce': await announce(actor, activity); break; diff --git a/src/remote/activitypub/kernel/remove/index.ts b/src/remote/activitypub/kernel/remove/index.ts new file mode 100644 index 0000000000..91b207c80d --- /dev/null +++ b/src/remote/activitypub/kernel/remove/index.ts @@ -0,0 +1,22 @@ +import { IRemoteUser } from '../../../../models/user'; +import { IRemove } from '../../type'; +import { resolveNote } from '../../models/note'; +import { removePinned } from '../../../../services/i/pin'; + +export default async (actor: IRemoteUser, activity: IRemove): Promise<void> => { + if ('actor' in activity && actor.uri !== activity.actor) { + throw new Error('invalid actor'); + } + + if (activity.target == null) { + throw new Error('target is null'); + } + + if (activity.target === actor.featured) { + const note = await resolveNote(activity.object); + await removePinned(actor, note._id); + return; + } + + throw new Error(`unknown target: ${activity.target}`); +}; diff --git a/src/remote/activitypub/misc/get-note-html.ts b/src/remote/activitypub/misc/get-note-html.ts index 8df440930b..0a607bd48c 100644 --- a/src/remote/activitypub/misc/get-note-html.ts +++ b/src/remote/activitypub/misc/get-note-html.ts @@ -1,23 +1,10 @@ import { INote } from '../../../models/note'; import toHtml from '../../../mfm/html'; import parse from '../../../mfm/parse'; -import config from '../../../config'; export default function(note: INote) { - if (note.text == null) return null; - let html = toHtml(parse(note.text), note.mentionedRemoteUsers); - - 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>`; - } + if (html == null) html = ''; return html; } diff --git a/src/remote/activitypub/models/note.ts b/src/remote/activitypub/models/note.ts index 1dfeebfdf7..d49cf53079 100644 --- a/src/remote/activitypub/models/note.ts +++ b/src/remote/activitypub/models/note.ts @@ -56,7 +56,7 @@ export async function createNote(value: any, resolver?: Resolver, silent = false log(`Creating the Note: ${note.id}`); // 投稿者をフェッチ - const actor = await resolvePerson(note.attributedTo) as IRemoteUser; + const actor = await resolvePerson(note.attributedTo, null, resolver) as IRemoteUser; // 投稿者が凍結されていたらスキップ if (actor.isSuspended) { @@ -73,16 +73,16 @@ export async function createNote(value: any, resolver?: Resolver, silent = false visibility = 'followers'; } else { visibility = 'specified'; - visibleUsers = await Promise.all(note.to.map(uri => resolvePerson(uri))); + visibleUsers = await Promise.all(note.to.map(uri => resolvePerson(uri, null, resolver))); } } //#endergion - // 添付メディア + // 添付ファイル // TODO: attachmentは必ずしもImageではない // TODO: attachmentは必ずしも配列ではない // Noteがsensitiveなら添付もsensitiveにする - const media = note.attachment + const files = note.attachment .map(attach => attach.sensitive = note.sensitive) ? await Promise.all(note.attachment.map(x => resolveImage(actor, x))) : []; @@ -91,7 +91,7 @@ export async function createNote(value: any, resolver?: Resolver, silent = false const reply = note.inReplyTo ? await resolveNote(note.inReplyTo, resolver) : null; // テキストのパース - const text = htmlToMFM(note.content); + const text = note._misskey_content ? note._misskey_content : htmlToMFM(note.content); // ユーザーの情報が古かったらついでに更新しておく if (actor.updatedAt == null || Date.now() - actor.updatedAt.getTime() > 1000 * 60 * 60 * 24) { @@ -100,7 +100,7 @@ export async function createNote(value: any, resolver?: Resolver, silent = false return await post(actor, { createdAt: new Date(note.published), - media, + files: files, reply, renote: undefined, cw: note.summary, diff --git a/src/remote/activitypub/models/person.ts b/src/remote/activitypub/models/person.ts index 3bd4e16763..ee95e43ad3 100644 --- a/src/remote/activitypub/models/person.ts +++ b/src/remote/activitypub/models/person.ts @@ -3,15 +3,16 @@ import { toUnicode } from 'punycode'; import * as debug from 'debug'; import config from '../../../config'; -import User, { validateUsername, isValidName, IUser, IRemoteUser } from '../../../models/user'; +import User, { validateUsername, isValidName, IUser, IRemoteUser, isRemoteUser } from '../../../models/user'; import Resolver from '../resolver'; import { resolveImage } from './image'; -import { isCollectionOrOrderedCollection, IPerson } from '../type'; +import { isCollectionOrOrderedCollection, isCollection, IPerson } from '../type'; import { IDriveFile } from '../../../models/drive-file'; import Meta from '../../../models/meta'; import htmlToMFM from '../../../mfm/html-to-mfm'; import { updateUserStats } from '../../../services/update-chart'; import { URL } from 'url'; +import { resolveNote } from './note'; const log = debug('misskey:activitypub'); @@ -139,6 +140,7 @@ export async function createPerson(uri: string, resolver?: Resolver): Promise<IU avatarId: null, bannerId: null, createdAt: Date.parse(person.published) || null, + updatedAt: new Date(), description: htmlToMFM(person.summary), followersCount, followingCount, @@ -154,6 +156,7 @@ export async function createPerson(uri: string, resolver?: Resolver): Promise<IU }, inbox: person.inbox, sharedInbox: person.sharedInbox, + featured: person.featured, endpoints: person.endpoints, uri: person.id, url: person.url, @@ -210,15 +213,18 @@ export async function createPerson(uri: string, resolver?: Resolver): Promise<IU user.bannerUrl = bannerUrl; //#endregion + await updateFeatured(user._id).catch(err => console.log(err)); return user; } /** * Personの情報を更新します。 - * * Misskeyに対象のPersonが登録されていなければ無視します。 + * @param uri URI of Person + * @param resolver Resolver + * @param hint Hint of Person object (この値が正当なPersonの場合、Remote resolveをせずに更新に利用します) */ -export async function updatePerson(uri: string, resolver?: Resolver): Promise<void> { +export async function updatePerson(uri: string, resolver?: Resolver, hint?: object): Promise<void> { if (typeof uri !== 'string') throw 'uri is not string'; // URIがこのサーバーを指しているならスキップ @@ -236,7 +242,7 @@ export async function updatePerson(uri: string, resolver?: Resolver): Promise<vo if (resolver == null) resolver = new Resolver(); - const object = await resolver.resolve(uri) as any; + const object = hint || await resolver.resolve(uri) as any; const err = validatePerson(object, uri); @@ -279,6 +285,7 @@ export async function updatePerson(uri: string, resolver?: Resolver): Promise<vo updatedAt: new Date(), inbox: person.inbox, sharedInbox: person.sharedInbox, + featured: person.featured, avatarId: avatar ? avatar._id : null, bannerId: banner ? banner._id : null, avatarUrl: (avatar && avatar.metadata.thumbnailUrl) ? avatar.metadata.thumbnailUrl : (avatar && avatar.metadata.url) ? avatar.metadata.url : null, @@ -290,9 +297,18 @@ export async function updatePerson(uri: string, resolver?: Resolver): Promise<vo name: person.name, url: person.url, endpoints: person.endpoints, - isCat: (person as any).isCat === true ? true : false + isBot: object.type == 'Service', + isCat: (person as any).isCat === true ? true : false, + isLocked: person.manuallyApprovesFollowers, + createdAt: Date.parse(person.published) || null, + publicKey: { + id: person.publicKey.id, + publicKeyPem: person.publicKey.publicKeyPem + }, } }); + + await updateFeatured(exist._id).catch(err => console.log(err)); } /** @@ -301,7 +317,7 @@ export async function updatePerson(uri: string, resolver?: Resolver): Promise<vo * Misskeyに対象のPersonが登録されていればそれを返し、そうでなければ * リモートサーバーからフェッチしてMisskeyに登録しそれを返します。 */ -export async function resolvePerson(uri: string, verifier?: string): Promise<IUser> { +export async function resolvePerson(uri: string, verifier?: string, resolver?: Resolver): Promise<IUser> { if (typeof uri !== 'string') throw 'uri is not string'; //#region このサーバーに既に登録されていたらそれを返す @@ -313,5 +329,37 @@ export async function resolvePerson(uri: string, verifier?: string): Promise<IUs //#endregion // リモートサーバーからフェッチしてきて登録 - return await createPerson(uri); + if (resolver == null) resolver = new Resolver(); + return await createPerson(uri, resolver); +} + +export async function updateFeatured(userId: mongo.ObjectID) { + const user = await User.findOne({ _id: userId }); + if (!isRemoteUser(user)) return; + if (!user.featured) return; + + log(`Updating the featured: ${user.uri}`); + + const resolver = new Resolver(); + + // Resolve to (Ordered)Collection Object + const collection = await resolver.resolveCollection(user.featured); + if (!isCollectionOrOrderedCollection(collection)) throw new Error(`Object is not Collection or OrderedCollection`); + + // Resolve to Object(may be Note) arrays + const unresolvedItems = isCollection(collection) ? collection.items : collection.orderedItems; + const items = await resolver.resolve(unresolvedItems); + if (!Array.isArray(items)) throw new Error(`Collection items is not an array`); + + // Resolve and regist Notes + const featuredNotes = await Promise.all(items + .filter(item => item.type === 'Note') + .slice(0, 5) + .map(item => resolveNote(item, resolver))); + + await User.update({ _id: user._id }, { + $set: { + pinnedNoteIds: featuredNotes.map(note => note._id) + } + }); } diff --git a/src/remote/activitypub/renderer/add.ts b/src/remote/activitypub/renderer/add.ts new file mode 100644 index 0000000000..4d6fe392aa --- /dev/null +++ b/src/remote/activitypub/renderer/add.ts @@ -0,0 +1,9 @@ +import config from '../../../config'; +import { ILocalUser } from '../../../models/user'; + +export default (user: ILocalUser, target: any, object: any) => ({ + type: 'Add', + actor: `${config.url}/users/${user._id}`, + target, + object +}); diff --git a/src/remote/activitypub/renderer/announce.ts b/src/remote/activitypub/renderer/announce.ts index f6276ade04..18e23cc336 100644 --- a/src/remote/activitypub/renderer/announce.ts +++ b/src/remote/activitypub/renderer/announce.ts @@ -5,7 +5,7 @@ export default (object: any, note: INote) => { const attributedTo = `${config.url}/users/${note.userId}`; return { - id: `${config.url}/notes/${note._id}`, + id: `${config.url}/notes/${note._id}/activity`, actor: `${config.url}/users/${note.userId}`, type: 'Announce', published: note.createdAt.toISOString(), diff --git a/src/remote/activitypub/renderer/hashtag.ts b/src/remote/activitypub/renderer/hashtag.ts index a37ba63532..36563c2df5 100644 --- a/src/remote/activitypub/renderer/hashtag.ts +++ b/src/remote/activitypub/renderer/hashtag.ts @@ -3,5 +3,5 @@ import config from '../../../config'; export default (tag: string) => ({ type: 'Hashtag', href: `${config.url}/tags/${encodeURIComponent(tag)}`, - name: '#' + tag + name: `#${tag}` }); diff --git a/src/remote/activitypub/renderer/note.ts b/src/remote/activitypub/renderer/note.ts index 1d169d3088..b3ce1c03e4 100644 --- a/src/remote/activitypub/renderer/note.ts +++ b/src/remote/activitypub/renderer/note.ts @@ -6,10 +6,11 @@ import DriveFile, { IDriveFile } from '../../../models/drive-file'; import Note, { INote } from '../../../models/note'; import User from '../../../models/user'; import toHtml from '../misc/get-note-html'; +import parseMfm from '../../../mfm/parse'; export default async function renderNote(note: INote, dive = true): Promise<any> { - const promisedFiles: Promise<IDriveFile[]> = note.mediaIds - ? DriveFile.find({ _id: { $in: note.mediaIds } }) + const promisedFiles: Promise<IDriveFile[]> = note.fileIds + ? DriveFile.find({ _id: { $in: note.fileIds } }) : Promise.resolve([]); let inReplyTo; @@ -81,12 +82,39 @@ export default async function renderNote(note: INote, dive = true): Promise<any> const files = await promisedFiles; + let text = note.text; + + if (note.poll != null) { + if (text == null) text = ''; + const url = `${config.url}/notes/${note._id}`; + // TODO: i18n + text += `\n\n[投票を見る](${url})`; + } + + if (note.renoteId != null) { + if (text == null) text = ''; + const url = `${config.url}/notes/${note.renoteId}`; + text += `\n\nRE: ${url}`; + } + + // 省略されたメンションのホストを復元する + if (text != null) { + text = parseMfm(text).map(x => { + if (x.type == 'mention' && x.host == null) { + return `${x.content}@${config.host}`; + } else { + return x.content; + } + }).join(''); + } + return { id: `${config.url}/notes/${note._id}`, type: 'Note', attributedTo, summary: note.cw, - content: toHtml(note), + content: toHtml(Object.assign({}, note, { text })), + _misskey_content: text, published: note.createdAt.toISOString(), to, cc, diff --git a/src/remote/activitypub/renderer/ordered-collection.ts b/src/remote/activitypub/renderer/ordered-collection.ts index 3c448cf873..5461005983 100644 --- a/src/remote/activitypub/renderer/ordered-collection.ts +++ b/src/remote/activitypub/renderer/ordered-collection.ts @@ -4,8 +4,9 @@ * @param totalItems Total number of items * @param first URL of first page (optional) * @param last URL of last page (optional) + * @param orderedItems attached objects (optional) */ -export default function(id: string, totalItems: any, first: string, last: string) { +export default function(id: string, totalItems: any, first?: string, last?: string, orderedItems?: object) { const page: any = { id, type: 'OrderedCollection', @@ -14,6 +15,7 @@ export default function(id: string, totalItems: any, first: string, last: string if (first) page.first = first; if (last) page.last = last; + if (orderedItems) page.orderedItems = orderedItems; return page; } diff --git a/src/remote/activitypub/renderer/person.ts b/src/remote/activitypub/renderer/person.ts index 78918af368..52485e6959 100644 --- a/src/remote/activitypub/renderer/person.ts +++ b/src/remote/activitypub/renderer/person.ts @@ -21,6 +21,7 @@ export default async (user: ILocalUser) => { outbox: `${id}/outbox`, followers: `${id}/followers`, following: `${id}/following`, + featured: `${id}/collections/featured`, sharedInbox: `${config.url}/inbox`, url: `${config.url}/@${user.username}`, preferredUsername: user.username, diff --git a/src/remote/activitypub/renderer/remove.ts b/src/remote/activitypub/renderer/remove.ts new file mode 100644 index 0000000000..ed840be751 --- /dev/null +++ b/src/remote/activitypub/renderer/remove.ts @@ -0,0 +1,9 @@ +import config from '../../../config'; +import { ILocalUser } from '../../../models/user'; + +export default (user: ILocalUser, target: any, object: any) => ({ + type: 'Remove', + actor: `${config.url}/users/${user._id}`, + target, + object +}); diff --git a/src/remote/activitypub/renderer/tombstone.ts b/src/remote/activitypub/renderer/tombstone.ts new file mode 100644 index 0000000000..553406b93b --- /dev/null +++ b/src/remote/activitypub/renderer/tombstone.ts @@ -0,0 +1,4 @@ +export default (id: string) => ({ + id, + type: 'Tombstone' +}); diff --git a/src/remote/activitypub/renderer/update.ts b/src/remote/activitypub/renderer/update.ts new file mode 100644 index 0000000000..cf9acc9acb --- /dev/null +++ b/src/remote/activitypub/renderer/update.ts @@ -0,0 +1,14 @@ +import config from '../../../config'; +import { ILocalUser } from '../../../models/user'; + +export default (object: any, user: ILocalUser) => { + const activity = { + id: `${config.url}/users/${user._id}#updates/${new Date().getTime()}`, + actor: `${config.url}/users/${user._id}`, + type: 'Update', + to: [ 'https://www.w3.org/ns/activitystreams#Public' ], + object + } as any; + + return activity; +}; diff --git a/src/remote/activitypub/request.ts b/src/remote/activitypub/request.ts index 6238d3acb1..177b6f458e 100644 --- a/src/remote/activitypub/request.ts +++ b/src/remote/activitypub/request.ts @@ -2,6 +2,7 @@ import { request } from 'https'; const { sign } = require('http-signature'); import { URL } from 'url'; import * as debug from 'debug'; +const crypto = require('crypto'); import config from '../../config'; import { ILocalUser } from '../../models/user'; @@ -11,22 +12,33 @@ const log = debug('misskey:activitypub:deliver'); export default (user: ILocalUser, url: string, object: any) => new Promise((resolve, reject) => { log(`--> ${url}`); + const timeout = 10 * 1000; + const { protocol, hostname, port, pathname, search } = new URL(url); + const data = JSON.stringify(object); + + const sha256 = crypto.createHash('sha256'); + sha256.update(data); + const hash = sha256.digest('base64'); + const req = request({ protocol, hostname, port, method: 'POST', path: pathname + search, + timeout, headers: { - 'Content-Type': 'application/activity+json' + 'User-Agent': config.user_agent, + 'Content-Type': 'application/activity+json', + 'Digest': `SHA-256=${hash}` } }, res => { log(`${url} --> ${res.statusCode}`); if (res.statusCode >= 400) { - reject(); + reject(res); } else { resolve(); } @@ -35,7 +47,8 @@ export default (user: ILocalUser, url: string, object: any) => new Promise((reso sign(req, { authorizationHeaderName: 'Signature', key: user.keypair, - keyId: `${config.url}/users/${user._id}/publickey` + keyId: `${config.url}/users/${user._id}/publickey`, + headers: ['date', 'host', 'digest'] }); // Signature: Signature ... => Signature: ... @@ -43,5 +56,12 @@ export default (user: ILocalUser, url: string, object: any) => new Promise((reso sig = sig.replace(/^Signature /, ''); req.setHeader('Signature', sig); - req.end(JSON.stringify(object)); + req.on('timeout', () => req.abort()); + + req.on('error', e => { + if (req.aborted) reject('timeout'); + reject(e); + }); + + req.end(data); }); diff --git a/src/remote/activitypub/resolver.ts b/src/remote/activitypub/resolver.ts index 0b053ca774..ff26971758 100644 --- a/src/remote/activitypub/resolver.ts +++ b/src/remote/activitypub/resolver.ts @@ -1,12 +1,13 @@ import * as request from 'request-promise-native'; import * as debug from 'debug'; import { IObject } from './type'; -//import config from '../../config'; +import config from '../../config'; const log = debug('misskey:activitypub:resolver'); export default class Resolver { private history: Set<string>; + private timeout = 10 * 1000; constructor() { this.history = new Set(); @@ -19,11 +20,11 @@ export default class Resolver { switch (collection.type) { case 'Collection': - collection.objects = collection.object.items; + collection.objects = collection.items; break; case 'OrderedCollection': - collection.objects = collection.object.orderedItems; + collection.objects = collection.orderedItems; break; default: @@ -50,10 +51,14 @@ export default class Resolver { const object = await request({ url: value, + timeout: this.timeout, headers: { + 'User-Agent': config.user_agent, Accept: 'application/activity+json, application/ld+json' }, json: true + }).catch(e => { + throw new Error(`request error: ${e.message}`); }); if (object === null || ( diff --git a/src/remote/activitypub/type.ts b/src/remote/activitypub/type.ts index 3d40ad48cb..5c06ee4ffe 100644 --- a/src/remote/activitypub/type.ts +++ b/src/remote/activitypub/type.ts @@ -40,6 +40,7 @@ export interface IOrderedCollection extends IObject { export interface INote extends IObject { type: 'Note'; + _misskey_content: string; } export interface IPerson extends IObject { @@ -52,6 +53,7 @@ export interface IPerson extends IObject { publicKey: any; followers: any; following: any; + featured?: any; outbox: any; endpoints: string[]; } @@ -89,6 +91,14 @@ export interface IReject extends IActivity { type: 'Reject'; } +export interface IAdd extends IActivity { + type: 'Add'; +} + +export interface IRemove extends IActivity { + type: 'Remove'; +} + export interface ILike extends IActivity { type: 'Like'; _misskey_reaction: string; @@ -107,5 +117,7 @@ export type Object = IFollow | IAccept | IReject | + IAdd | + IRemove | ILike | IAnnounce; diff --git a/src/server/activitypub.ts b/src/server/activitypub.ts index 1007790ca6..adbc6639fa 100644 --- a/src/server/activitypub.ts +++ b/src/server/activitypub.ts @@ -10,9 +10,10 @@ import User, { isLocalUser, ILocalUser, IUser } from '../models/user'; import renderNote from '../remote/activitypub/renderer/note'; import renderKey from '../remote/activitypub/renderer/key'; import renderPerson from '../remote/activitypub/renderer/person'; -import Outbox from './activitypub/outbox'; +import Outbox, { packActivity } from './activitypub/outbox'; import Followers from './activitypub/followers'; import Following from './activitypub/following'; +import Featured from './activitypub/featured'; // Init router const router = new Router(); @@ -22,7 +23,7 @@ const router = new Router(); function inbox(ctx: Router.IRouterContext) { let signature; - ctx.req.headers.authorization = 'Signature ' + ctx.req.headers.signature; + ctx.req.headers.authorization = `Signature ${ctx.req.headers.signature}`; try { signature = httpSignature.parseRequest(ctx.req, { 'headers': [] }); @@ -74,6 +75,24 @@ router.get('/notes/:note', async (ctx, next) => { } ctx.body = pack(await renderNote(note, false)); + ctx.set('Cache-Control', 'public, max-age=180'); + setResponseType(ctx); +}); + +// note activity +router.get('/notes/:note/activity', async ctx => { + const note = await Note.findOne({ + _id: new mongo.ObjectID(ctx.params.note), + visibility: { $in: ['public', 'home'] } + }); + + if (note === null) { + ctx.status = 404; + return; + } + + ctx.body = pack(await packActivity(note)); + ctx.set('Cache-Control', 'public, max-age=180'); setResponseType(ctx); }); @@ -86,6 +105,9 @@ router.get('/users/:user/followers', Followers); // following router.get('/users/:user/following', Following); +// featured +router.get('/users/:user/collections/featured', Featured); + // publickey router.get('/users/:user/publickey', async ctx => { const userId = new mongo.ObjectID(ctx.params.user); @@ -102,6 +124,7 @@ router.get('/users/:user/publickey', async ctx => { if (isLocalUser(user)) { ctx.body = pack(renderKey(user)); + ctx.set('Cache-Control', 'public, max-age=180'); setResponseType(ctx); } else { ctx.status = 400; @@ -116,6 +139,7 @@ async function userInfo(ctx: Router.IRouterContext, user: IUser) { } ctx.body = pack(await renderPerson(user as ILocalUser)); + ctx.set('Cache-Control', 'public, max-age=180'); setResponseType(ctx); } diff --git a/src/server/activitypub/featured.ts b/src/server/activitypub/featured.ts new file mode 100644 index 0000000000..f400cc416f --- /dev/null +++ b/src/server/activitypub/featured.ts @@ -0,0 +1,39 @@ +import * as mongo from 'mongodb'; +import * as Router from 'koa-router'; +import config from '../../config'; +import User from '../../models/user'; +import pack from '../../remote/activitypub/renderer'; +import renderOrderedCollection from '../../remote/activitypub/renderer/ordered-collection'; +import { setResponseType } from '../activitypub'; +import Note from '../../models/note'; +import renderNote from '../../remote/activitypub/renderer/note'; + +export default async (ctx: Router.IRouterContext) => { + const userId = new mongo.ObjectID(ctx.params.user); + + // Verify user + const user = await User.findOne({ + _id: userId, + host: null + }); + + if (user === null) { + ctx.status = 404; + return; + } + + const pinnedNoteIds = user.pinnedNoteIds || []; + + const pinnedNotes = await Promise.all(pinnedNoteIds.map(id => Note.findOne({ _id: id }))); + + const renderedNotes = await Promise.all(pinnedNotes.map(note => renderNote(note))); + + const rendered = renderOrderedCollection( + `${config.url}/users/${userId}/collections/featured`, + renderedNotes.length, null, null, renderedNotes + ); + + ctx.body = pack(rendered); + ctx.set('Cache-Control', 'private, max-age=0, must-revalidate'); + setResponseType(ctx); +}; diff --git a/src/server/activitypub/followers.ts b/src/server/activitypub/followers.ts index eb58703443..fcc75fc5b1 100644 --- a/src/server/activitypub/followers.ts +++ b/src/server/activitypub/followers.ts @@ -78,6 +78,7 @@ export default async (ctx: Router.IRouterContext) => { // index page const rendered = renderOrderedCollection(partOf, user.followersCount, `${partOf}?page=true`, null); ctx.body = pack(rendered); + ctx.set('Cache-Control', 'private, max-age=0, must-revalidate'); setResponseType(ctx); } }; diff --git a/src/server/activitypub/following.ts b/src/server/activitypub/following.ts index 80878fd4ca..2c739ff07d 100644 --- a/src/server/activitypub/following.ts +++ b/src/server/activitypub/following.ts @@ -78,6 +78,7 @@ export default async (ctx: Router.IRouterContext) => { // index page const rendered = renderOrderedCollection(partOf, user.followingCount, `${partOf}?page=true`, null); ctx.body = pack(rendered); + ctx.set('Cache-Control', 'private, max-age=0, must-revalidate'); setResponseType(ctx); } }; diff --git a/src/server/activitypub/outbox.ts b/src/server/activitypub/outbox.ts index 37df190880..aeb6f25dd4 100644 --- a/src/server/activitypub/outbox.ts +++ b/src/server/activitypub/outbox.ts @@ -8,8 +8,11 @@ import renderOrderedCollection from '../../remote/activitypub/renderer/ordered-c import renderOrderedCollectionPage from '../../remote/activitypub/renderer/ordered-collection-page'; import { setResponseType } from '../activitypub'; -import Note from '../../models/note'; +import Note, { INote } from '../../models/note'; import renderNote from '../../remote/activitypub/renderer/note'; +import renderCreate from '../../remote/activitypub/renderer/create'; +import renderAnnounce from '../../remote/activitypub/renderer/announce'; +import { countIf } from '../../prelude/array'; export default async (ctx: Router.IRouterContext) => { const userId = new mongo.ObjectID(ctx.params.user); @@ -25,7 +28,7 @@ export default async (ctx: Router.IRouterContext) => { const page: boolean = ctx.request.query.page === 'true'; // Validate parameters - if (sinceIdErr || untilIdErr || pageErr || [sinceId, untilId].filter(x => x != null).length > 1) { + if (sinceIdErr || untilIdErr || pageErr || countIf(x => x != null, [sinceId, untilId]) > 1) { ctx.status = 400; return; } @@ -52,15 +55,7 @@ export default async (ctx: Router.IRouterContext) => { const query = { userId: user._id, - $and: [{ - $or: [ { visibility: 'public' }, { visibility: 'home' } ] - }, { // exclude renote, but include quote - $or: [{ - text: { $ne: null } - }, { - mediaIds: { $ne: [] } - }] - }] + visibility: { $in: ['public', 'home'] } } as any; if (sinceId) { @@ -84,15 +79,16 @@ export default async (ctx: Router.IRouterContext) => { if (sinceId) notes.reverse(); - const renderedNotes = await Promise.all(notes.map(note => renderNote(note, false))); + const activities = await Promise.all(notes.map(note => packActivity(note))); const rendered = renderOrderedCollectionPage( `${partOf}?page=true${sinceId ? `&since_id=${sinceId}` : ''}${untilId ? `&until_id=${untilId}` : ''}`, - user.notesCount, renderedNotes, partOf, + user.notesCount, activities, partOf, notes.length > 0 ? `${partOf}?page=true&since_id=${notes[0]._id}` : null, notes.length > 0 ? `${partOf}?page=true&until_id=${notes[notes.length - 1]._id}` : null ); ctx.body = pack(rendered); + ctx.set('Cache-Control', 'private, max-age=0, must-revalidate'); setResponseType(ctx); } else { // index page @@ -101,6 +97,20 @@ export default async (ctx: Router.IRouterContext) => { `${partOf}?page=true&since_id=000000000000000000000000` ); ctx.body = pack(rendered); + ctx.set('Cache-Control', 'private, max-age=0, must-revalidate'); setResponseType(ctx); } }; + +/** + * Pack Create<Note> or Announce Activity + * @param note Note + */ +export async function packActivity(note: INote): Promise<object> { + if (note.renoteId && note.text == null && note.poll == null && (note.fileIds == null || note.fileIds.length == 0)) { + const renote = await Note.findOne(note.renoteId); + return renderAnnounce(renote.uri ? renote.uri : `${config.url}/notes/${renote._id}`, note); + } + + return renderCreate(await renderNote(note, false), note); +} diff --git a/src/server/api/call.ts b/src/server/api/call.ts index e9abc11f54..7419bdc95d 100644 --- a/src/server/api/call.ts +++ b/src/server/api/call.ts @@ -9,6 +9,10 @@ export default (endpoint: string, user: IUser, app: IApp, data: any, file?: any) const ep = endpoints.find(e => e.name === endpoint); + if (ep == null) { + return rej('ENDPOINT_NOT_FOUND'); + } + if (ep.meta.secure && !isSecure) { return rej('ACCESS_DENIED'); } @@ -25,10 +29,8 @@ export default (endpoint: string, user: IUser, app: IApp, data: any, file?: any) return rej('YOU_ARE_NOT_ADMIN'); } - if (app && ep.meta.kind) { - if (!app.permission.some(p => p === ep.meta.kind)) { - return rej('PERMISSION_DENIED'); - } + if (app && ep.meta.kind && !app.permission.some(p => p === ep.meta.kind)) { + return rej('PERMISSION_DENIED'); } if (ep.meta.requireCredential && ep.meta.limit) { diff --git a/src/server/api/common/read-messaging-message.ts b/src/server/api/common/read-messaging-message.ts index 005240a37c..075e369832 100644 --- a/src/server/api/common/read-messaging-message.ts +++ b/src/server/api/common/read-messaging-message.ts @@ -1,7 +1,7 @@ import * as mongo from 'mongodb'; import Message from '../../../models/messaging-message'; import { IMessagingMessage as IMessage } from '../../../models/messaging-message'; -import { publishUserStream } from '../../../stream'; +import { publishMainStream } from '../../../stream'; import { publishMessagingStream } from '../../../stream'; import { publishMessagingIndexStream } from '../../../stream'; import User from '../../../models/user'; @@ -71,6 +71,6 @@ export default ( }); // 全ての(いままで未読だった)自分宛てのメッセージを(これで)読みましたよというイベントを発行 - publishUserStream(userId, 'read_all_messaging_messages'); + publishMainStream(userId, 'readAllMessagingMessages'); } }); diff --git a/src/server/api/common/read-notification.ts b/src/server/api/common/read-notification.ts index 0b0f3e4e5a..2d58ada4ce 100644 --- a/src/server/api/common/read-notification.ts +++ b/src/server/api/common/read-notification.ts @@ -1,6 +1,6 @@ import * as mongo from 'mongodb'; import { default as Notification, INotification } from '../../../models/notification'; -import { publishUserStream } from '../../../stream'; +import { publishMainStream } from '../../../stream'; import Mute from '../../../models/mute'; import User from '../../../models/user'; @@ -66,6 +66,6 @@ export default ( }); // 全ての(いままで未読だった)通知を(これで)読みましたよというイベントを発行 - publishUserStream(userId, 'read_all_notifications'); + publishMainStream(userId, 'readAllNotifications'); } }); diff --git a/src/server/api/endpoints.ts b/src/server/api/endpoints.ts index d4a44070e6..2b00094269 100644 --- a/src/server/api/endpoints.ts +++ b/src/server/api/endpoints.ts @@ -79,7 +79,7 @@ const files = glob.sync('**/*.js', { }); const endpoints: IEndpoint[] = files.map(f => { - const ep = require('./endpoints/' + f); + const ep = require(`./endpoints/${f}`); return { name: f.replace('.js', ''), diff --git a/src/server/api/endpoints/admin/update-meta.ts b/src/server/api/endpoints/admin/update-meta.ts index 2c7929fabe..f0ebfbe936 100644 --- a/src/server/api/endpoints/admin/update-meta.ts +++ b/src/server/api/endpoints/admin/update-meta.ts @@ -11,11 +11,35 @@ export const meta = { requireAdmin: true, params: { + broadcasts: $.arr($.obj()).optional.nullable.note({ + desc: { + 'ja-JP': 'ブロードキャスト' + } + }), + disableRegistration: $.bool.optional.nullable.note({ desc: { 'ja-JP': '招待制か否か' } }), + + disableLocalTimeline: $.bool.optional.nullable.note({ + desc: { + 'ja-JP': 'ローカルタイムライン(とソーシャルタイムライン)を無効にするか否か' + } + }), + + hidedTags: $.arr($.str).optional.nullable.note({ + desc: { + 'ja-JP': '統計などで無視するハッシュタグ' + } + }), + + bannerUrl: $.str.optional.nullable.note({ + desc: { + 'ja-JP': 'インスタンスのバナー画像URL' + } + }), } }; @@ -25,10 +49,26 @@ export default (params: any) => new Promise(async (res, rej) => { const set = {} as any; - if (ps.disableRegistration === true || ps.disableRegistration === false) { + if (ps.broadcasts) { + set.broadcasts = ps.broadcasts; + } + + if (typeof ps.disableRegistration === 'boolean') { set.disableRegistration = ps.disableRegistration; } + if (typeof ps.disableLocalTimeline === 'boolean') { + set.disableLocalTimeline = ps.disableLocalTimeline; + } + + if (Array.isArray(ps.hidedTags)) { + set.hidedTags = ps.hidedTags; + } + + if (ps.bannerUrl !== undefined) { + set.bannerUrl = ps.bannerUrl; + } + await Meta.update({}, { $set: set }, { upsert: true }); diff --git a/src/server/api/endpoints/aggregation/hashtags.ts b/src/server/api/endpoints/aggregation/hashtags.ts new file mode 100644 index 0000000000..ffeafb2538 --- /dev/null +++ b/src/server/api/endpoints/aggregation/hashtags.ts @@ -0,0 +1,66 @@ +import Note from '../../../../models/note'; +import Meta from '../../../../models/meta'; + +export default () => new Promise(async (res, rej) => { + const meta = await Meta.findOne({}); + const hidedTags = meta ? (meta.hidedTags || []).map(t => t.toLowerCase()) : []; + + const span = 1000 * 60 * 60 * 24 * 7; // 1週間 + + //#region 1. 指定期間の内に投稿されたハッシュタグ(とユーザーのペア)を集計 + const data = await Note.aggregate([{ + $match: { + createdAt: { + $gt: new Date(Date.now() - span) + }, + tagsLower: { + $exists: true, + $ne: [] + } + } + }, { + $unwind: '$tagsLower' + }, { + $group: { + _id: { tag: '$tagsLower', userId: '$userId' } + } + }]) as Array<{ + _id: { + tag: string; + userId: any; + } + }>; + //#endregion + + if (data.length == 0) { + return res([]); + } + + let tags: Array<{ + name: string; + count: number; + }> = []; + + // カウント + data.map(x => x._id).forEach(x => { + // ブラックリストに登録されているタグなら弾く + if (hidedTags.includes(x.tag)) return; + + const i = tags.findIndex(tag => tag.name == x.tag); + if (i != -1) { + tags[i].count++; + } else { + tags.push({ + name: x.tag, + count: 1 + }); + } + }); + + // タグを人気順に並べ替え + tags = tags.sort((a, b) => b.count - a.count); + + tags = tags.slice(0, 30); + + res(tags); +}); diff --git a/src/server/api/endpoints/ap/show.ts b/src/server/api/endpoints/ap/show.ts new file mode 100644 index 0000000000..1f390d01aa --- /dev/null +++ b/src/server/api/endpoints/ap/show.ts @@ -0,0 +1,116 @@ +import $ from 'cafy'; +import getParams from '../../get-params'; +import config from '../../../../config'; +import * as mongo from 'mongodb'; +import User, { pack as packUser, IUser } from '../../../../models/user'; +import { createPerson } from '../../../../remote/activitypub/models/person'; +import Note, { pack as packNote, INote } from '../../../../models/note'; +import { createNote } from '../../../../remote/activitypub/models/note'; +import Resolver from '../../../../remote/activitypub/resolver'; + +export const meta = { + desc: { + 'ja-JP': 'URIを指定してActivityPubオブジェクトを参照します。' + }, + + requireCredential: false, + + params: { + uri: $.str.note({ + desc: { + 'ja-JP': 'ActivityPubオブジェクトのURI' + } + }), + }, +}; + +export default (params: any) => new Promise(async (res, rej) => { + const [ps, psErr] = getParams(meta, params); + if (psErr) return rej(psErr); + + const object = await fetchAny(ps.uri); + if (object !== null) return res(object); + + return rej('object not found'); +}); + +/*** + * URIからUserかNoteを解決する + */ +async function fetchAny(uri: string) { + // URIがこのサーバーを指しているなら、ローカルユーザーIDとしてDBからフェッチ + if (uri.startsWith(config.url + '/')) { + const id = new mongo.ObjectID(uri.split('/').pop()); + const [ user, note ] = await Promise.all([ + User.findOne({ _id: id }), + Note.findOne({ _id: id }) + ]); + + const packed = await mergePack(user, note); + if (packed !== null) return packed; + } + + // URI(AP Object id)としてDB検索 + { + const [ user, note ] = await Promise.all([ + User.findOne({ uri: uri }), + Note.findOne({ uri: uri }) + ]); + + const packed = await mergePack(user, note); + if (packed !== null) return packed; + } + + // リモートから一旦オブジェクトフェッチ + const resolver = new Resolver(); + const object = await resolver.resolve(uri) as any; + + // /@user のような正規id以外で取得できるURIが指定されていた場合、ここで初めて正規URIが確定する + // これはDBに存在する可能性があるため再度DB検索 + if (uri !== object.id) { + const [ user, note ] = await Promise.all([ + User.findOne({ uri: object.id }), + Note.findOne({ uri: object.id }) + ]); + + const packed = await mergePack(user, note); + if (packed !== null) return packed; + } + + // それでもみつからなければ新規であるため登録 + if (object.type === 'Person') { + const user = await createPerson(object.id); + return { + type: 'User', + object: user + }; + } + + if (object.type === 'Note') { + const note = await createNote(object.id); + return { + type: 'Note', + object: note + }; + } + + return null; +} + +async function mergePack(user: IUser, note: INote) { + if (user !== null) { + return { + type: 'User', + object: await packUser(user, null, { detail: true }) + }; + } + + if (note !== null) { + return { + type: 'Note', + object: await packNote(note, null, { detail: true }) + }; + } + + return null; +} diff --git a/src/server/api/endpoints/chart.ts b/src/server/api/endpoints/chart.ts index 7da970131e..3b1a3b56fc 100644 --- a/src/server/api/endpoints/chart.ts +++ b/src/server/api/endpoints/chart.ts @@ -6,6 +6,15 @@ type Omit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>; function migrateStats(stats: IStats[]) { stats.forEach(stat => { + if (stat.network == null) { + stat.network = { + requests: 0, + totalTime: 0, + incomingBytes: 0, + outgoingBytes: 0 + }; + } + const isOldData = stat.users.local.inc == null || stat.users.local.dec == null || @@ -180,6 +189,12 @@ export default (params: any) => new Promise(async (res, rej) => { decCount: 0, decSize: 0 } + }, + network: { + requests: 0, + totalTime: 0, + incomingBytes: 0, + outgoingBytes: 0 } }); } else { @@ -236,6 +251,12 @@ export default (params: any) => new Promise(async (res, rej) => { decCount: 0, decSize: 0 } + }, + network: { + requests: 0, + totalTime: 0, + incomingBytes: 0, + outgoingBytes: 0 } }); } diff --git a/src/server/api/endpoints/drive/files.ts b/src/server/api/endpoints/drive/files.ts index dc6a602e10..de0bde086b 100644 --- a/src/server/api/endpoints/drive/files.ts +++ b/src/server/api/endpoints/drive/files.ts @@ -1,5 +1,5 @@ import $ from 'cafy'; import ID from '../../../../misc/cafy-id'; -import DriveFile, { pack } from '../../../../models/drive-file'; +import DriveFile, { packMany } from '../../../../models/drive-file'; import { ILocalUser } from '../../../../models/user'; export const meta = { @@ -73,6 +73,5 @@ export default async (params: any, user: ILocalUser) => { }); // Serialize - const _files = await Promise.all(files.map(file => pack(file))); - return _files; + return await packMany(files); }; diff --git a/src/server/api/endpoints/drive/files/check_existence.ts b/src/server/api/endpoints/drive/files/check_existence.ts new file mode 100644 index 0000000000..73d75b7caf --- /dev/null +++ b/src/server/api/endpoints/drive/files/check_existence.ts @@ -0,0 +1,38 @@ +import $ from 'cafy'; +import DriveFile, { pack } from '../../../../../models/drive-file'; +import { ILocalUser } from '../../../../../models/user'; + +export const meta = { + desc: { + 'ja-JP': '与えられたMD5ハッシュ値を持つファイルがドライブに存在するかどうかを返します。', + 'en-US': 'Returns whether the file with the given MD5 hash exists in the user\'s drive.' + }, + + requireCredential: true, + + kind: 'drive-read', + + params: { + md5: $.str.note({ + desc: { + 'ja-JP': 'ファイルのMD5ハッシュ' + } + }) + } +}; + +export default (params: any, user: ILocalUser) => new Promise(async (res, rej) => { + const [md5, md5Err] = $.str.get(params.md5); + if (md5Err) return rej('invalid md5 param'); + + const file = await DriveFile.findOne({ + md5: md5, + 'metadata.userId': user._id + }); + + if (file === null) { + res({ file: null }); + } else { + res({ file: await pack(file) }); + } +}); diff --git a/src/server/api/endpoints/drive/files/create.ts b/src/server/api/endpoints/drive/files/create.ts index dfbd11d0c2..4b5ffa90e0 100644 --- a/src/server/api/endpoints/drive/files/create.ts +++ b/src/server/api/endpoints/drive/files/create.ts @@ -31,8 +31,8 @@ export const meta = { } }), - isSensitive: $.bool.optional.note({ - default: false, + isSensitive: $.bool.optional.nullable.note({ + default: null, desc: { 'ja-JP': 'このメディアが「閲覧注意」(NSFW)かどうか', 'en-US': 'Whether this media is NSFW' diff --git a/src/server/api/endpoints/drive/files/update.ts b/src/server/api/endpoints/drive/files/update.ts index ba9abfec61..3c7932c341 100644 --- a/src/server/api/endpoints/drive/files/update.ts +++ b/src/server/api/endpoints/drive/files/update.ts @@ -4,6 +4,7 @@ import DriveFile, { validateFileName, pack } from '../../../../../models/drive-f import { publishDriveStream } from '../../../../../stream'; import { ILocalUser } from '../../../../../models/user'; import getParams from '../../../get-params'; +import Note from '../../../../../models/note'; export const meta = { desc: { @@ -93,6 +94,18 @@ export default (params: any, user: ILocalUser) => new Promise(async (res, rej) = } }); + // ドライブのファイルが非正規化されているドキュメントも更新 + Note.find({ + '_files._id': file._id + }).then(notes => { + notes.forEach(note => { + note._files[note._files.findIndex(f => f._id.equals(file._id))] = file; + Note.findOneAndUpdate({ _id: note._id }, { + _files: note._files + }); + }); + }); + // Serialize const fileObj = await pack(file); diff --git a/src/server/api/endpoints/drive/stream.ts b/src/server/api/endpoints/drive/stream.ts index a9f3f7e9a5..3ac7dd0234 100644 --- a/src/server/api/endpoints/drive/stream.ts +++ b/src/server/api/endpoints/drive/stream.ts @@ -1,5 +1,5 @@ import $ from 'cafy'; import ID from '../../../../misc/cafy-id'; -import DriveFile, { pack } from '../../../../models/drive-file'; +import DriveFile, { packMany } from '../../../../models/drive-file'; import { ILocalUser } from '../../../../models/user'; export const meta = { @@ -63,5 +63,5 @@ export default (params: any, user: ILocalUser) => new Promise(async (res, rej) = }); // Serialize - res(await Promise.all(files.map(file => pack(file)))); + res(await packMany(files)); }); diff --git a/src/server/api/endpoints/following/create.ts b/src/server/api/endpoints/following/create.ts index c9bea0e3d2..00aa904f08 100644 --- a/src/server/api/endpoints/following/create.ts +++ b/src/server/api/endpoints/following/create.ts @@ -57,7 +57,7 @@ export default (params: any, user: ILocalUser) => new Promise(async (res, rej) = } // Create following - create(follower, followee); + await create(follower, followee); // Send response res(await pack(followee._id, user)); diff --git a/src/server/api/endpoints/following/delete.ts b/src/server/api/endpoints/following/delete.ts index f3b4a73ae8..cdfbf43cd1 100644 --- a/src/server/api/endpoints/following/delete.ts +++ b/src/server/api/endpoints/following/delete.ts @@ -57,7 +57,7 @@ export default (params: any, user: ILocalUser) => new Promise(async (res, rej) = } // Delete following - deleteFollowing(follower, followee); + await deleteFollowing(follower, followee); // Send response res(await pack(followee._id, user)); diff --git a/src/server/api/endpoints/games/reversi/match.ts b/src/server/api/endpoints/games/reversi/match.ts index aba400af1d..d7483a0bfd 100644 --- a/src/server/api/endpoints/games/reversi/match.ts +++ b/src/server/api/endpoints/games/reversi/match.ts @@ -2,7 +2,7 @@ import $ from 'cafy'; import ID from '../../../../../misc/cafy-id'; import Matching, { pack as packMatching } from '../../../../../models/games/reversi/matching'; import ReversiGame, { pack as packGame } from '../../../../../models/games/reversi/game'; import User, { ILocalUser } from '../../../../../models/user'; -import { publishUserStream, publishReversiStream } from '../../../../../stream'; +import { publishMainStream, publishReversiStream } from '../../../../../stream'; import { eighteight } from '../../../../../games/reversi/maps'; export const meta = { @@ -58,7 +58,7 @@ export default (params: any, user: ILocalUser) => new Promise(async (res, rej) = }); if (other == 0) { - publishUserStream(user._id, 'reversi_no_invites'); + publishMainStream(user._id, 'reversi_no_invites'); } } else { // Fetch child @@ -94,6 +94,6 @@ export default (params: any, user: ILocalUser) => new Promise(async (res, rej) = // 招待 publishReversiStream(child._id, 'invited', packed); - publishUserStream(child._id, 'reversi_invited', packed); + publishMainStream(child._id, 'reversiInvited', packed); } }); diff --git a/src/server/api/endpoints/hashtags/trend.ts b/src/server/api/endpoints/hashtags/trend.ts index 01dfccc71c..0ec6a4ffec 100644 --- a/src/server/api/endpoints/hashtags/trend.ts +++ b/src/server/api/endpoints/hashtags/trend.ts @@ -1,4 +1,6 @@ import Note from '../../../../models/note'; +import { erase } from '../../../../prelude/array'; +import Meta from '../../../../models/meta'; /* トレンドに載るためには「『直近a分間のユニーク投稿数が今からa分前~今からb分前の間のユニーク投稿数のn倍以上』のハッシュタグの上位5位以内に入る」ことが必要 @@ -16,6 +18,9 @@ const max = 5; * Get trends of hashtags */ export default () => new Promise(async (res, rej) => { + const meta = await Meta.findOne({}); + const hidedTags = meta ? (meta.hidedTags || []).map(t => t.toLowerCase()) : []; + //#region 1. 直近Aの内に投稿されたハッシュタグ(とユーザーのペア)を集計 const data = await Note.aggregate([{ $match: { @@ -52,6 +57,9 @@ export default () => new Promise(async (res, rej) => { // カウント data.map(x => x._id).forEach(x => { + // ブラックリストに登録されているタグなら弾く + if (hidedTags.includes(x.tag)) return; + const i = tags.findIndex(tag => tag.name == x.tag); if (i != -1) { tags[i].count++; @@ -85,8 +93,7 @@ export default () => new Promise(async (res, rej) => { //#endregion // タグを人気順に並べ替え - let hots = (await Promise.all(hotsPromises)) - .filter(x => x != null) + let hots = erase(null, await Promise.all(hotsPromises)) .sort((a, b) => b.count - a.count) .map(tag => tag.name) .slice(0, max); diff --git a/src/server/api/endpoints/i.ts b/src/server/api/endpoints/i.ts index 1f99ef2d8d..5aa2070650 100644 --- a/src/server/api/endpoints/i.ts +++ b/src/server/api/endpoints/i.ts @@ -22,6 +22,7 @@ export default (params: any, user: ILocalUser, app: IApp) => new Promise(async ( // Serialize res(await pack(user, user, { detail: true, + includeHasUnreadNotes: true, includeSecrets: isSecure })); diff --git a/src/server/api/endpoints/i/favorites.ts b/src/server/api/endpoints/i/favorites.ts index 32c1a55fb0..e7cf8a71a7 100644 --- a/src/server/api/endpoints/i/favorites.ts +++ b/src/server/api/endpoints/i/favorites.ts @@ -1,5 +1,5 @@ import $ from 'cafy'; import ID from '../../../../misc/cafy-id'; -import Favorite, { pack } from '../../../../models/favorite'; +import Favorite, { packMany } from '../../../../models/favorite'; import { ILocalUser } from '../../../../models/user'; export const meta = { @@ -55,5 +55,5 @@ export default (params: any, user: ILocalUser) => new Promise(async (res, rej) = .find(query, { limit, sort }); // Serialize - res(await Promise.all(favorites.map(favorite => pack(favorite, user)))); + res(await packMany(favorites, user)); }); diff --git a/src/server/api/endpoints/i/notifications.ts b/src/server/api/endpoints/i/notifications.ts index 46242b9d9f..5cc836e362 100644 --- a/src/server/api/endpoints/i/notifications.ts +++ b/src/server/api/endpoints/i/notifications.ts @@ -1,7 +1,7 @@ import $ from 'cafy'; import ID from '../../../../misc/cafy-id'; import Notification from '../../../../models/notification'; import Mute from '../../../../models/mute'; -import { pack } from '../../../../models/notification'; +import { packMany } from '../../../../models/notification'; import { getFriendIds } from '../../common/get-friends'; import read from '../../common/read-notification'; import { ILocalUser } from '../../../../models/user'; @@ -83,7 +83,7 @@ export default (params: any, user: ILocalUser) => new Promise(async (res, rej) = }); // Serialize - res(await Promise.all(notifications.map(notification => pack(notification)))); + res(await packMany(notifications)); // Mark all as read if (notifications.length > 0 && markAsRead) { diff --git a/src/server/api/endpoints/i/pin.ts b/src/server/api/endpoints/i/pin.ts index ae03a86336..bf729ca091 100644 --- a/src/server/api/endpoints/i/pin.ts +++ b/src/server/api/endpoints/i/pin.ts @@ -1,31 +1,37 @@ import $ from 'cafy'; import ID from '../../../../misc/cafy-id'; -import User, { ILocalUser } from '../../../../models/user'; -import Note from '../../../../models/note'; +import { ILocalUser } from '../../../../models/user'; import { pack } from '../../../../models/user'; +import { addPinned } from '../../../../services/i/pin'; +import getParams from '../../get-params'; -/** - * Pin note - */ -export default async (params: any, user: ILocalUser) => new Promise(async (res, rej) => { - // Get 'noteId' parameter - const [noteId, noteIdErr] = $.type(ID).get(params.noteId); - if (noteIdErr) return rej('invalid noteId param'); +export const meta = { + desc: { + 'ja-JP': '指定した投稿をピン留めします。' + }, - // Fetch pinee - const note = await Note.findOne({ - _id: noteId, - userId: user._id - }); + requireCredential: true, - if (note === null) { - return rej('note not found'); + kind: 'account-write', + + params: { + noteId: $.type(ID).note({ + desc: { + 'ja-JP': '対象の投稿のID' + } + }) } +}; - await User.update(user._id, { - $set: { - pinnedNoteId: note._id - } - }); +export default async (params: any, user: ILocalUser) => new Promise(async (res, rej) => { + const [ps, psErr] = getParams(meta, params); + if (psErr) return rej(psErr); + + // Processing + try { + await addPinned(user, ps.noteId); + } catch (e) { + return rej(e.message); + } // Serialize const iObj = await pack(user, user, { diff --git a/src/server/api/endpoints/i/regenerate_token.ts b/src/server/api/endpoints/i/regenerate_token.ts index fe4a5cd118..2d85f06cfa 100644 --- a/src/server/api/endpoints/i/regenerate_token.ts +++ b/src/server/api/endpoints/i/regenerate_token.ts @@ -1,7 +1,7 @@ import $ from 'cafy'; import * as bcrypt from 'bcryptjs'; import User, { ILocalUser } from '../../../../models/user'; -import { publishUserStream } from '../../../../stream'; +import { publishMainStream } from '../../../../stream'; import generateUserToken from '../../common/generate-native-user-token'; export const meta = { @@ -33,5 +33,5 @@ export default async (params: any, user: ILocalUser) => new Promise(async (res, res(); // Publish event - publishUserStream(user._id, 'my_token_regenerated'); + publishMainStream(user._id, 'myTokenRegenerated'); }); diff --git a/src/server/api/endpoints/i/unpin.ts b/src/server/api/endpoints/i/unpin.ts new file mode 100644 index 0000000000..2a81993e4b --- /dev/null +++ b/src/server/api/endpoints/i/unpin.ts @@ -0,0 +1,43 @@ +import $ from 'cafy'; import ID from '../../../../misc/cafy-id'; +import { ILocalUser } from '../../../../models/user'; +import { pack } from '../../../../models/user'; +import { removePinned } from '../../../../services/i/pin'; +import getParams from '../../get-params'; + +export const meta = { + desc: { + 'ja-JP': '指定した投稿のピン留めを解除します。' + }, + + requireCredential: true, + + kind: 'account-write', + + params: { + noteId: $.type(ID).note({ + desc: { + 'ja-JP': '対象の投稿のID' + } + }) + } +}; + +export default async (params: any, user: ILocalUser) => new Promise(async (res, rej) => { + const [ps, psErr] = getParams(meta, params); + if (psErr) return rej(psErr); + + // Processing + try { + await removePinned(user, ps.noteId); + } catch (e) { + return rej(e.message); + } + + // Serialize + const iObj = await pack(user, user, { + detail: true + }); + + // Send response + res(iObj); +}); diff --git a/src/server/api/endpoints/i/update.ts b/src/server/api/endpoints/i/update.ts index cdb4eb3f56..548ce5cadb 100644 --- a/src/server/api/endpoints/i/update.ts +++ b/src/server/api/endpoints/i/update.ts @@ -1,10 +1,12 @@ import $ from 'cafy'; import ID from '../../../../misc/cafy-id'; import User, { isValidName, isValidDescription, isValidLocation, isValidBirthday, pack, ILocalUser } from '../../../../models/user'; -import { publishUserStream } from '../../../../stream'; +import { publishMainStream } from '../../../../stream'; import DriveFile from '../../../../models/drive-file'; import acceptAllFollowRequests from '../../../../services/following/requests/accept-all'; import { IApp } from '../../../../models/app'; import config from '../../../../config'; +import { publishToFollowers } from '../../../../services/i/update'; +import getParams from '../../get-params'; export const meta = { desc: { @@ -14,75 +16,111 @@ export const meta = { requireCredential: true, - kind: 'account-write' -}; + kind: 'account-write', -export default async (params: any, user: ILocalUser, app: IApp) => new Promise(async (res, rej) => { - const isSecure = user != null && app == null; + params: { + name: $.str.optional.nullable.pipe(isValidName).note({ + desc: { + 'ja-JP': '名前(ハンドルネームやニックネーム)' + } + }), - const updates = {} as any; + description: $.str.optional.nullable.pipe(isValidDescription).note({ + desc: { + 'ja-JP': 'アカウントの説明や自己紹介' + } + }), - // Get 'name' parameter - const [name, nameErr] = $.str.optional.nullable.pipe(isValidName).get(params.name); - if (nameErr) return rej('invalid name param'); - if (name) updates.name = name; + location: $.str.optional.nullable.pipe(isValidLocation).note({ + desc: { + 'ja-JP': '住んでいる地域、所在' + } + }), - // Get 'description' parameter - const [description, descriptionErr] = $.str.optional.nullable.pipe(isValidDescription).get(params.description); - if (descriptionErr) return rej('invalid description param'); - if (description !== undefined) updates.description = description; + birthday: $.str.optional.nullable.pipe(isValidBirthday).note({ + desc: { + 'ja-JP': '誕生日 (YYYY-MM-DD形式)' + } + }), - // Get 'location' parameter - const [location, locationErr] = $.str.optional.nullable.pipe(isValidLocation).get(params.location); - if (locationErr) return rej('invalid location param'); - if (location !== undefined) updates['profile.location'] = location; + avatarId: $.type(ID).optional.nullable.note({ + desc: { + 'ja-JP': 'アイコンに設定する画像のドライブファイルID' + } + }), - // Get 'birthday' parameter - const [birthday, birthdayErr] = $.str.optional.nullable.pipe(isValidBirthday).get(params.birthday); - if (birthdayErr) return rej('invalid birthday param'); - if (birthday !== undefined) updates['profile.birthday'] = birthday; + bannerId: $.type(ID).optional.nullable.note({ + desc: { + 'ja-JP': 'バナーに設定する画像のドライブファイルID' + } + }), - // Get 'avatarId' parameter - const [avatarId, avatarIdErr] = $.type(ID).optional.nullable.get(params.avatarId); - if (avatarIdErr) return rej('invalid avatarId param'); - if (avatarId !== undefined) updates.avatarId = avatarId; + wallpaperId: $.type(ID).optional.nullable.note({ + desc: { + 'ja-JP': '壁紙に設定する画像のドライブファイルID' + } + }), - // Get 'bannerId' parameter - const [bannerId, bannerIdErr] = $.type(ID).optional.nullable.get(params.bannerId); - if (bannerIdErr) return rej('invalid bannerId param'); - if (bannerId !== undefined) updates.bannerId = bannerId; + isLocked: $.bool.optional.note({ + desc: { + 'ja-JP': '鍵アカウントか否か' + } + }), - // Get 'wallpaperId' parameter - const [wallpaperId, wallpaperIdErr] = $.type(ID).optional.nullable.get(params.wallpaperId); - if (wallpaperIdErr) return rej('invalid wallpaperId param'); - if (wallpaperId !== undefined) updates.wallpaperId = wallpaperId; + isBot: $.bool.optional.note({ + desc: { + 'ja-JP': 'Botか否か' + } + }), - // Get 'isLocked' parameter - const [isLocked, isLockedErr] = $.bool.optional.get(params.isLocked); - if (isLockedErr) return rej('invalid isLocked param'); - if (isLocked != null) updates.isLocked = isLocked; + isCat: $.bool.optional.note({ + desc: { + 'ja-JP': '猫か否か' + } + }), + + autoWatch: $.bool.optional.note({ + desc: { + 'ja-JP': '投稿の自動ウォッチをするか否か' + } + }), + + alwaysMarkNsfw: $.bool.optional.note({ + desc: { + 'ja-JP': 'アップロードするメディアをデフォルトで「閲覧注意」として設定するか' + } + }), + } +}; + +export default async (params: any, user: ILocalUser, app: IApp) => new Promise(async (res, rej) => { + const [ps, psErr] = getParams(meta, params); + if (psErr) throw psErr; - // Get 'isBot' parameter - const [isBot, isBotErr] = $.bool.optional.get(params.isBot); - if (isBotErr) return rej('invalid isBot param'); - if (isBot != null) updates.isBot = isBot; + const isSecure = user != null && app == null; - // Get 'isCat' parameter - const [isCat, isCatErr] = $.bool.optional.get(params.isCat); - if (isCatErr) return rej('invalid isCat param'); - if (isCat != null) updates.isCat = isCat; + const updates = {} as any; - // Get 'autoWatch' parameter - const [autoWatch, autoWatchErr] = $.bool.optional.get(params.autoWatch); - if (autoWatchErr) return rej('invalid autoWatch param'); - if (autoWatch != null) updates['settings.autoWatch'] = autoWatch; + if (ps.name !== undefined) updates.name = ps.name; + if (ps.description !== undefined) updates.description = ps.description; + if (ps.location !== undefined) updates['profile.location'] = ps.location; + if (ps.birthday !== undefined) updates['profile.birthday'] = ps.birthday; + if (ps.avatarId !== undefined) updates.avatarId = ps.avatarId; + if (ps.bannerId !== undefined) updates.bannerId = ps.bannerId; + if (ps.wallpaperId !== undefined) updates.wallpaperId = ps.wallpaperId; + if (typeof ps.isLocked == 'boolean') updates.isLocked = ps.isLocked; + if (typeof ps.isBot == 'boolean') updates.isBot = ps.isBot; + if (typeof ps.isCat == 'boolean') updates.isCat = ps.isCat; + if (typeof ps.autoWatch == 'boolean') updates['settings.autoWatch'] = ps.autoWatch; + if (typeof ps.alwaysMarkNsfw == 'boolean') updates['settings.alwaysMarkNsfw'] = ps.alwaysMarkNsfw; - if (avatarId) { + if (ps.avatarId) { const avatar = await DriveFile.findOne({ - _id: avatarId + _id: ps.avatarId }); if (avatar == null) return rej('avatar not found'); + if (!avatar.contentType.startsWith('image/')) return rej('avatar not an image'); updates.avatarUrl = avatar.metadata.thumbnailUrl || avatar.metadata.url || `${config.drive_url}/${avatar._id}`; @@ -91,12 +129,13 @@ export default async (params: any, user: ILocalUser, app: IApp) => new Promise(a } } - if (bannerId) { + if (ps.bannerId) { const banner = await DriveFile.findOne({ - _id: bannerId + _id: ps.bannerId }); if (banner == null) return rej('banner not found'); + if (!banner.contentType.startsWith('image/')) return rej('banner not an image'); updates.bannerUrl = banner.metadata.url || `${config.drive_url}/${banner._id}`; @@ -105,13 +144,13 @@ export default async (params: any, user: ILocalUser, app: IApp) => new Promise(a } } - if (wallpaperId !== undefined) { - if (wallpaperId === null) { + if (ps.wallpaperId !== undefined) { + if (ps.wallpaperId === null) { updates.wallpaperUrl = null; updates.wallpaperColor = null; } else { const wallpaper = await DriveFile.findOne({ - _id: wallpaperId + _id: ps.wallpaperId }); if (wallpaper == null) return rej('wallpaper not found'); @@ -138,10 +177,13 @@ export default async (params: any, user: ILocalUser, app: IApp) => new Promise(a res(iObj); // Publish meUpdated event - publishUserStream(user._id, 'meUpdated', iObj); + publishMainStream(user._id, 'meUpdated', iObj); // 鍵垢を解除したとき、溜まっていたフォローリクエストがあるならすべて承認 - if (user.isLocked && isLocked === false) { + if (user.isLocked && ps.isLocked === false) { acceptAllFollowRequests(user); } + + // フォロワーにUpdateを配信 + publishToFollowers(user._id); }); diff --git a/src/server/api/endpoints/i/update_client_setting.ts b/src/server/api/endpoints/i/update_client_setting.ts index aed93c792f..2c05299dff 100644 --- a/src/server/api/endpoints/i/update_client_setting.ts +++ b/src/server/api/endpoints/i/update_client_setting.ts @@ -1,6 +1,6 @@ import $ from 'cafy'; import User, { ILocalUser } from '../../../../models/user'; -import { publishUserStream } from '../../../../stream'; +import { publishMainStream } from '../../../../stream'; export const meta = { requireCredential: true, @@ -26,7 +26,7 @@ export default async (params: any, user: ILocalUser) => new Promise(async (res, res(); // Publish event - publishUserStream(user._id, 'clientSettingUpdated', { + publishMainStream(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 ffca9b90b3..27afc9fe5a 100644 --- a/src/server/api/endpoints/i/update_home.ts +++ b/src/server/api/endpoints/i/update_home.ts @@ -1,6 +1,6 @@ import $ from 'cafy'; import User, { ILocalUser } from '../../../../models/user'; -import { publishUserStream } from '../../../../stream'; +import { publishMainStream } from '../../../../stream'; export const meta = { requireCredential: true, @@ -25,5 +25,5 @@ export default async (params: any, user: ILocalUser) => new Promise(async (res, res(); - publishUserStream(user._id, 'home_updated', home); + publishMainStream(user._id, 'homeUpdated', home); }); diff --git a/src/server/api/endpoints/i/update_mobile_home.ts b/src/server/api/endpoints/i/update_mobile_home.ts index 0b72fbe2c1..1d4df389e4 100644 --- a/src/server/api/endpoints/i/update_mobile_home.ts +++ b/src/server/api/endpoints/i/update_mobile_home.ts @@ -1,6 +1,6 @@ import $ from 'cafy'; import User, { ILocalUser } from '../../../../models/user'; -import { publishUserStream } from '../../../../stream'; +import { publishMainStream } from '../../../../stream'; export const meta = { requireCredential: true, @@ -24,5 +24,5 @@ export default async (params: any, user: ILocalUser) => new Promise(async (res, res(); - publishUserStream(user._id, 'mobile_home_updated', home); + publishMainStream(user._id, 'mobileHomeUpdated', home); }); diff --git a/src/server/api/endpoints/i/update_widget.ts b/src/server/api/endpoints/i/update_widget.ts index 5cbe7c07a3..92499493eb 100644 --- a/src/server/api/endpoints/i/update_widget.ts +++ b/src/server/api/endpoints/i/update_widget.ts @@ -1,6 +1,6 @@ import $ from 'cafy'; import User, { ILocalUser } from '../../../../models/user'; -import { publishUserStream } from '../../../../stream'; +import { publishMainStream } from '../../../../stream'; export const meta = { requireCredential: true, @@ -73,7 +73,7 @@ export default async (params: any, user: ILocalUser) => new Promise(async (res, //#endregion if (widget) { - publishUserStream(user._id, 'widgetUpdated', { + publishMainStream(user._id, 'widgetUpdated', { id, data }); diff --git a/src/server/api/endpoints/messaging/messages/create.ts b/src/server/api/endpoints/messaging/messages/create.ts index a6fabcfa45..cb115cf987 100644 --- a/src/server/api/endpoints/messaging/messages/create.ts +++ b/src/server/api/endpoints/messaging/messages/create.ts @@ -6,7 +6,7 @@ import User, { ILocalUser } from '../../../../../models/user'; import Mute from '../../../../../models/mute'; import DriveFile from '../../../../../models/drive-file'; import { pack } from '../../../../../models/messaging-message'; -import { publishUserStream } from '../../../../../stream'; +import { publishMainStream } from '../../../../../stream'; import { publishMessagingStream, publishMessagingIndexStream } from '../../../../../stream'; import pushSw from '../../../../../push-sw'; @@ -74,7 +74,7 @@ export default (params: any, user: ILocalUser) => new Promise(async (res, rej) = createdAt: new Date(), fileId: file ? file._id : undefined, recipientId: recipient._id, - text: text ? text : undefined, + text: text ? text.trim() : undefined, userId: user._id, isRead: false }); @@ -88,12 +88,12 @@ export default (params: any, user: ILocalUser) => new Promise(async (res, rej) = // 自分のストリーム publishMessagingStream(message.userId, message.recipientId, 'message', messageObj); publishMessagingIndexStream(message.userId, 'message', messageObj); - publishUserStream(message.userId, 'messaging_message', messageObj); + publishMainStream(message.userId, 'messagingMessage', messageObj); // 相手のストリーム publishMessagingStream(message.recipientId, message.userId, 'message', messageObj); publishMessagingIndexStream(message.recipientId, 'message', messageObj); - publishUserStream(message.recipientId, 'messaging_message', messageObj); + publishMainStream(message.recipientId, 'messagingMessage', messageObj); // Update flag User.update({ _id: recipient._id }, { @@ -102,7 +102,7 @@ export default (params: any, user: ILocalUser) => new Promise(async (res, rej) = } }); - // 3秒経っても(今回作成した)メッセージが既読にならなかったら「未読のメッセージがありますよ」イベントを発行する + // 2秒経っても(今回作成した)メッセージが既読にならなかったら「未読のメッセージがありますよ」イベントを発行する setTimeout(async () => { const freshMessage = await Message.findOne({ _id: message._id }, { isRead: true }); if (!freshMessage.isRead) { @@ -117,10 +117,10 @@ export default (params: any, user: ILocalUser) => new Promise(async (res, rej) = } //#endregion - publishUserStream(message.recipientId, 'unread_messaging_message', messageObj); - pushSw(message.recipientId, 'unread_messaging_message', messageObj); + publishMainStream(message.recipientId, 'unreadMessagingMessage', messageObj); + pushSw(message.recipientId, 'unreadMessagingMessage', messageObj); } - }, 3000); + }, 2000); // 履歴作成(自分) History.update({ diff --git a/src/server/api/endpoints/meta.ts b/src/server/api/endpoints/meta.ts index 2b39f26b8e..c76d7f2e8f 100644 --- a/src/server/api/endpoints/meta.ts +++ b/src/server/api/endpoints/meta.ts @@ -4,6 +4,7 @@ import * as os from 'os'; import config from '../../../config'; import Meta from '../../../models/meta'; +import { ILocalUser } from '../../../models/user'; const pkg = require('../../../../package.json'); const client = require('../../../../built/client/meta.json'); @@ -11,7 +12,7 @@ const client = require('../../../../built/client/meta.json'); /** * Show core info */ -export default () => new Promise(async (res, rej) => { +export default (params: any, me: ILocalUser) => new Promise(async (res, rej) => { const meta: any = (await Meta.findOne()) || {}; res({ @@ -31,9 +32,22 @@ export default () => new Promise(async (res, rej) => { model: os.cpus()[0].model, cores: os.cpus().length }, - broadcasts: meta.broadcasts, + broadcasts: meta.broadcasts || [], disableRegistration: meta.disableRegistration, + disableLocalTimeline: meta.disableLocalTimeline, + driveCapacityPerLocalUserMb: config.localDriveCapacityMb, recaptchaSitekey: config.recaptcha ? config.recaptcha.site_key : null, - swPublickey: config.sw ? config.sw.public_key : null + swPublickey: config.sw ? config.sw.public_key : null, + hidedTags: (me && me.isAdmin) ? meta.hidedTags : undefined, + bannerUrl: meta.bannerUrl, + features: { + registration: !meta.disableRegistration, + localTimeLine: !meta.disableLocalTimeline, + elasticsearch: config.elasticsearch ? true : false, + recaptcha: config.recaptcha ? true : false, + objectStorage: config.drive && config.drive.storage === 'minio', + twitter: config.twitter ? true : false, + serviceWorker: config.sw ? true : false + } }); }); diff --git a/src/server/api/endpoints/notes.ts b/src/server/api/endpoints/notes.ts index 029bc1a95e..d65710d33f 100644 --- a/src/server/api/endpoints/notes.ts +++ b/src/server/api/endpoints/notes.ts @@ -1,51 +1,65 @@ -/** - * Module dependencies - */ import $ from 'cafy'; import ID from '../../../misc/cafy-id'; -import Note, { pack } from '../../../models/note'; +import Note, { packMany } from '../../../models/note'; +import getParams from '../get-params'; -/** - * Get all notes - */ -export default (params: any) => new Promise(async (res, rej) => { - // Get 'local' parameter - const [local, localErr] = $.bool.optional.get(params.local); - if (localErr) return rej('invalid local param'); +export const meta = { + desc: { + 'ja-JP': '投稿を取得します。' + }, + + params: { + local: $.bool.optional.note({ + desc: { + 'ja-JP': 'ローカルの投稿に限定するか否か' + } + }), + + reply: $.bool.optional.note({ + desc: { + 'ja-JP': '返信に限定するか否か' + } + }), - // Get 'reply' parameter - const [reply, replyErr] = $.bool.optional.get(params.reply); - if (replyErr) return rej('invalid reply param'); + renote: $.bool.optional.note({ + desc: { + 'ja-JP': 'Renoteに限定するか否か' + } + }), - // Get 'renote' parameter - const [renote, renoteErr] = $.bool.optional.get(params.renote); - if (renoteErr) return rej('invalid renote param'); + withFiles: $.bool.optional.note({ + desc: { + 'ja-JP': 'ファイルが添付された投稿に限定するか否か' + } + }), - // Get 'media' parameter - const [media, mediaErr] = $.bool.optional.get(params.media); - if (mediaErr) return rej('invalid media param'); + media: $.bool.optional.note({ + desc: { + 'ja-JP': 'ファイルが添付された投稿に限定するか否か (このパラメータは廃止予定です。代わりに withFiles を使ってください。)' + } + }), - // Get 'poll' parameter - const [poll, pollErr] = $.bool.optional.get(params.poll); - if (pollErr) return rej('invalid poll param'); + poll: $.bool.optional.note({ + desc: { + 'ja-JP': 'アンケートが添付された投稿に限定するか否か' + } + }), - // Get 'bot' parameter - //const [bot, botErr] = $.bool.optional.get(params.bot); - //if (botErr) return rej('invalid bot param'); + limit: $.num.optional.range(1, 100).note({ + default: 10 + }), - // Get 'limit' parameter - const [limit = 10, limitErr] = $.num.optional.range(1, 100).get(params.limit); - if (limitErr) return rej('invalid limit param'); + sinceId: $.type(ID).optional.note({}), - // Get 'sinceId' parameter - const [sinceId, sinceIdErr] = $.type(ID).optional.get(params.sinceId); - if (sinceIdErr) return rej('invalid sinceId param'); + untilId: $.type(ID).optional.note({}), + } +}; - // Get 'untilId' parameter - const [untilId, untilIdErr] = $.type(ID).optional.get(params.untilId); - if (untilIdErr) return rej('invalid untilId param'); +export default (params: any) => new Promise(async (res, rej) => { + const [ps, psErr] = getParams(meta, params); + if (psErr) throw psErr; // Check if both of sinceId and untilId is specified - if (sinceId && untilId) { + if (ps.sinceId && ps.untilId) { return rej('cannot set sinceId and untilId'); } @@ -56,35 +70,37 @@ export default (params: any) => new Promise(async (res, rej) => { const query = { visibility: 'public' } as any; - if (sinceId) { + if (ps.sinceId) { sort._id = 1; query._id = { - $gt: sinceId + $gt: ps.sinceId }; - } else if (untilId) { + } else if (ps.untilId) { query._id = { - $lt: untilId + $lt: ps.untilId }; } - if (local) { + if (ps.local) { query['_user.host'] = null; } - if (reply != undefined) { - query.replyId = reply ? { $exists: true, $ne: null } : null; + if (ps.reply != undefined) { + query.replyId = ps.reply ? { $exists: true, $ne: null } : null; } - if (renote != undefined) { - query.renoteId = renote ? { $exists: true, $ne: null } : null; + if (ps.renote != undefined) { + query.renoteId = ps.renote ? { $exists: true, $ne: null } : null; } - if (media != undefined) { - query.mediaIds = media ? { $exists: true, $ne: null } : []; + const withFiles = ps.withFiles != undefined ? ps.withFiles : ps.media; + + if (withFiles) { + query.fileIds = withFiles ? { $exists: true, $ne: null } : []; } - if (poll != undefined) { - query.poll = poll ? { $exists: true, $ne: null } : null; + if (ps.poll != undefined) { + query.poll = ps.poll ? { $exists: true, $ne: null } : null; } // TODO @@ -95,10 +111,10 @@ export default (params: any) => new Promise(async (res, rej) => { // Issue query const notes = await Note .find(query, { - limit: limit, + limit: ps.limit, sort: sort }); // Serialize - res(await Promise.all(notes.map(note => pack(note)))); + res(await packMany(notes)); }); diff --git a/src/server/api/endpoints/notes/conversation.ts b/src/server/api/endpoints/notes/conversation.ts index 2782d14155..0c23f9e5fc 100644 --- a/src/server/api/endpoints/notes/conversation.ts +++ b/src/server/api/endpoints/notes/conversation.ts @@ -1,5 +1,5 @@ import $ from 'cafy'; import ID from '../../../../misc/cafy-id'; -import Note, { pack, INote } from '../../../../models/note'; +import Note, { packMany, INote } from '../../../../models/note'; import { ILocalUser } from '../../../../models/user'; /** @@ -52,5 +52,5 @@ export default (params: any, user: ILocalUser) => new Promise(async (res, rej) = } // Serialize - res(await Promise.all(conversation.map(note => pack(note, user)))); + res(await packMany(conversation, user)); }); diff --git a/src/server/api/endpoints/notes/create.ts b/src/server/api/endpoints/notes/create.ts index 04f5f7562e..96745132a3 100644 --- a/src/server/api/endpoints/notes/create.ts +++ b/src/server/api/endpoints/notes/create.ts @@ -71,9 +71,15 @@ export const meta = { ref: 'geo' }), + fileIds: $.arr($.type(ID)).optional.unique().range(1, 4).note({ + desc: { + 'ja-JP': '添付するファイル' + } + }), + mediaIds: $.arr($.type(ID)).optional.unique().range(1, 4).note({ desc: { - 'ja-JP': '添付するメディア' + 'ja-JP': '添付するファイル (このパラメータは廃止予定です。代わりに fileIds を使ってください。)' } }), @@ -124,26 +130,16 @@ export default (params: any, user: ILocalUser, app: IApp) => new Promise(async ( } let files: IDriveFile[] = []; - if (ps.mediaIds !== undefined) { - // Fetch files - // forEach だと途中でエラーなどがあっても return できないので - // 敢えて for を使っています。 - for (const mediaId of ps.mediaIds) { - // Fetch file - // SELECT _id - const entity = await DriveFile.findOne({ - _id: mediaId, + const fileIds = ps.fileIds != null ? ps.fileIds : ps.mediaIds != null ? ps.mediaIds : null; + if (fileIds != null) { + files = await Promise.all(fileIds.map(fileId => { + return DriveFile.findOne({ + _id: fileId, 'metadata.userId': user._id }); + })); - if (entity === null) { - return rej('file not found'); - } else { - files.push(entity); - } - } - } else { - files = null; + files = files.filter(file => file != null); } let renote: INote = null; @@ -155,7 +151,7 @@ export default (params: any, user: ILocalUser, app: IApp) => new Promise(async ( if (renote == null) { return rej('renoteee is not found'); - } else if (renote.renoteId && !renote.text && !renote.mediaIds) { + } else if (renote.renoteId && !renote.text && !renote.fileIds) { return rej('cannot renote to renote'); } } @@ -176,7 +172,7 @@ export default (params: any, user: ILocalUser, app: IApp) => new Promise(async ( } // 返信対象が引用でないRenoteだったらエラー - if (reply.renoteId && !reply.text && !reply.mediaIds) { + if (reply.renoteId && !reply.text && !reply.fileIds) { return rej('cannot reply to renote'); } } @@ -191,13 +187,13 @@ export default (params: any, user: ILocalUser, app: IApp) => new Promise(async ( // テキストが無いかつ添付ファイルが無いかつRenoteも無いかつ投票も無かったらエラー if ((ps.text === undefined || ps.text === null) && files === null && renote === null && ps.poll === undefined) { - return rej('text, mediaIds, renoteId or poll is required'); + return rej('text, fileIds, renoteId or poll is required'); } // 投稿を作成 const note = await create(user, { createdAt: new Date(), - media: files, + files: files, poll: ps.poll, text: ps.text, reply, diff --git a/src/server/api/endpoints/notes/delete.ts b/src/server/api/endpoints/notes/delete.ts index 6d9826cf7b..2fe36897c0 100644 --- a/src/server/api/endpoints/notes/delete.ts +++ b/src/server/api/endpoints/notes/delete.ts @@ -1,7 +1,7 @@ import $ from 'cafy'; import ID from '../../../../misc/cafy-id'; import Note from '../../../../models/note'; import deleteNote from '../../../../services/note/delete'; -import { ILocalUser } from '../../../../models/user'; +import User, { ILocalUser } from '../../../../models/user'; export const meta = { desc: { @@ -21,15 +21,18 @@ export default (params: any, user: ILocalUser) => new Promise(async (res, rej) = // Fetch note const note = await Note.findOne({ - _id: noteId, - userId: user._id + _id: noteId }); if (note === null) { return rej('note not found'); } - await deleteNote(user, note); + if (!user.isAdmin && !note.userId.equals(user._id)) { + return rej('access denied'); + } + + await deleteNote(await User.findOne({ _id: note.userId }), note); res(); }); diff --git a/src/server/api/endpoints/notes/favorites/create.ts b/src/server/api/endpoints/notes/favorites/create.ts index daf7780abc..9aefb701ae 100644 --- a/src/server/api/endpoints/notes/favorites/create.ts +++ b/src/server/api/endpoints/notes/favorites/create.ts @@ -2,6 +2,7 @@ import $ from 'cafy'; import ID from '../../../../../misc/cafy-id'; import Favorite from '../../../../../models/favorite'; import Note from '../../../../../models/note'; import { ILocalUser } from '../../../../../models/user'; +import getParams from '../../../get-params'; export const meta = { desc: { @@ -11,17 +12,24 @@ export const meta = { requireCredential: true, - kind: 'favorite-write' + kind: 'favorite-write', + + params: { + noteId: $.type(ID).note({ + desc: { + 'ja-JP': '対象の投稿のID' + } + }) + } }; export default (params: any, user: ILocalUser) => new Promise(async (res, rej) => { - // Get 'noteId' parameter - const [noteId, noteIdErr] = $.type(ID).get(params.noteId); - if (noteIdErr) return rej('invalid noteId param'); + const [ps, psErr] = getParams(meta, params); + if (psErr) return rej(psErr); // Get favoritee const note = await Note.findOne({ - _id: noteId + _id: ps.noteId }); if (note === null) { diff --git a/src/server/api/endpoints/notes/global-timeline.ts b/src/server/api/endpoints/notes/global-timeline.ts index 8f7233e308..8362143bb2 100644 --- a/src/server/api/endpoints/notes/global-timeline.ts +++ b/src/server/api/endpoints/notes/global-timeline.ts @@ -1,42 +1,52 @@ import $ from 'cafy'; import ID from '../../../../misc/cafy-id'; import Note from '../../../../models/note'; import Mute from '../../../../models/mute'; -import { pack } from '../../../../models/note'; +import { packMany } from '../../../../models/note'; import { ILocalUser } from '../../../../models/user'; +import getParams from '../../get-params'; +import { countIf } from '../../../../prelude/array'; -/** - * Get timeline of global - */ -export default async (params: any, user: ILocalUser) => { - // Get 'limit' parameter - const [limit = 10, limitErr] = $.num.optional.range(1, 100).get(params.limit); - if (limitErr) throw 'invalid limit param'; +export const meta = { + desc: { + 'ja-JP': 'グローバルタイムラインを取得します。' + }, + + params: { + withFiles: $.bool.optional.note({ + desc: { + 'ja-JP': 'ファイルが添付された投稿に限定するか否か' + } + }), + + mediaOnly: $.bool.optional.note({ + desc: { + 'ja-JP': 'ファイルが添付された投稿に限定するか否か (このパラメータは廃止予定です。代わりに withFiles を使ってください。)' + } + }), + + limit: $.num.optional.range(1, 100).note({ + default: 10 + }), + + sinceId: $.type(ID).optional.note({}), - // Get 'sinceId' parameter - const [sinceId, sinceIdErr] = $.type(ID).optional.get(params.sinceId); - if (sinceIdErr) throw 'invalid sinceId param'; + untilId: $.type(ID).optional.note({}), - // Get 'untilId' parameter - const [untilId, untilIdErr] = $.type(ID).optional.get(params.untilId); - if (untilIdErr) throw 'invalid untilId param'; + sinceDate: $.num.optional.note({}), - // Get 'sinceDate' parameter - const [sinceDate, sinceDateErr] = $.num.optional.get(params.sinceDate); - if (sinceDateErr) throw 'invalid sinceDate param'; + untilDate: $.num.optional.note({}), + } +}; - // Get 'untilDate' parameter - const [untilDate, untilDateErr] = $.num.optional.get(params.untilDate); - if (untilDateErr) throw 'invalid untilDate param'; +export default async (params: any, user: ILocalUser) => { + const [ps, psErr] = getParams(meta, params); + if (psErr) throw psErr; // Check if only one of sinceId, untilId, sinceDate, untilDate specified - if ([sinceId, untilId, sinceDate, untilDate].filter(x => x != null).length > 1) { + if (countIf(x => x != null, [ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate]) > 1) { throw 'only one of sinceId, untilId, sinceDate, untilDate can be specified'; } - // Get 'mediaOnly' parameter - const [mediaOnly, mediaOnlyErr] = $.bool.optional.get(params.mediaOnly); - if (mediaOnlyErr) throw 'invalid mediaOnly param'; - // ミュートしているユーザーを取得 const mutedUserIds = user ? (await Mute.find({ muterId: user._id @@ -68,27 +78,29 @@ export default async (params: any, user: ILocalUser) => { }; } - if (mediaOnly) { - query.mediaIds = { $exists: true, $ne: [] }; + const withFiles = ps.withFiles != null ? ps.withFiles : ps.mediaOnly; + + if (withFiles) { + query.fileIds = { $exists: true, $ne: [] }; } - if (sinceId) { + if (ps.sinceId) { sort._id = 1; query._id = { - $gt: sinceId + $gt: ps.sinceId }; - } else if (untilId) { + } else if (ps.untilId) { query._id = { - $lt: untilId + $lt: ps.untilId }; - } else if (sinceDate) { + } else if (ps.sinceDate) { sort._id = 1; query.createdAt = { - $gt: new Date(sinceDate) + $gt: new Date(ps.sinceDate) }; - } else if (untilDate) { + } else if (ps.untilDate) { query.createdAt = { - $lt: new Date(untilDate) + $lt: new Date(ps.untilDate) }; } //#endregion @@ -96,10 +108,10 @@ export default async (params: any, user: ILocalUser) => { // Issue query const timeline = await Note .find(query, { - limit: limit, + limit: ps.limit, sort: sort }); // Serialize - return await Promise.all(timeline.map(note => pack(note, user))); + return await packMany(timeline, user); }; diff --git a/src/server/api/endpoints/notes/hybrid-timeline.ts b/src/server/api/endpoints/notes/hybrid-timeline.ts index 2dbb1190c1..14b4432b33 100644 --- a/src/server/api/endpoints/notes/hybrid-timeline.ts +++ b/src/server/api/endpoints/notes/hybrid-timeline.ts @@ -2,13 +2,12 @@ import $ from 'cafy'; import ID from '../../../../misc/cafy-id'; import Note from '../../../../models/note'; import Mute from '../../../../models/mute'; import { getFriends } from '../../common/get-friends'; -import { pack } from '../../../../models/note'; +import { packMany } from '../../../../models/note'; import { ILocalUser } from '../../../../models/user'; import getParams from '../../get-params'; +import { countIf } from '../../../../prelude/array'; export const meta = { - name: 'notes/hybrid-timeline', - desc: { 'ja-JP': 'ハイブリッドタイムラインを取得します。' }, @@ -66,23 +65,26 @@ export const meta = { } }), + withFiles: $.bool.optional.note({ + desc: { + 'ja-JP': 'true にすると、ファイルが添付された投稿だけ取得します' + } + }), + mediaOnly: $.bool.optional.note({ desc: { - 'ja-JP': 'true にすると、メディアが添付された投稿だけ取得します' + 'ja-JP': 'true にすると、ファイルが添付された投稿だけ取得します (このパラメータは廃止予定です。代わりに withFiles を使ってください。)' } }), } }; -/** - * Get hybrid timeline of myself - */ export default async (params: any, user: ILocalUser) => { const [ps, psErr] = getParams(meta, params); if (psErr) throw psErr; // Check if only one of sinceId, untilId, sinceDate, untilDate specified - if ([ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate].filter(x => x != null).length > 1) { + if (countIf(x => x != null, [ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate]) > 1) { throw 'only one of sinceId, untilId, sinceDate, untilDate can be specified'; } @@ -164,7 +166,7 @@ export default async (params: any, user: ILocalUser) => { }, { text: { $ne: null } }, { - mediaIds: { $ne: [] } + fileIds: { $ne: [] } }, { poll: { $ne: null } }] @@ -180,7 +182,7 @@ export default async (params: any, user: ILocalUser) => { }, { text: { $ne: null } }, { - mediaIds: { $ne: [] } + fileIds: { $ne: [] } }, { poll: { $ne: null } }] @@ -196,16 +198,16 @@ export default async (params: any, user: ILocalUser) => { }, { text: { $ne: null } }, { - mediaIds: { $ne: [] } + fileIds: { $ne: [] } }, { poll: { $ne: null } }] }); } - if (ps.mediaOnly) { + if (ps.withFiles || ps.mediaOnly) { query.$and.push({ - mediaIds: { $exists: true, $ne: [] } + fileIds: { $exists: true, $ne: [] } }); } @@ -238,5 +240,5 @@ export default async (params: any, user: ILocalUser) => { }); // Serialize - return await Promise.all(timeline.map(note => pack(note, user))); + return await packMany(timeline, user); }; diff --git a/src/server/api/endpoints/notes/local-timeline.ts b/src/server/api/endpoints/notes/local-timeline.ts index bbcc6303ca..8ab07d8ea7 100644 --- a/src/server/api/endpoints/notes/local-timeline.ts +++ b/src/server/api/endpoints/notes/local-timeline.ts @@ -1,42 +1,65 @@ import $ from 'cafy'; import ID from '../../../../misc/cafy-id'; import Note from '../../../../models/note'; import Mute from '../../../../models/mute'; -import { pack } from '../../../../models/note'; +import { packMany } from '../../../../models/note'; import { ILocalUser } from '../../../../models/user'; +import getParams from '../../get-params'; +import { countIf } from '../../../../prelude/array'; -/** - * Get timeline of local - */ -export default async (params: any, user: ILocalUser) => { - // Get 'limit' parameter - const [limit = 10, limitErr] = $.num.optional.range(1, 100).get(params.limit); - if (limitErr) throw 'invalid limit param'; +export const meta = { + desc: { + 'ja-JP': 'ローカルタイムラインを取得します。' + }, + + params: { + withFiles: $.bool.optional.note({ + desc: { + 'ja-JP': 'ファイルが添付された投稿に限定するか否か' + } + }), + + mediaOnly: $.bool.optional.note({ + desc: { + 'ja-JP': 'ファイルが添付された投稿に限定するか否か (このパラメータは廃止予定です。代わりに withFiles を使ってください。)' + } + }), + + fileType: $.arr($.str).optional.note({ + desc: { + 'ja-JP': '指定された種類のファイルが添付された投稿のみを取得します' + } + }), + + excludeNsfw: $.bool.optional.note({ + default: false, + desc: { + 'ja-JP': 'true にすると、NSFW指定されたファイルを除外します(fileTypeが指定されている場合のみ有効)' + } + }), - // Get 'sinceId' parameter - const [sinceId, sinceIdErr] = $.type(ID).optional.get(params.sinceId); - if (sinceIdErr) throw 'invalid sinceId param'; + limit: $.num.optional.range(1, 100).note({ + default: 10 + }), - // Get 'untilId' parameter - const [untilId, untilIdErr] = $.type(ID).optional.get(params.untilId); - if (untilIdErr) throw 'invalid untilId param'; + sinceId: $.type(ID).optional.note({}), - // Get 'sinceDate' parameter - const [sinceDate, sinceDateErr] = $.num.optional.get(params.sinceDate); - if (sinceDateErr) throw 'invalid sinceDate param'; + untilId: $.type(ID).optional.note({}), - // Get 'untilDate' parameter - const [untilDate, untilDateErr] = $.num.optional.get(params.untilDate); - if (untilDateErr) throw 'invalid untilDate param'; + sinceDate: $.num.optional.note({}), + + untilDate: $.num.optional.note({}), + } +}; + +export default async (params: any, user: ILocalUser) => { + const [ps, psErr] = getParams(meta, params); + if (psErr) throw psErr; // Check if only one of sinceId, untilId, sinceDate, untilDate specified - if ([sinceId, untilId, sinceDate, untilDate].filter(x => x != null).length > 1) { + if (countIf(x => x != null, [ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate]) > 1) { throw 'only one of sinceId, untilId, sinceDate, untilDate can be specified'; } - // Get 'mediaOnly' parameter - const [mediaOnly, mediaOnlyErr] = $.bool.optional.get(params.mediaOnly); - if (mediaOnlyErr) throw 'invalid mediaOnly param'; - // ミュートしているユーザーを取得 const mutedUserIds = user ? (await Mute.find({ muterId: user._id @@ -69,27 +92,43 @@ export default async (params: any, user: ILocalUser) => { }; } - if (mediaOnly) { - query.mediaIds = { $exists: true, $ne: [] }; + const withFiles = ps.withFiles != null ? ps.withFiles : ps.mediaOnly; + + if (withFiles) { + query.fileIds = { $exists: true, $ne: [] }; + } + + if (ps.fileType) { + query.fileIds = { $exists: true, $ne: [] }; + + query['_files.contentType'] = { + $in: ps.fileType + }; + + if (ps.excludeNsfw) { + query['_files.metadata.isSensitive'] = { + $ne: true + }; + } } - if (sinceId) { + if (ps.sinceId) { sort._id = 1; query._id = { - $gt: sinceId + $gt: ps.sinceId }; - } else if (untilId) { + } else if (ps.untilId) { query._id = { - $lt: untilId + $lt: ps.untilId }; - } else if (sinceDate) { + } else if (ps.sinceDate) { sort._id = 1; query.createdAt = { - $gt: new Date(sinceDate) + $gt: new Date(ps.sinceDate) }; - } else if (untilDate) { + } else if (ps.untilDate) { query.createdAt = { - $lt: new Date(untilDate) + $lt: new Date(ps.untilDate) }; } //#endregion @@ -97,10 +136,10 @@ export default async (params: any, user: ILocalUser) => { // Issue query const timeline = await Note .find(query, { - limit: limit, + limit: ps.limit, sort: sort }); // Serialize - return await Promise.all(timeline.map(note => pack(note, user))); + return await packMany(timeline, user); }; diff --git a/src/server/api/endpoints/notes/mentions.ts b/src/server/api/endpoints/notes/mentions.ts index a7fb14d8a9..592a94263d 100644 --- a/src/server/api/endpoints/notes/mentions.ts +++ b/src/server/api/endpoints/notes/mentions.ts @@ -1,8 +1,10 @@ import $ from 'cafy'; import ID from '../../../../misc/cafy-id'; import Note from '../../../../models/note'; import { getFriendIds } from '../../common/get-friends'; -import { pack } from '../../../../models/note'; +import { packMany } from '../../../../models/note'; import { ILocalUser } from '../../../../models/user'; +import getParams from '../../get-params'; +import read from '../../../../services/note/read'; export const meta = { desc: { @@ -10,42 +12,55 @@ export const meta = { 'en-US': 'Get mentions of myself.' }, - requireCredential: true -}; + requireCredential: true, -export default (params: any, user: ILocalUser) => new Promise(async (res, rej) => { - // Get 'following' parameter - const [following = false, followingError] = - $.bool.optional.get(params.following); - if (followingError) return rej('invalid following param'); + params: { + following: $.bool.optional.note({ + default: false + }), + + limit: $.num.optional.range(1, 100).note({ + default: 10 + }), - // Get 'limit' parameter - const [limit = 10, limitErr] = $.num.optional.range(1, 100).get(params.limit); - if (limitErr) return rej('invalid limit param'); + sinceId: $.type(ID).optional.note({ + }), - // Get 'sinceId' parameter - const [sinceId, sinceIdErr] = $.type(ID).optional.get(params.sinceId); - if (sinceIdErr) return rej('invalid sinceId param'); + untilId: $.type(ID).optional.note({ + }), + + visibility: $.str.optional.note({ + }), + } +}; - // Get 'untilId' parameter - const [untilId, untilIdErr] = $.type(ID).optional.get(params.untilId); - if (untilIdErr) return rej('invalid untilId param'); +export default (params: any, user: ILocalUser) => new Promise(async (res, rej) => { + const [ps, psErr] = getParams(meta, params); + if (psErr) throw psErr; // Check if both of sinceId and untilId is specified - if (sinceId && untilId) { + if (ps.sinceId && ps.untilId) { return rej('cannot set sinceId and untilId'); } // Construct query const query = { - mentions: user._id + $or: [{ + mentions: user._id + }, { + visibleUserIds: user._id + }] } as any; const sort = { _id: -1 }; - if (following) { + if (ps.visibility) { + query.visibility = ps.visibility; + } + + if (ps.following) { const followingIds = await getFriendIds(user._id); query.userId = { @@ -53,26 +68,26 @@ export default (params: any, user: ILocalUser) => new Promise(async (res, rej) = }; } - if (sinceId) { + if (ps.sinceId) { sort._id = 1; query._id = { - $gt: sinceId + $gt: ps.sinceId }; - } else if (untilId) { + } else if (ps.untilId) { query._id = { - $lt: untilId + $lt: ps.untilId }; } // Issue query const mentions = await Note .find(query, { - limit: limit, + limit: ps.limit, sort: sort }); + mentions.forEach(note => read(user._id, note._id)); + // Serialize - res(await Promise.all(mentions.map(async mention => - await pack(mention, user) - ))); + res(await packMany(mentions, user)); }); diff --git a/src/server/api/endpoints/notes/polls/vote.ts b/src/server/api/endpoints/notes/polls/vote.ts index ab80e7f5d0..3b78d62fd3 100644 --- a/src/server/api/endpoints/notes/polls/vote.ts +++ b/src/server/api/endpoints/notes/polls/vote.ts @@ -72,7 +72,10 @@ export default (params: any, user: ILocalUser) => new Promise(async (res, rej) = $inc: inc }); - publishNoteStream(note._id, 'poll_voted'); + publishNoteStream(note._id, 'pollVoted', { + choice: choice, + userId: user._id.toHexString() + }); // Notify notify(note.userId, user._id, 'poll_vote', { diff --git a/src/server/api/endpoints/notes/reactions/create.ts b/src/server/api/endpoints/notes/reactions/create.ts index 0781db16c5..ec68f065d8 100644 --- a/src/server/api/endpoints/notes/reactions/create.ts +++ b/src/server/api/endpoints/notes/reactions/create.ts @@ -43,6 +43,10 @@ export default (params: any, user: ILocalUser) => new Promise(async (res, rej) = return rej('note not found'); } + if (note.deletedAt != null) { + return rej('this not is already deleted'); + } + try { await create(user, note, ps.reaction); } catch (e) { diff --git a/src/server/api/endpoints/notes/replies.ts b/src/server/api/endpoints/notes/replies.ts index 44c80afc4a..b2f8f94f69 100644 --- a/src/server/api/endpoints/notes/replies.ts +++ b/src/server/api/endpoints/notes/replies.ts @@ -1,5 +1,5 @@ import $ from 'cafy'; import ID from '../../../../misc/cafy-id'; -import Note, { pack } from '../../../../models/note'; +import Note, { packMany } from '../../../../models/note'; import { ILocalUser } from '../../../../models/user'; /** @@ -30,5 +30,5 @@ export default (params: any, user: ILocalUser) => new Promise(async (res, rej) = const ids = (note._replyIds || []).slice(offset, offset + limit); // Serialize - res(await Promise.all(ids.map(id => pack(id, user)))); + res(await packMany(ids, user)); }); diff --git a/src/server/api/endpoints/notes/reposts.ts b/src/server/api/endpoints/notes/reposts.ts index 05e68302ba..2c6e1a499f 100644 --- a/src/server/api/endpoints/notes/reposts.ts +++ b/src/server/api/endpoints/notes/reposts.ts @@ -1,5 +1,5 @@ import $ from 'cafy'; import ID from '../../../../misc/cafy-id'; -import Note, { pack } from '../../../../models/note'; +import Note, { packMany } from '../../../../models/note'; import { ILocalUser } from '../../../../models/user'; /** @@ -62,6 +62,5 @@ export default (params: any, user: ILocalUser) => new Promise(async (res, rej) = }); // Serialize - res(await Promise.all(renotes.map(async note => - await pack(note, user)))); + res(await packMany(renotes, user)); }); diff --git a/src/server/api/endpoints/notes/search.ts b/src/server/api/endpoints/notes/search.ts index 9124899ad8..2755a70483 100644 --- a/src/server/api/endpoints/notes/search.ts +++ b/src/server/api/endpoints/notes/search.ts @@ -2,7 +2,7 @@ import $ from 'cafy'; import * as mongo from 'mongodb'; import Note from '../../../../models/note'; import { ILocalUser } from '../../../../models/user'; -import { pack } from '../../../../models/note'; +import { packMany } from '../../../../models/note'; import es from '../../../../db/elasticsearch'; export default (params: any, me: ILocalUser) => new Promise(async (res, rej) => { @@ -60,6 +60,6 @@ export default (params: any, me: ILocalUser) => new Promise(async (res, rej) => } }); - res(await Promise.all(notes.map(note => pack(note, me)))); + res(await packMany(notes, me)); }); }); diff --git a/src/server/api/endpoints/notes/search_by_tag.ts b/src/server/api/endpoints/notes/search_by_tag.ts index e092275fe8..d380f27f9c 100644 --- a/src/server/api/endpoints/notes/search_by_tag.ts +++ b/src/server/api/endpoints/notes/search_by_tag.ts @@ -3,120 +3,171 @@ import Note from '../../../../models/note'; import User, { ILocalUser } from '../../../../models/user'; import Mute from '../../../../models/mute'; import { getFriendIds } from '../../common/get-friends'; -import { pack } from '../../../../models/note'; +import { packMany } from '../../../../models/note'; +import getParams from '../../get-params'; +import { erase } from '../../../../prelude/array'; -/** - * Search notes by tag - */ -export default (params: any, me: ILocalUser) => new Promise(async (res, rej) => { - // Get 'tag' parameter - const [tag, tagError] = $.str.get(params.tag); - if (tagError) return rej('invalid tag param'); +export const meta = { + desc: { + 'ja-JP': '指定されたタグが付けられた投稿を取得します。' + }, - // Get 'includeUserIds' parameter - const [includeUserIds = [], includeUserIdsErr] = $.arr($.type(ID)).optional.get(params.includeUserIds); - if (includeUserIdsErr) return rej('invalid includeUserIds param'); + params: { + tag: $.str.optional.note({ + desc: { + 'ja-JP': 'タグ' + } + }), - // Get 'excludeUserIds' parameter - const [excludeUserIds = [], excludeUserIdsErr] = $.arr($.type(ID)).optional.get(params.excludeUserIds); - if (excludeUserIdsErr) return rej('invalid excludeUserIds param'); + query: $.arr($.arr($.str)).optional.note({ + desc: { + 'ja-JP': 'クエリ' + } + }), - // Get 'includeUserUsernames' parameter - const [includeUserUsernames = [], includeUserUsernamesErr] = $.arr($.str).optional.get(params.includeUserUsernames); - if (includeUserUsernamesErr) return rej('invalid includeUserUsernames param'); + includeUserIds: $.arr($.type(ID)).optional.note({ + default: [] + }), - // Get 'excludeUserUsernames' parameter - const [excludeUserUsernames = [], excludeUserUsernamesErr] = $.arr($.str).optional.get(params.excludeUserUsernames); - if (excludeUserUsernamesErr) return rej('invalid excludeUserUsernames param'); + excludeUserIds: $.arr($.type(ID)).optional.note({ + default: [] + }), - // Get 'following' parameter - const [following = null, followingErr] = $.bool.optional.nullable.get(params.following); - if (followingErr) return rej('invalid following param'); + includeUserUsernames: $.arr($.str).optional.note({ + default: [] + }), - // Get 'mute' parameter - const [mute = 'mute_all', muteErr] = $.str.optional.get(params.mute); - if (muteErr) return rej('invalid mute param'); + excludeUserUsernames: $.arr($.str).optional.note({ + default: [] + }), - // Get 'reply' parameter - const [reply = null, replyErr] = $.bool.optional.nullable.get(params.reply); - if (replyErr) return rej('invalid reply param'); + following: $.bool.optional.nullable.note({ + default: null + }), - // Get 'renote' parameter - const [renote = null, renoteErr] = $.bool.optional.nullable.get(params.renote); - if (renoteErr) return rej('invalid renote param'); + mute: $.str.optional.note({ + default: 'mute_all' + }), - // Get 'media' parameter - const [media = null, mediaErr] = $.bool.optional.nullable.get(params.media); - if (mediaErr) return rej('invalid media param'); + reply: $.bool.optional.nullable.note({ + default: null, - // Get 'poll' parameter - const [poll = null, pollErr] = $.bool.optional.nullable.get(params.poll); - if (pollErr) return rej('invalid poll param'); + desc: { + 'ja-JP': '返信に限定するか否か' + } + }), - // Get 'sinceDate' parameter - const [sinceDate, sinceDateErr] = $.num.optional.get(params.sinceDate); - if (sinceDateErr) throw 'invalid sinceDate param'; + renote: $.bool.optional.nullable.note({ + default: null, - // Get 'untilDate' parameter - const [untilDate, untilDateErr] = $.num.optional.get(params.untilDate); - if (untilDateErr) throw 'invalid untilDate param'; + desc: { + 'ja-JP': 'Renoteに限定するか否か' + } + }), - // Get 'offset' parameter - const [offset = 0, offsetErr] = $.num.optional.min(0).get(params.offset); - if (offsetErr) return rej('invalid offset param'); + withFiles: $.bool.optional.note({ + desc: { + 'ja-JP': 'true にすると、ファイルが添付された投稿だけ取得します' + } + }), - // Get 'limit' parameter - const [limit = 10, limitErr] = $.num.optional.range(1, 30).get(params.limit); - if (limitErr) return rej('invalid limit param'); + media: $.bool.optional.nullable.note({ + default: null, + + desc: { + 'ja-JP': 'ファイルが添付された投稿に限定するか否か (このパラメータは廃止予定です。代わりに withFiles を使ってください。)' + } + }), + + poll: $.bool.optional.nullable.note({ + default: null, + + desc: { + 'ja-JP': 'アンケートが添付された投稿に限定するか否か' + } + }), - if (includeUserUsernames != null) { - const ids = (await Promise.all(includeUserUsernames.map(async (username) => { + untilId: $.type(ID).optional.note({ + desc: { + 'ja-JP': '指定すると、この投稿を基点としてより古い投稿を取得します' + } + }), + + sinceDate: $.num.optional.note({ + }), + + untilDate: $.num.optional.note({ + }), + + offset: $.num.optional.min(0).note({ + default: 0 + }), + + limit: $.num.optional.range(1, 30).note({ + default: 10 + }), + } +}; + +export default (params: any, me: ILocalUser) => new Promise(async (res, rej) => { + const [ps, psErr] = getParams(meta, params); + if (psErr) throw psErr; + + if (ps.includeUserUsernames != null) { + const ids = erase(null, await Promise.all(ps.includeUserUsernames.map(async (username) => { const _user = await User.findOne({ usernameLower: username.toLowerCase() }); return _user ? _user._id : null; - }))).filter(id => id != null); + }))); - ids.forEach(id => includeUserIds.push(id)); + ids.forEach(id => ps.includeUserIds.push(id)); } - if (excludeUserUsernames != null) { - const ids = (await Promise.all(excludeUserUsernames.map(async (username) => { + if (ps.excludeUserUsernames != null) { + const ids = erase(null, await Promise.all(ps.excludeUserUsernames.map(async (username) => { const _user = await User.findOne({ usernameLower: username.toLowerCase() }); return _user ? _user._id : null; - }))).filter(id => id != null); + }))); - ids.forEach(id => excludeUserIds.push(id)); + ids.forEach(id => ps.excludeUserIds.push(id)); } - let q: any = { - $and: [{ - tagsLower: tag.toLowerCase() - }] + const q: any = { + $and: [ps.tag ? { + tagsLower: ps.tag.toLowerCase() + } : { + $or: ps.query.map(tags => ({ + $and: tags.map(t => ({ + tagsLower: t.toLowerCase() + })) + })) + }], + deletedAt: { $exists: false } }; const push = (x: any) => q.$and.push(x); - if (includeUserIds && includeUserIds.length != 0) { + if (ps.includeUserIds && ps.includeUserIds.length != 0) { push({ userId: { - $in: includeUserIds + $in: ps.includeUserIds } }); - } else if (excludeUserIds && excludeUserIds.length != 0) { + } else if (ps.excludeUserIds && ps.excludeUserIds.length != 0) { push({ userId: { - $nin: excludeUserIds + $nin: ps.excludeUserIds } }); } - if (following != null && me != null) { + if (ps.following != null && me != null) { const ids = await getFriendIds(me._id, false); push({ - userId: following ? { + userId: ps.following ? { $in: ids } : { $nin: ids.concat(me._id) @@ -131,7 +182,7 @@ export default (params: any, me: ILocalUser) => new Promise(async (res, rej) => }); const mutedUserIds = mutes.map(m => m.muteeId); - switch (mute) { + switch (ps.mute) { case 'mute_all': push({ userId: { @@ -202,8 +253,8 @@ export default (params: any, me: ILocalUser) => new Promise(async (res, rej) => } } - if (reply != null) { - if (reply) { + if (ps.reply != null) { + if (ps.reply) { push({ replyId: { $exists: true, @@ -223,8 +274,8 @@ export default (params: any, me: ILocalUser) => new Promise(async (res, rej) => } } - if (renote != null) { - if (renote) { + if (ps.renote != null) { + if (ps.renote) { push({ renoteId: { $exists: true, @@ -244,29 +295,16 @@ export default (params: any, me: ILocalUser) => new Promise(async (res, rej) => } } - if (media != null) { - if (media) { - push({ - mediaIds: { - $exists: true, - $ne: null - } - }); - } else { - push({ - $or: [{ - mediaIds: { - $exists: false - } - }, { - mediaIds: null - }] - }); - } + const withFiles = ps.withFiles != null ? ps.withFiles : ps.media; + + if (withFiles) { + push({ + fileIds: { $exists: true, $ne: [] } + }); } - if (poll != null) { - if (poll) { + if (ps.poll != null) { + if (ps.poll) { push({ poll: { $exists: true, @@ -286,24 +324,32 @@ export default (params: any, me: ILocalUser) => new Promise(async (res, rej) => } } - if (sinceDate) { + if (ps.untilId) { + push({ + _id: { + $lt: ps.untilId + } + }); + } + + if (ps.sinceDate) { push({ createdAt: { - $gt: new Date(sinceDate) + $gt: new Date(ps.sinceDate) } }); } - if (untilDate) { + if (ps.untilDate) { push({ createdAt: { - $lt: new Date(untilDate) + $lt: new Date(ps.untilDate) } }); } if (q.$and.length == 0) { - q = {}; + delete q.$and; } // Search notes @@ -312,10 +358,10 @@ export default (params: any, me: ILocalUser) => new Promise(async (res, rej) => sort: { _id: -1 }, - limit: limit, - skip: offset + limit: ps.limit, + skip: ps.offset }); // Serialize - res(await Promise.all(notes.map(note => pack(note, me)))); + res(await packMany(notes, me)); }); diff --git a/src/server/api/endpoints/notes/timeline.ts b/src/server/api/endpoints/notes/timeline.ts index 099bf2010b..44a504eb18 100644 --- a/src/server/api/endpoints/notes/timeline.ts +++ b/src/server/api/endpoints/notes/timeline.ts @@ -2,9 +2,10 @@ import $ from 'cafy'; import ID from '../../../../misc/cafy-id'; import Note from '../../../../models/note'; import Mute from '../../../../models/mute'; import { getFriends } from '../../common/get-friends'; -import { pack } from '../../../../models/note'; +import { packMany } from '../../../../models/note'; import { ILocalUser } from '../../../../models/user'; import getParams from '../../get-params'; +import { countIf } from '../../../../prelude/array'; export const meta = { desc: { @@ -67,9 +68,15 @@ export const meta = { } }), + withFiles: $.bool.optional.note({ + desc: { + 'ja-JP': 'true にすると、ファイルが添付された投稿だけ取得します' + } + }), + mediaOnly: $.bool.optional.note({ desc: { - 'ja-JP': 'true にすると、メディアが添付された投稿だけ取得します' + 'ja-JP': 'true にすると、ファイルが添付された投稿だけ取得します (このパラメータは廃止予定です。代わりに withFiles を使ってください。)' } }), } @@ -80,7 +87,7 @@ export default async (params: any, user: ILocalUser) => { if (psErr) throw psErr; // Check if only one of sinceId, untilId, sinceDate, untilDate specified - if ([ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate].filter(x => x != null).length > 1) { + if (countIf(x => x != null, [ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate]) > 1) { throw 'only one of sinceId, untilId, sinceDate, untilDate can be specified'; } @@ -154,7 +161,7 @@ export default async (params: any, user: ILocalUser) => { }, { text: { $ne: null } }, { - mediaIds: { $ne: [] } + fileIds: { $ne: [] } }, { poll: { $ne: null } }] @@ -170,7 +177,7 @@ export default async (params: any, user: ILocalUser) => { }, { text: { $ne: null } }, { - mediaIds: { $ne: [] } + fileIds: { $ne: [] } }, { poll: { $ne: null } }] @@ -186,16 +193,18 @@ export default async (params: any, user: ILocalUser) => { }, { text: { $ne: null } }, { - mediaIds: { $ne: [] } + fileIds: { $ne: [] } }, { poll: { $ne: null } }] }); } - if (ps.mediaOnly) { + const withFiles = ps.withFiles != null ? ps.withFiles : ps.mediaOnly; + + if (withFiles) { query.$and.push({ - mediaIds: { $exists: true, $ne: [] } + fileIds: { $exists: true, $ne: [] } }); } @@ -228,5 +237,5 @@ export default async (params: any, user: ILocalUser) => { }); // Serialize - return await Promise.all(timeline.map(note => pack(note, user))); + return await packMany(timeline, user); }; diff --git a/src/server/api/endpoints/notes/trend.ts b/src/server/api/endpoints/notes/trend.ts index 7a0a098f28..9f55ed3243 100644 --- a/src/server/api/endpoints/notes/trend.ts +++ b/src/server/api/endpoints/notes/trend.ts @@ -52,7 +52,7 @@ export default (params: any, user: ILocalUser) => new Promise(async (res, rej) = } if (media != undefined) { - query.mediaIds = media ? { $exists: true, $ne: null } : null; + query.fileIds = media ? { $exists: true, $ne: null } : null; } if (poll != undefined) { diff --git a/src/server/api/endpoints/notes/user-list-timeline.ts b/src/server/api/endpoints/notes/user-list-timeline.ts index a7b43014ed..6758b4eb73 100644 --- a/src/server/api/endpoints/notes/user-list-timeline.ts +++ b/src/server/api/endpoints/notes/user-list-timeline.ts @@ -1,7 +1,7 @@ import $ from 'cafy'; import ID from '../../../../misc/cafy-id'; import Note from '../../../../models/note'; import Mute from '../../../../models/mute'; -import { pack } from '../../../../models/note'; +import { packMany } from '../../../../models/note'; import UserList from '../../../../models/user-list'; import { ILocalUser } from '../../../../models/user'; import getParams from '../../get-params'; @@ -73,9 +73,15 @@ export const meta = { } }), + withFiles: $.bool.optional.note({ + desc: { + 'ja-JP': 'true にすると、ファイルが添付された投稿だけ取得します' + } + }), + mediaOnly: $.bool.optional.note({ desc: { - 'ja-JP': 'true にすると、メディアが添付された投稿だけ取得します' + 'ja-JP': 'true にすると、ファイルが添付された投稿だけ取得します (このパラメータは廃止予定です。代わりに withFiles を使ってください。)' } }), } @@ -160,7 +166,7 @@ export default async (params: any, user: ILocalUser) => { }, { text: { $ne: null } }, { - mediaIds: { $ne: [] } + fileIds: { $ne: [] } }, { poll: { $ne: null } }] @@ -176,7 +182,7 @@ export default async (params: any, user: ILocalUser) => { }, { text: { $ne: null } }, { - mediaIds: { $ne: [] } + fileIds: { $ne: [] } }, { poll: { $ne: null } }] @@ -192,16 +198,18 @@ export default async (params: any, user: ILocalUser) => { }, { text: { $ne: null } }, { - mediaIds: { $ne: [] } + fileIds: { $ne: [] } }, { poll: { $ne: null } }] }); } - if (ps.mediaOnly) { + const withFiles = ps.withFiles != null ? ps.withFiles : ps.mediaOnly; + + if (withFiles) { query.$and.push({ - mediaIds: { $exists: true, $ne: [] } + fileIds: { $exists: true, $ne: [] } }); } @@ -234,5 +242,5 @@ export default async (params: any, user: ILocalUser) => { }); // Serialize - return await Promise.all(timeline.map(note => pack(note, user))); + return await packMany(timeline, user); }; diff --git a/src/server/api/endpoints/notifications/mark_all_as_read.ts b/src/server/api/endpoints/notifications/mark_all_as_read.ts index e2bde777b3..6487cd8b48 100644 --- a/src/server/api/endpoints/notifications/mark_all_as_read.ts +++ b/src/server/api/endpoints/notifications/mark_all_as_read.ts @@ -1,5 +1,5 @@ import Notification from '../../../../models/notification'; -import { publishUserStream } from '../../../../stream'; +import { publishMainStream } from '../../../../stream'; import User, { ILocalUser } from '../../../../models/user'; export const meta = { @@ -40,5 +40,5 @@ export default (params: any, user: ILocalUser) => new Promise(async (res, rej) = }); // 全ての通知を読みましたよというイベントを発行 - publishUserStream(user._id, 'read_all_notifications'); + publishMainStream(user._id, 'readAllNotifications'); }); diff --git a/src/server/api/endpoints/sw/register.ts b/src/server/api/endpoints/sw/register.ts index 3414600048..503fc94654 100644 --- a/src/server/api/endpoints/sw/register.ts +++ b/src/server/api/endpoints/sw/register.ts @@ -1,6 +1,7 @@ import $ from 'cafy'; import Subscription from '../../../../models/sw-subscription'; import { ILocalUser } from '../../../../models/user'; +import config from '../../../../config'; export const meta = { requireCredential: true @@ -31,8 +32,11 @@ export default async (params: any, user: ILocalUser) => new Promise(async (res, deletedAt: { $exists: false } }); - if (exist !== null) { - return res(); + if (exist != null) { + return res({ + state: 'already-subscribed', + key: config.sw.public_key + }); } await Subscription.insert({ @@ -42,5 +46,8 @@ export default async (params: any, user: ILocalUser) => new Promise(async (res, publickey: publickey }); - res(); + res({ + state: 'subscribed', + key: config.sw.public_key + }); }); diff --git a/src/server/api/endpoints/users/followers.ts b/src/server/api/endpoints/users/followers.ts index 9411873573..7fe3ca9943 100644 --- a/src/server/api/endpoints/users/followers.ts +++ b/src/server/api/endpoints/users/followers.ts @@ -73,8 +73,7 @@ export default (params: any, me: ILocalUser) => new Promise(async (res, rej) => } // Serialize - const users = await Promise.all(following.map(async f => - await pack(f.followerId, me, { detail: true }))); + const users = await Promise.all(following.map(f => pack(f.followerId, me, { detail: true }))); // Response res({ diff --git a/src/server/api/endpoints/users/following.ts b/src/server/api/endpoints/users/following.ts index 7a64d15d7b..0e564fd1b6 100644 --- a/src/server/api/endpoints/users/following.ts +++ b/src/server/api/endpoints/users/following.ts @@ -73,8 +73,7 @@ export default (params: any, me: ILocalUser) => new Promise(async (res, rej) => } // Serialize - const users = await Promise.all(following.map(async f => - await pack(f.followeeId, me, { detail: true }))); + const users = await Promise.all(following.map(f => pack(f.followeeId, me, { detail: true }))); // Response res({ diff --git a/src/server/api/endpoints/users/lists/delete.ts b/src/server/api/endpoints/users/lists/delete.ts new file mode 100644 index 0000000000..906534922e --- /dev/null +++ b/src/server/api/endpoints/users/lists/delete.ts @@ -0,0 +1,43 @@ +import $ from 'cafy'; +import ID from '../../../../../misc/cafy-id'; +import UserList, { deleteUserList } from '../../../../../models/user-list'; +import { ILocalUser } from '../../../../../models/user'; +import getParams from '../../../get-params'; + +export const meta = { + desc: { + 'ja-JP': '指定したユーザーリストを削除します。', + 'en-US': 'Delete a user list' + }, + + requireCredential: true, + + kind: 'account-write', + + params: { + listId: $.type(ID).note({ + desc: { + 'ja-JP': '対象となるユーザーリストのID', + 'en-US': 'ID of target user list' + } + }) + } +}; + +export default (params: any, user: ILocalUser) => new Promise(async (res, rej) => { + const [ps, psErr] = getParams(meta, params); + if (psErr) return rej(psErr); + + const userList = await UserList.findOne({ + _id: ps.listId, + userId: user._id + }); + + if (userList == null) { + return rej('list not found'); + } + + deleteUserList(userList); + + res(); +}); diff --git a/src/server/api/endpoints/users/lists/update.ts b/src/server/api/endpoints/users/lists/update.ts new file mode 100644 index 0000000000..e6577eca4f --- /dev/null +++ b/src/server/api/endpoints/users/lists/update.ts @@ -0,0 +1,56 @@ +import $ from 'cafy'; +import ID from '../../../../../misc/cafy-id'; +import UserList, { pack } from '../../../../../models/user-list'; +import { ILocalUser } from '../../../../../models/user'; +import getParams from '../../../get-params'; + +export const meta = { + desc: { + 'ja-JP': '指定したユーザーリストを更新します。', + 'en-US': 'Update a user list' + }, + + requireCredential: true, + + kind: 'account-write', + + params: { + listId: $.type(ID).note({ + desc: { + 'ja-JP': '対象となるユーザーリストのID', + 'en-US': 'ID of target user list' + } + }), + title: $.str.range(1, 100).note({ + desc: { + 'ja-JP': 'このユーザーリストの名前', + 'en-US': 'name of this user list' + } + }) + } +}; + +export default (params: any, user: ILocalUser) => new Promise(async (res, rej) => { + const [ps, psErr] = getParams(meta, params); + if (psErr) throw psErr; + + // Fetch the list + const userList = await UserList.findOne({ + _id: ps.listId, + userId: user._id + }); + + if (userList == null) { + return rej('list not found'); + } + + // update + await UserList.update({ _id: userList._id }, { + $set: { + title: ps.title + } + }); + + // Response + res(await pack(userList._id)); +}); diff --git a/src/server/api/endpoints/users/notes.ts b/src/server/api/endpoints/users/notes.ts index ff7855bde0..1bfe832c51 100644 --- a/src/server/api/endpoints/users/notes.ts +++ b/src/server/api/endpoints/users/notes.ts @@ -1,64 +1,123 @@ import $ from 'cafy'; import ID from '../../../../misc/cafy-id'; import getHostLower from '../../common/get-host-lower'; -import Note, { pack } from '../../../../models/note'; +import Note, { packMany } from '../../../../models/note'; import User, { ILocalUser } from '../../../../models/user'; +import getParams from '../../get-params'; +import { countIf } from '../../../../prelude/array'; -/** - * Get notes of a user - */ -export default (params: any, me: ILocalUser) => new Promise(async (res, rej) => { - // Get 'userId' parameter - const [userId, userIdErr] = $.type(ID).optional.get(params.userId); - if (userIdErr) return rej('invalid userId param'); +export const meta = { + desc: { + 'ja-JP': '指定したユーザーのタイムラインを取得します。' + }, - // Get 'username' parameter - const [username, usernameErr] = $.str.optional.get(params.username); - if (usernameErr) return rej('invalid username param'); + params: { + userId: $.type(ID).optional.note({ + desc: { + 'ja-JP': 'ユーザーID' + } + }), - if (userId === undefined && username === undefined) { - return rej('userId or username is required'); - } + username: $.str.optional.note({ + desc: { + 'ja-JP': 'ユーザー名' + } + }), + + host: $.str.optional.note({ + }), - // Get 'host' parameter - const [host, hostErr] = $.str.optional.get(params.host); - if (hostErr) return rej('invalid host param'); + includeReplies: $.bool.optional.note({ + default: true, - // Get 'includeReplies' parameter - const [includeReplies = true, includeRepliesErr] = $.bool.optional.get(params.includeReplies); - if (includeRepliesErr) return rej('invalid includeReplies param'); + desc: { + 'ja-JP': 'リプライを含めるか否か' + } + }), - // Get 'withMedia' parameter - const [withMedia = false, withMediaErr] = $.bool.optional.get(params.withMedia); - if (withMediaErr) return rej('invalid withMedia param'); + limit: $.num.optional.range(1, 100).note({ + default: 10, + desc: { + 'ja-JP': '最大数' + } + }), - // Get 'limit' parameter - const [limit = 10, limitErr] = $.num.optional.range(1, 100).get(params.limit); - if (limitErr) return rej('invalid limit param'); + sinceId: $.type(ID).optional.note({ + desc: { + 'ja-JP': '指定すると、この投稿を基点としてより新しい投稿を取得します' + } + }), - // Get 'sinceId' parameter - const [sinceId, sinceIdErr] = $.type(ID).optional.get(params.sinceId); - if (sinceIdErr) return rej('invalid sinceId param'); + untilId: $.type(ID).optional.note({ + desc: { + 'ja-JP': '指定すると、この投稿を基点としてより古い投稿を取得します' + } + }), - // Get 'untilId' parameter - const [untilId, untilIdErr] = $.type(ID).optional.get(params.untilId); - if (untilIdErr) return rej('invalid untilId param'); + sinceDate: $.num.optional.note({ + desc: { + 'ja-JP': '指定した時間を基点としてより新しい投稿を取得します。数値は、1970年1月1日 00:00:00 UTC から指定した日時までの経過時間をミリ秒単位で表します。' + } + }), - // Get 'sinceDate' parameter - const [sinceDate, sinceDateErr] = $.num.optional.get(params.sinceDate); - if (sinceDateErr) throw 'invalid sinceDate param'; + untilDate: $.num.optional.note({ + desc: { + 'ja-JP': '指定した時間を基点としてより古い投稿を取得します。数値は、1970年1月1日 00:00:00 UTC から指定した日時までの経過時間をミリ秒単位で表します。' + } + }), - // Get 'untilDate' parameter - const [untilDate, untilDateErr] = $.num.optional.get(params.untilDate); - if (untilDateErr) throw 'invalid untilDate param'; + includeMyRenotes: $.bool.optional.note({ + default: true, + desc: { + 'ja-JP': '自分の行ったRenoteを含めるかどうか' + } + }), + + includeRenotedMyNotes: $.bool.optional.note({ + default: true, + desc: { + 'ja-JP': 'Renoteされた自分の投稿を含めるかどうか' + } + }), + + includeLocalRenotes: $.bool.optional.note({ + default: true, + desc: { + 'ja-JP': 'Renoteされたローカルの投稿を含めるかどうか' + } + }), + + withFiles: $.bool.optional.note({ + default: false, + desc: { + 'ja-JP': 'true にすると、ファイルが添付された投稿だけ取得します' + } + }), + + mediaOnly: $.bool.optional.note({ + default: false, + desc: { + 'ja-JP': 'true にすると、ファイルが添付された投稿だけ取得します (このパラメータは廃止予定です。代わりに withFiles を使ってください。)' + } + }), + } +}; + +export default (params: any, me: ILocalUser) => new Promise(async (res, rej) => { + const [ps, psErr] = getParams(meta, params); + if (psErr) throw psErr; + + if (ps.userId === undefined && ps.username === undefined) { + return rej('userId or username is required'); + } // Check if only one of sinceId, untilId, sinceDate, untilDate specified - if ([sinceId, untilId, sinceDate, untilDate].filter(x => x != null).length > 1) { + if (countIf(x => x != null, [ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate]) > 1) { throw 'only one of sinceId, untilId, sinceDate, untilDate can be specified'; } - const q = userId !== undefined - ? { _id: userId } - : { usernameLower: username.toLowerCase(), host: getHostLower(host) } ; + const q = ps.userId !== undefined + ? { _id: ps.userId } + : { usernameLower: ps.username.toLowerCase(), host: getHostLower(ps.host) } ; // Lookup user const user = await User.findOne(q, { @@ -80,32 +139,34 @@ export default (params: any, me: ILocalUser) => new Promise(async (res, rej) => userId: user._id } as any; - if (sinceId) { + if (ps.sinceId) { sort._id = 1; query._id = { - $gt: sinceId + $gt: ps.sinceId }; - } else if (untilId) { + } else if (ps.untilId) { query._id = { - $lt: untilId + $lt: ps.untilId }; - } else if (sinceDate) { + } else if (ps.sinceDate) { sort._id = 1; query.createdAt = { - $gt: new Date(sinceDate) + $gt: new Date(ps.sinceDate) }; - } else if (untilDate) { + } else if (ps.untilDate) { query.createdAt = { - $lt: new Date(untilDate) + $lt: new Date(ps.untilDate) }; } - if (!includeReplies) { + if (!ps.includeReplies) { query.replyId = null; } - if (withMedia) { - query.mediaIds = { + const withFiles = ps.withFiles != null ? ps.withFiles : ps.mediaOnly; + + if (withFiles) { + query.fileIds = { $exists: true, $ne: [] }; @@ -115,12 +176,10 @@ export default (params: any, me: ILocalUser) => new Promise(async (res, rej) => // Issue query const notes = await Note .find(query, { - limit: limit, + limit: ps.limit, sort: sort }); // Serialize - res(await Promise.all(notes.map(async (note) => - await pack(note, me) - ))); + res(await packMany(notes, me)); }); diff --git a/src/server/api/private/signin.ts b/src/server/api/private/signin.ts index c42fb7bd8c..0e44c2ddd6 100644 --- a/src/server/api/private/signin.ts +++ b/src/server/api/private/signin.ts @@ -3,7 +3,7 @@ import * as bcrypt from 'bcryptjs'; import * as speakeasy from 'speakeasy'; import User, { ILocalUser } from '../../../models/user'; import Signin, { pack } from '../../../models/signin'; -import { publishUserStream } from '../../../stream'; +import { publishMainStream } from '../../../stream'; import signin from '../common/signin'; import config from '../../../config'; @@ -87,5 +87,5 @@ export default async (ctx: Koa.Context) => { }); // Publish signin event - publishUserStream(user._id, 'signin', await pack(record)); + publishMainStream(user._id, 'signin', await pack(record)); }; diff --git a/src/server/api/service/twitter.ts b/src/server/api/service/twitter.ts index aad2846bb4..f71e588628 100644 --- a/src/server/api/service/twitter.ts +++ b/src/server/api/service/twitter.ts @@ -4,7 +4,7 @@ import * as uuid from 'uuid'; import autwh from 'autwh'; import redis from '../../../db/redis'; import User, { pack, ILocalUser } from '../../../models/user'; -import { publishUserStream } from '../../../stream'; +import { publishMainStream } from '../../../stream'; import config from '../../../config'; import signin from '../common/signin'; @@ -49,7 +49,7 @@ router.get('/disconnect/twitter', async ctx => { ctx.body = `Twitterの連携を解除しました :v:`; // Publish i updated event - publishUserStream(user._id, 'meUpdated', await pack(user, user, { + publishMainStream(user._id, 'meUpdated', await pack(user, user, { detail: true, includeSecrets: true })); @@ -174,7 +174,7 @@ if (config.twitter == null) { ctx.body = `Twitter: @${result.screenName} を、Misskey: @${user.username} に接続しました!`; // Publish i updated event - publishUserStream(user._id, 'meUpdated', await pack(user, user, { + publishMainStream(user._id, 'meUpdated', await pack(user, user, { detail: true, includeSecrets: true })); diff --git a/src/server/api/stream/channel.ts b/src/server/api/stream/channel.ts new file mode 100644 index 0000000000..e2726060dc --- /dev/null +++ b/src/server/api/stream/channel.ts @@ -0,0 +1,39 @@ +import autobind from 'autobind-decorator'; +import Connection from '.'; + +/** + * Stream channel + */ +export default abstract class Channel { + protected connection: Connection; + public id: string; + + protected get user() { + return this.connection.user; + } + + protected get subscriber() { + return this.connection.subscriber; + } + + constructor(id: string, connection: Connection) { + this.id = id; + this.connection = connection; + } + + @autobind + public send(typeOrPayload: any, payload?: any) { + const type = payload === undefined ? typeOrPayload.type : typeOrPayload; + const body = payload === undefined ? typeOrPayload.body : payload; + + this.connection.sendMessageToWs('channel', { + id: this.id, + type: type, + body: body + }); + } + + public abstract init(params: any): void; + public dispose?(): void; + public onMessage?(type: string, body: any): void; +} diff --git a/src/server/api/stream/channels/drive.ts b/src/server/api/stream/channels/drive.ts new file mode 100644 index 0000000000..807fc93cd0 --- /dev/null +++ b/src/server/api/stream/channels/drive.ts @@ -0,0 +1,12 @@ +import autobind from 'autobind-decorator'; +import Channel from '../channel'; + +export default class extends Channel { + @autobind + public async init(params: any) { + // Subscribe drive stream + this.subscriber.on(`driveStream:${this.user._id}`, data => { + this.send(data); + }); + } +} diff --git a/src/server/api/stream/channels/games/reversi-game.ts b/src/server/api/stream/channels/games/reversi-game.ts new file mode 100644 index 0000000000..11f1fb1feb --- /dev/null +++ b/src/server/api/stream/channels/games/reversi-game.ts @@ -0,0 +1,309 @@ +import autobind from 'autobind-decorator'; +import * as CRC32 from 'crc-32'; +import ReversiGame, { pack } from '../../../../../models/games/reversi/game'; +import { publishReversiGameStream } from '../../../../../stream'; +import Reversi from '../../../../../games/reversi/core'; +import * as maps from '../../../../../games/reversi/maps'; +import Channel from '../../channel'; + +export default class extends Channel { + private gameId: string; + + @autobind + public async init(params: any) { + this.gameId = params.gameId as string; + + // Subscribe game stream + this.subscriber.on(`reversiGameStream:${this.gameId}`, data => { + this.send(data); + }); + } + + @autobind + public onMessage(type: string, body: any) { + switch (type) { + case 'accept': this.accept(true); break; + case 'cancel-accept': this.accept(false); break; + case 'update-settings': this.updateSettings(body.settings); break; + case 'init-form': this.initForm(body); break; + case 'update-form': this.updateForm(body.id, body.value); break; + case 'message': this.message(body); break; + case 'set': this.set(body.pos); break; + case 'check': this.check(body.crc32); break; + } + } + + @autobind + private async updateSettings(settings: any) { + const game = await ReversiGame.findOne({ _id: this.gameId }); + + if (game.isStarted) return; + if (!game.user1Id.equals(this.user._id) && !game.user2Id.equals(this.user._id)) return; + if (game.user1Id.equals(this.user._id) && game.user1Accepted) return; + if (game.user2Id.equals(this.user._id) && game.user2Accepted) return; + + await ReversiGame.update({ _id: this.gameId }, { + $set: { + settings + } + }); + + publishReversiGameStream(this.gameId, 'updateSettings', settings); + } + + @autobind + private async initForm(form: any) { + const game = await ReversiGame.findOne({ _id: this.gameId }); + + if (game.isStarted) return; + if (!game.user1Id.equals(this.user._id) && !game.user2Id.equals(this.user._id)) return; + + const set = game.user1Id.equals(this.user._id) ? { + form1: form + } : { + form2: form + }; + + await ReversiGame.update({ _id: this.gameId }, { + $set: set + }); + + publishReversiGameStream(this.gameId, 'initForm', { + userId: this.user._id, + form + }); + } + + @autobind + private async updateForm(id: string, value: any) { + const game = await ReversiGame.findOne({ _id: this.gameId }); + + if (game.isStarted) return; + if (!game.user1Id.equals(this.user._id) && !game.user2Id.equals(this.user._id)) return; + + const form = game.user1Id.equals(this.user._id) ? game.form2 : game.form1; + + const item = form.find((i: any) => i.id == id); + + if (item == null) return; + + item.value = value; + + const set = game.user1Id.equals(this.user._id) ? { + form2: form + } : { + form1: form + }; + + await ReversiGame.update({ _id: this.gameId }, { + $set: set + }); + + publishReversiGameStream(this.gameId, 'updateForm', { + userId: this.user._id, + id, + value + }); + } + + @autobind + private async message(message: any) { + message.id = Math.random(); + publishReversiGameStream(this.gameId, 'message', { + userId: this.user._id, + message + }); + } + + @autobind + private async accept(accept: boolean) { + const game = await ReversiGame.findOne({ _id: this.gameId }); + + if (game.isStarted) return; + + let bothAccepted = false; + + if (game.user1Id.equals(this.user._id)) { + await ReversiGame.update({ _id: this.gameId }, { + $set: { + user1Accepted: accept + } + }); + + publishReversiGameStream(this.gameId, 'changeAccepts', { + user1: accept, + user2: game.user2Accepted + }); + + if (accept && game.user2Accepted) bothAccepted = true; + } else if (game.user2Id.equals(this.user._id)) { + await ReversiGame.update({ _id: this.gameId }, { + $set: { + user2Accepted: accept + } + }); + + publishReversiGameStream(this.gameId, 'changeAccepts', { + user1: game.user1Accepted, + user2: accept + }); + + if (accept && game.user1Accepted) bothAccepted = true; + } else { + return; + } + + if (bothAccepted) { + // 3秒後、まだacceptされていたらゲーム開始 + setTimeout(async () => { + const freshGame = await ReversiGame.findOne({ _id: this.gameId }); + if (freshGame == null || freshGame.isStarted || freshGame.isEnded) return; + if (!freshGame.user1Accepted || !freshGame.user2Accepted) return; + + let bw: number; + if (freshGame.settings.bw == 'random') { + bw = Math.random() > 0.5 ? 1 : 2; + } else { + bw = freshGame.settings.bw as number; + } + + function getRandomMap() { + const mapCount = Object.entries(maps).length; + const rnd = Math.floor(Math.random() * mapCount); + return Object.values(maps)[rnd].data; + } + + const map = freshGame.settings.map != null ? freshGame.settings.map : getRandomMap(); + + await ReversiGame.update({ _id: this.gameId }, { + $set: { + startedAt: new Date(), + isStarted: true, + black: bw, + 'settings.map': map + } + }); + + //#region 盤面に最初から石がないなどして始まった瞬間に勝敗が決定する場合があるのでその処理 + const o = new Reversi(map, { + isLlotheo: freshGame.settings.isLlotheo, + canPutEverywhere: freshGame.settings.canPutEverywhere, + loopedBoard: freshGame.settings.loopedBoard + }); + + if (o.isEnded) { + let winner; + if (o.winner === true) { + winner = freshGame.black == 1 ? freshGame.user1Id : freshGame.user2Id; + } else if (o.winner === false) { + winner = freshGame.black == 1 ? freshGame.user2Id : freshGame.user1Id; + } else { + winner = null; + } + + await ReversiGame.update({ + _id: this.gameId + }, { + $set: { + isEnded: true, + winnerId: winner + } + }); + + publishReversiGameStream(this.gameId, 'ended', { + winnerId: winner, + game: await pack(this.gameId, this.user) + }); + } + //#endregion + + publishReversiGameStream(this.gameId, 'started', await pack(this.gameId, this.user)); + }, 3000); + } + } + + // 石を打つ + @autobind + private async set(pos: number) { + const game = await ReversiGame.findOne({ _id: this.gameId }); + + if (!game.isStarted) return; + if (game.isEnded) return; + if (!game.user1Id.equals(this.user._id) && !game.user2Id.equals(this.user._id)) return; + + const o = new Reversi(game.settings.map, { + isLlotheo: game.settings.isLlotheo, + canPutEverywhere: game.settings.canPutEverywhere, + loopedBoard: game.settings.loopedBoard + }); + + game.logs.forEach(log => { + o.put(log.color, log.pos); + }); + + const myColor = + (game.user1Id.equals(this.user._id) && game.black == 1) || (game.user2Id.equals(this.user._id) && game.black == 2) + ? true + : false; + + if (!o.canPut(myColor, pos)) return; + o.put(myColor, pos); + + let winner; + if (o.isEnded) { + if (o.winner === true) { + winner = game.black == 1 ? game.user1Id : game.user2Id; + } else if (o.winner === false) { + winner = game.black == 1 ? game.user2Id : game.user1Id; + } else { + winner = null; + } + } + + const log = { + at: new Date(), + color: myColor, + pos + }; + + const crc32 = CRC32.str(game.logs.map(x => x.pos.toString()).join('') + pos.toString()); + + await ReversiGame.update({ + _id: this.gameId + }, { + $set: { + crc32, + isEnded: o.isEnded, + winnerId: winner + }, + $push: { + logs: log + } + }); + + publishReversiGameStream(this.gameId, 'set', Object.assign(log, { + next: o.turn + })); + + if (o.isEnded) { + publishReversiGameStream(this.gameId, 'ended', { + winnerId: winner, + game: await pack(this.gameId, this.user) + }); + } + } + + @autobind + private async check(crc32: string) { + const game = await ReversiGame.findOne({ _id: this.gameId }); + + if (!game.isStarted) return; + + // 互換性のため + if (game.crc32 == null) return; + + if (crc32 !== game.crc32) { + this.send('rescue', await pack(game, this.user)); + } + } +} diff --git a/src/server/api/stream/channels/games/reversi.ts b/src/server/api/stream/channels/games/reversi.ts new file mode 100644 index 0000000000..d75025c944 --- /dev/null +++ b/src/server/api/stream/channels/games/reversi.ts @@ -0,0 +1,30 @@ +import autobind from 'autobind-decorator'; +import * as mongo from 'mongodb'; +import Matching, { pack } from '../../../../../models/games/reversi/matching'; +import { publishMainStream } from '../../../../../stream'; +import Channel from '../../channel'; + +export default class extends Channel { + @autobind + public async init(params: any) { + // Subscribe reversi stream + this.subscriber.on(`reversiStream:${this.user._id}`, data => { + this.send(data); + }); + } + + @autobind + public async onMessage(type: string, body: any) { + switch (type) { + case 'ping': + if (body.id == null) return; + const matching = await Matching.findOne({ + parentId: this.user._id, + childId: new mongo.ObjectID(body.id) + }); + if (matching == null) return; + publishMainStream(matching.childId, 'reversiInvited', await pack(matching, matching.childId)); + break; + } + } +} diff --git a/src/server/api/stream/channels/global-timeline.ts b/src/server/api/stream/channels/global-timeline.ts new file mode 100644 index 0000000000..ab0fe5d094 --- /dev/null +++ b/src/server/api/stream/channels/global-timeline.ts @@ -0,0 +1,39 @@ +import autobind from 'autobind-decorator'; +import Mute from '../../../../models/mute'; +import { pack } from '../../../../models/note'; +import shouldMuteThisNote from '../../../../misc/should-mute-this-note'; +import Channel from '../channel'; + +export default class extends Channel { + private mutedUserIds: string[] = []; + + @autobind + public async init(params: any) { + // Subscribe events + this.subscriber.on('globalTimeline', this.onNote); + + const mute = await Mute.find({ muterId: this.user._id }); + this.mutedUserIds = mute.map(m => m.muteeId.toString()); + } + + @autobind + private async onNote(note: any) { + // Renoteなら再pack + if (note.renoteId != null) { + note.renote = await pack(note.renoteId, this.user, { + detail: true + }); + } + + // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する + if (shouldMuteThisNote(note, this.mutedUserIds)) return; + + this.send('note', note); + } + + @autobind + public dispose() { + // Unsubscribe events + this.subscriber.off('globalTimeline', this.onNote); + } +} diff --git a/src/server/api/stream/channels/hashtag.ts b/src/server/api/stream/channels/hashtag.ts new file mode 100644 index 0000000000..652b0caa5b --- /dev/null +++ b/src/server/api/stream/channels/hashtag.ts @@ -0,0 +1,33 @@ +import autobind from 'autobind-decorator'; +import Mute from '../../../../models/mute'; +import { pack } from '../../../../models/note'; +import shouldMuteThisNote from '../../../../misc/should-mute-this-note'; +import Channel from '../channel'; + +export default class extends Channel { + @autobind + public async init(params: any) { + const mute = this.user ? await Mute.find({ muterId: this.user._id }) : null; + const mutedUserIds = mute ? mute.map(m => m.muteeId.toString()) : []; + + const q: Array<string[]> = params.q; + + // Subscribe stream + this.subscriber.on('hashtag', async note => { + const matched = q.some(tags => tags.every(tag => note.tags.map((t: string) => t.toLowerCase()).includes(tag.toLowerCase()))); + if (!matched) return; + + // Renoteなら再pack + if (note.renoteId != null) { + note.renote = await pack(note.renoteId, this.user, { + detail: true + }); + } + + // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する + if (shouldMuteThisNote(note, mutedUserIds)) return; + + this.send('note', note); + }); + } +} diff --git a/src/server/api/stream/channels/home-timeline.ts b/src/server/api/stream/channels/home-timeline.ts new file mode 100644 index 0000000000..4c674e75ef --- /dev/null +++ b/src/server/api/stream/channels/home-timeline.ts @@ -0,0 +1,39 @@ +import autobind from 'autobind-decorator'; +import Mute from '../../../../models/mute'; +import { pack } from '../../../../models/note'; +import shouldMuteThisNote from '../../../../misc/should-mute-this-note'; +import Channel from '../channel'; + +export default class extends Channel { + private mutedUserIds: string[] = []; + + @autobind + public async init(params: any) { + // Subscribe events + this.subscriber.on(`homeTimeline:${this.user._id}`, this.onNote); + + const mute = await Mute.find({ muterId: this.user._id }); + this.mutedUserIds = mute.map(m => m.muteeId.toString()); + } + + @autobind + private async onNote(note: any) { + // Renoteなら再pack + if (note.renoteId != null) { + note.renote = await pack(note.renoteId, this.user, { + detail: true + }); + } + + // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する + if (shouldMuteThisNote(note, this.mutedUserIds)) return; + + this.send('note', note); + } + + @autobind + public dispose() { + // Unsubscribe events + this.subscriber.off(`homeTimeline:${this.user._id}`, this.onNote); + } +} diff --git a/src/server/api/stream/channels/hybrid-timeline.ts b/src/server/api/stream/channels/hybrid-timeline.ts new file mode 100644 index 0000000000..0b12ab3a8f --- /dev/null +++ b/src/server/api/stream/channels/hybrid-timeline.ts @@ -0,0 +1,41 @@ +import autobind from 'autobind-decorator'; +import Mute from '../../../../models/mute'; +import { pack } from '../../../../models/note'; +import shouldMuteThisNote from '../../../../misc/should-mute-this-note'; +import Channel from '../channel'; + +export default class extends Channel { + private mutedUserIds: string[] = []; + + @autobind + public async init(params: any) { + // Subscribe events + this.subscriber.on('hybridTimeline', this.onNewNote); + this.subscriber.on(`hybridTimeline:${this.user._id}`, this.onNewNote); + + const mute = await Mute.find({ muterId: this.user._id }); + this.mutedUserIds = mute.map(m => m.muteeId.toString()); + } + + @autobind + private async onNewNote(note: any) { + // Renoteなら再pack + if (note.renoteId != null) { + note.renote = await pack(note.renoteId, this.user, { + detail: true + }); + } + + // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する + if (shouldMuteThisNote(note, this.mutedUserIds)) return; + + this.send('note', note); + } + + @autobind + public dispose() { + // Unsubscribe events + this.subscriber.off('hybridTimeline', this.onNewNote); + this.subscriber.off(`hybridTimeline:${this.user._id}`, this.onNewNote); + } +} diff --git a/src/server/api/stream/channels/index.ts b/src/server/api/stream/channels/index.ts new file mode 100644 index 0000000000..7e71590d00 --- /dev/null +++ b/src/server/api/stream/channels/index.ts @@ -0,0 +1,31 @@ +import main from './main'; +import homeTimeline from './home-timeline'; +import localTimeline from './local-timeline'; +import hybridTimeline from './hybrid-timeline'; +import globalTimeline from './global-timeline'; +import notesStats from './notes-stats'; +import serverStats from './server-stats'; +import userList from './user-list'; +import messaging from './messaging'; +import messagingIndex from './messaging-index'; +import drive from './drive'; +import hashtag from './hashtag'; +import gamesReversi from './games/reversi'; +import gamesReversiGame from './games/reversi-game'; + +export default { + main, + homeTimeline, + localTimeline, + hybridTimeline, + globalTimeline, + notesStats, + serverStats, + userList, + messaging, + messagingIndex, + drive, + hashtag, + gamesReversi, + gamesReversiGame +}; diff --git a/src/server/api/stream/channels/local-timeline.ts b/src/server/api/stream/channels/local-timeline.ts new file mode 100644 index 0000000000..769ec6392f --- /dev/null +++ b/src/server/api/stream/channels/local-timeline.ts @@ -0,0 +1,39 @@ +import autobind from 'autobind-decorator'; +import Mute from '../../../../models/mute'; +import { pack } from '../../../../models/note'; +import shouldMuteThisNote from '../../../../misc/should-mute-this-note'; +import Channel from '../channel'; + +export default class extends Channel { + private mutedUserIds: string[] = []; + + @autobind + public async init(params: any) { + // Subscribe events + this.subscriber.on('localTimeline', this.onNote); + + const mute = this.user ? await Mute.find({ muterId: this.user._id }) : null; + this.mutedUserIds = mute ? mute.map(m => m.muteeId.toString()) : []; + } + + @autobind + private async onNote(note: any) { + // Renoteなら再pack + if (note.renoteId != null) { + note.renote = await pack(note.renoteId, this.user, { + detail: true + }); + } + + // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する + if (shouldMuteThisNote(note, this.mutedUserIds)) return; + + this.send('note', note); + } + + @autobind + public dispose() { + // Unsubscribe events + this.subscriber.off('localTimeline', this.onNote); + } +} diff --git a/src/server/api/stream/channels/main.ts b/src/server/api/stream/channels/main.ts new file mode 100644 index 0000000000..fd0984c833 --- /dev/null +++ b/src/server/api/stream/channels/main.ts @@ -0,0 +1,25 @@ +import autobind from 'autobind-decorator'; +import Mute from '../../../../models/mute'; +import Channel from '../channel'; + +export default class extends Channel { + @autobind + public async init(params: any) { + const mute = await Mute.find({ muterId: this.user._id }); + const mutedUserIds = mute.map(m => m.muteeId.toString()); + + // Subscribe main stream channel + this.subscriber.on(`mainStream:${this.user._id}`, async data => { + const { type, body } = data; + + switch (type) { + case 'notification': { + if (mutedUserIds.includes(body.userId)) return; + break; + } + } + + this.send(type, body); + }); + } +} diff --git a/src/server/api/stream/channels/messaging-index.ts b/src/server/api/stream/channels/messaging-index.ts new file mode 100644 index 0000000000..6e87cca7f4 --- /dev/null +++ b/src/server/api/stream/channels/messaging-index.ts @@ -0,0 +1,12 @@ +import autobind from 'autobind-decorator'; +import Channel from '../channel'; + +export default class extends Channel { + @autobind + public async init(params: any) { + // Subscribe messaging index stream + this.subscriber.on(`messagingIndexStream:${this.user._id}`, data => { + this.send(data); + }); + } +} diff --git a/src/server/api/stream/channels/messaging.ts b/src/server/api/stream/channels/messaging.ts new file mode 100644 index 0000000000..e1a78c8678 --- /dev/null +++ b/src/server/api/stream/channels/messaging.ts @@ -0,0 +1,26 @@ +import autobind from 'autobind-decorator'; +import read from '../../common/read-messaging-message'; +import Channel from '../channel'; + +export default class extends Channel { + private otherpartyId: string; + + @autobind + public async init(params: any) { + this.otherpartyId = params.otherparty as string; + + // Subscribe messaging stream + this.subscriber.on(`messagingStream:${this.user._id}-${this.otherpartyId}`, data => { + this.send(data); + }); + } + + @autobind + public onMessage(type: string, body: any) { + switch (type) { + case 'read': + read(this.user._id, this.otherpartyId, body.id); + break; + } + } +} diff --git a/src/server/api/stream/channels/notes-stats.ts b/src/server/api/stream/channels/notes-stats.ts new file mode 100644 index 0000000000..cc68d9886d --- /dev/null +++ b/src/server/api/stream/channels/notes-stats.ts @@ -0,0 +1,34 @@ +import autobind from 'autobind-decorator'; +import Xev from 'xev'; +import Channel from '../channel'; + +const ev = new Xev(); + +export default class extends Channel { + @autobind + public async init(params: any) { + ev.addListener('notesStats', this.onStats); + } + + @autobind + private onStats(stats: any) { + this.send('stats', stats); + } + + @autobind + public onMessage(type: string, body: any) { + switch (type) { + case 'requestLog': + ev.once(`notesStatsLog:${body.id}`, statsLog => { + this.send('statsLog', statsLog); + }); + ev.emit('requestNotesStatsLog', body.id); + break; + } + } + + @autobind + public dispose() { + ev.removeListener('notesStats', this.onStats); + } +} diff --git a/src/server/api/stream/channels/server-stats.ts b/src/server/api/stream/channels/server-stats.ts new file mode 100644 index 0000000000..28a566e8ae --- /dev/null +++ b/src/server/api/stream/channels/server-stats.ts @@ -0,0 +1,37 @@ +import autobind from 'autobind-decorator'; +import Xev from 'xev'; +import Channel from '../channel'; + +const ev = new Xev(); + +export default class extends Channel { + @autobind + public async init(params: any) { + ev.addListener('serverStats', this.onStats); + } + + @autobind + private onStats(stats: any) { + this.send('stats', stats); + } + + @autobind + public onMessage(type: string, body: any) { + switch (type) { + case 'requestLog': + ev.once(`serverStatsLog:${body.id}`, statsLog => { + this.send('statsLog', statsLog); + }); + ev.emit('requestServerStatsLog', { + id: body.id, + length: body.length + }); + break; + } + } + + @autobind + public dispose() { + ev.removeListener('serverStats', this.onStats); + } +} diff --git a/src/server/api/stream/channels/user-list.ts b/src/server/api/stream/channels/user-list.ts new file mode 100644 index 0000000000..4ace308923 --- /dev/null +++ b/src/server/api/stream/channels/user-list.ts @@ -0,0 +1,14 @@ +import autobind from 'autobind-decorator'; +import Channel from '../channel'; + +export default class extends Channel { + @autobind + public async init(params: any) { + const listId = params.listId as string; + + // Subscribe stream + this.subscriber.on(`userListStream:${listId}`, data => { + this.send(data); + }); + } +} diff --git a/src/server/api/stream/drive.ts b/src/server/api/stream/drive.ts deleted file mode 100644 index 28c241e1bc..0000000000 --- a/src/server/api/stream/drive.ts +++ /dev/null @@ -1,9 +0,0 @@ -import * as websocket from 'websocket'; -import Xev from 'xev'; - -export default function(request: websocket.request, connection: websocket.connection, subscriber: Xev, user: any): void { - // Subscribe drive stream - subscriber.on(`drive-stream:${user._id}`, data => { - connection.send(JSON.stringify(data)); - }); -} diff --git a/src/server/api/stream/games/reversi-game.ts b/src/server/api/stream/games/reversi-game.ts deleted file mode 100644 index 5cbbf42d59..0000000000 --- a/src/server/api/stream/games/reversi-game.ts +++ /dev/null @@ -1,332 +0,0 @@ -import * as websocket from 'websocket'; -import Xev from 'xev'; -import * as CRC32 from 'crc-32'; -import ReversiGame, { pack } from '../../../../models/games/reversi/game'; -import { publishReversiGameStream } from '../../../../stream'; -import Reversi from '../../../../games/reversi/core'; -import * as maps from '../../../../games/reversi/maps'; -import { ParsedUrlQuery } from 'querystring'; - -export default function(request: websocket.request, connection: websocket.connection, subscriber: Xev, user?: any): void { - const q = request.resourceURL.query as ParsedUrlQuery; - const gameId = q.game as string; - - // Subscribe game stream - subscriber.on(`reversi-game-stream:${gameId}`, data => { - connection.send(JSON.stringify(data)); - }); - - connection.on('message', async (data) => { - const msg = JSON.parse(data.utf8Data); - - switch (msg.type) { - case 'accept': - accept(true); - break; - - case 'cancel-accept': - accept(false); - break; - - case 'update-settings': - if (msg.settings == null) return; - updateSettings(msg.settings); - break; - - case 'init-form': - if (msg.body == null) return; - initForm(msg.body); - break; - - case 'update-form': - if (msg.id == null || msg.value === undefined) return; - updateForm(msg.id, msg.value); - break; - - case 'message': - if (msg.body == null) return; - message(msg.body); - break; - - case 'set': - if (msg.pos == null) return; - set(msg.pos); - break; - - case 'check': - if (msg.crc32 == null) return; - check(msg.crc32); - break; - } - }); - - async function updateSettings(settings: any) { - const game = await ReversiGame.findOne({ _id: gameId }); - - if (game.isStarted) return; - if (!game.user1Id.equals(user._id) && !game.user2Id.equals(user._id)) return; - if (game.user1Id.equals(user._id) && game.user1Accepted) return; - if (game.user2Id.equals(user._id) && game.user2Accepted) return; - - await ReversiGame.update({ _id: gameId }, { - $set: { - settings - } - }); - - publishReversiGameStream(gameId, 'update-settings', settings); - } - - async function initForm(form: any) { - const game = await ReversiGame.findOne({ _id: gameId }); - - if (game.isStarted) return; - if (!game.user1Id.equals(user._id) && !game.user2Id.equals(user._id)) return; - - const set = game.user1Id.equals(user._id) ? { - form1: form - } : { - form2: form - }; - - await ReversiGame.update({ _id: gameId }, { - $set: set - }); - - publishReversiGameStream(gameId, 'init-form', { - userId: user._id, - form - }); - } - - async function updateForm(id: string, value: any) { - const game = await ReversiGame.findOne({ _id: gameId }); - - if (game.isStarted) return; - if (!game.user1Id.equals(user._id) && !game.user2Id.equals(user._id)) return; - - const form = game.user1Id.equals(user._id) ? game.form2 : game.form1; - - const item = form.find((i: any) => i.id == id); - - if (item == null) return; - - item.value = value; - - const set = game.user1Id.equals(user._id) ? { - form2: form - } : { - form1: form - }; - - await ReversiGame.update({ _id: gameId }, { - $set: set - }); - - publishReversiGameStream(gameId, 'update-form', { - userId: user._id, - id, - value - }); - } - - async function message(message: any) { - message.id = Math.random(); - publishReversiGameStream(gameId, 'message', { - userId: user._id, - message - }); - } - - async function accept(accept: boolean) { - const game = await ReversiGame.findOne({ _id: gameId }); - - if (game.isStarted) return; - - let bothAccepted = false; - - if (game.user1Id.equals(user._id)) { - await ReversiGame.update({ _id: gameId }, { - $set: { - user1Accepted: accept - } - }); - - publishReversiGameStream(gameId, 'change-accepts', { - user1: accept, - user2: game.user2Accepted - }); - - if (accept && game.user2Accepted) bothAccepted = true; - } else if (game.user2Id.equals(user._id)) { - await ReversiGame.update({ _id: gameId }, { - $set: { - user2Accepted: accept - } - }); - - publishReversiGameStream(gameId, 'change-accepts', { - user1: game.user1Accepted, - user2: accept - }); - - if (accept && game.user1Accepted) bothAccepted = true; - } else { - return; - } - - if (bothAccepted) { - // 3秒後、まだacceptされていたらゲーム開始 - setTimeout(async () => { - const freshGame = await ReversiGame.findOne({ _id: gameId }); - if (freshGame == null || freshGame.isStarted || freshGame.isEnded) return; - if (!freshGame.user1Accepted || !freshGame.user2Accepted) return; - - let bw: number; - if (freshGame.settings.bw == 'random') { - bw = Math.random() > 0.5 ? 1 : 2; - } else { - bw = freshGame.settings.bw as number; - } - - function getRandomMap() { - const mapCount = Object.entries(maps).length; - const rnd = Math.floor(Math.random() * mapCount); - return Object.values(maps)[rnd].data; - } - - const map = freshGame.settings.map != null ? freshGame.settings.map : getRandomMap(); - - await ReversiGame.update({ _id: gameId }, { - $set: { - startedAt: new Date(), - isStarted: true, - black: bw, - 'settings.map': map - } - }); - - //#region 盤面に最初から石がないなどして始まった瞬間に勝敗が決定する場合があるのでその処理 - const o = new Reversi(map, { - isLlotheo: freshGame.settings.isLlotheo, - canPutEverywhere: freshGame.settings.canPutEverywhere, - loopedBoard: freshGame.settings.loopedBoard - }); - - if (o.isEnded) { - let winner; - if (o.winner === true) { - winner = freshGame.black == 1 ? freshGame.user1Id : freshGame.user2Id; - } else if (o.winner === false) { - winner = freshGame.black == 1 ? freshGame.user2Id : freshGame.user1Id; - } else { - winner = null; - } - - await ReversiGame.update({ - _id: gameId - }, { - $set: { - isEnded: true, - winnerId: winner - } - }); - - publishReversiGameStream(gameId, 'ended', { - winnerId: winner, - game: await pack(gameId, user) - }); - } - //#endregion - - publishReversiGameStream(gameId, 'started', await pack(gameId, user)); - }, 3000); - } - } - - // 石を打つ - async function set(pos: number) { - const game = await ReversiGame.findOne({ _id: gameId }); - - if (!game.isStarted) return; - if (game.isEnded) return; - if (!game.user1Id.equals(user._id) && !game.user2Id.equals(user._id)) return; - - const o = new Reversi(game.settings.map, { - isLlotheo: game.settings.isLlotheo, - canPutEverywhere: game.settings.canPutEverywhere, - loopedBoard: game.settings.loopedBoard - }); - - game.logs.forEach(log => { - o.put(log.color, log.pos); - }); - - const myColor = - (game.user1Id.equals(user._id) && game.black == 1) || (game.user2Id.equals(user._id) && game.black == 2) - ? true - : false; - - if (!o.canPut(myColor, pos)) return; - o.put(myColor, pos); - - let winner; - if (o.isEnded) { - if (o.winner === true) { - winner = game.black == 1 ? game.user1Id : game.user2Id; - } else if (o.winner === false) { - winner = game.black == 1 ? game.user2Id : game.user1Id; - } else { - winner = null; - } - } - - const log = { - at: new Date(), - color: myColor, - pos - }; - - const crc32 = CRC32.str(game.logs.map(x => x.pos.toString()).join('') + pos.toString()); - - await ReversiGame.update({ - _id: gameId - }, { - $set: { - crc32, - isEnded: o.isEnded, - winnerId: winner - }, - $push: { - logs: log - } - }); - - publishReversiGameStream(gameId, 'set', Object.assign(log, { - next: o.turn - })); - - if (o.isEnded) { - publishReversiGameStream(gameId, 'ended', { - winnerId: winner, - game: await pack(gameId, user) - }); - } - } - - async function check(crc32: string) { - const game = await ReversiGame.findOne({ _id: gameId }); - - if (!game.isStarted) return; - - // 互換性のため - if (game.crc32 == null) return; - - if (crc32 !== game.crc32) { - connection.send(JSON.stringify({ - type: 'rescue', - body: await pack(game, user) - })); - } - } -} diff --git a/src/server/api/stream/games/reversi.ts b/src/server/api/stream/games/reversi.ts deleted file mode 100644 index f467613b21..0000000000 --- a/src/server/api/stream/games/reversi.ts +++ /dev/null @@ -1,28 +0,0 @@ -import * as mongo from 'mongodb'; -import * as websocket from 'websocket'; -import Xev from 'xev'; -import Matching, { pack } from '../../../../models/games/reversi/matching'; -import { publishUserStream } from '../../../../stream'; - -export default function(request: websocket.request, connection: websocket.connection, subscriber: Xev, user: any): void { - // Subscribe reversi stream - subscriber.on(`reversi-stream:${user._id}`, data => { - connection.send(JSON.stringify(data)); - }); - - connection.on('message', async (data) => { - const msg = JSON.parse(data.utf8Data); - - switch (msg.type) { - case 'ping': - if (msg.id == null) return; - const matching = await Matching.findOne({ - parentId: user._id, - childId: new mongo.ObjectID(msg.id) - }); - if (matching == null) return; - publishUserStream(matching.childId, 'reversi_invited', await pack(matching, matching.childId)); - break; - } - }); -} diff --git a/src/server/api/stream/global-timeline.ts b/src/server/api/stream/global-timeline.ts deleted file mode 100644 index 4786450cbb..0000000000 --- a/src/server/api/stream/global-timeline.ts +++ /dev/null @@ -1,35 +0,0 @@ -import * as websocket from 'websocket'; -import Xev from 'xev'; - -import { IUser } from '../../../models/user'; -import Mute from '../../../models/mute'; - -export default async function( - request: websocket.request, - connection: websocket.connection, - subscriber: Xev, - user: IUser -) { - const mute = await Mute.find({ muterId: user._id }); - const mutedUserIds = mute.map(m => m.muteeId.toString()); - - // Subscribe stream - subscriber.on('global-timeline', async note => { - //#region 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する - if (mutedUserIds.indexOf(note.userId) != -1) { - return; - } - if (note.reply != null && mutedUserIds.indexOf(note.reply.userId) != -1) { - return; - } - if (note.renote != null && mutedUserIds.indexOf(note.renote.userId) != -1) { - return; - } - //#endregion - - connection.send(JSON.stringify({ - type: 'note', - body: note - })); - }); -} diff --git a/src/server/api/stream/home.ts b/src/server/api/stream/home.ts deleted file mode 100644 index dc3ce9d19f..0000000000 --- a/src/server/api/stream/home.ts +++ /dev/null @@ -1,113 +0,0 @@ -import * as websocket from 'websocket'; -import Xev from 'xev'; -import * as debug from 'debug'; - -import User, { IUser } from '../../../models/user'; -import Mute from '../../../models/mute'; -import { pack as packNote, pack } from '../../../models/note'; -import readNotification from '../common/read-notification'; -import call from '../call'; -import { IApp } from '../../../models/app'; - -const log = debug('misskey'); - -export default async function( - request: websocket.request, - connection: websocket.connection, - subscriber: Xev, - user: IUser, - app: IApp -) { - const mute = await Mute.find({ muterId: user._id }); - const mutedUserIds = mute.map(m => m.muteeId.toString()); - - async function onNoteStream(noteId: any) { - const note = await packNote(noteId, user, { - detail: true - }); - - connection.send(JSON.stringify({ - type: 'note-updated', - body: { - note: note - } - })); - } - - // Subscribe Home stream channel - subscriber.on(`user-stream:${user._id}`, async x => { - //#region 流れてきたメッセージがミュートしているユーザーが関わるものだったら無視する - if (x.type == 'note') { - if (mutedUserIds.includes(x.body.userId)) { - return; - } - if (x.body.reply != null && mutedUserIds.includes(x.body.reply.userId)) { - return; - } - if (x.body.renote != null && mutedUserIds.includes(x.body.renote.userId)) { - return; - } - } else if (x.type == 'notification') { - if (mutedUserIds.includes(x.body.userId)) { - return; - } - } - //#endregion - - // Renoteなら再pack - if (x.type == 'note' && x.body.renoteId != null) { - x.body.renote = await pack(x.body.renoteId, user, { - detail: true - }); - } - - connection.send(JSON.stringify(x)); - }); - - connection.on('message', async data => { - const msg = JSON.parse(data.utf8Data); - - switch (msg.type) { - case 'api': - // 新鮮なデータを利用するためにユーザーをフェッチ - 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 } - })); - }).catch(e => { - connection.send(JSON.stringify({ - type: `api-res:${msg.id}`, - body: { e } - })); - }); - break; - - case 'alive': - // Update lastUsedAt - User.update({ _id: user._id }, { - $set: { - 'lastUsedAt': new Date() - } - }); - break; - - case 'read_notification': - if (!msg.id) return; - readNotification(user._id, msg.id); - break; - - case 'capture': - if (!msg.id) return; - log(`CAPTURE: ${msg.id} by @${user.username}`); - subscriber.on(`note-stream:${msg.id}`, onNoteStream); - break; - - case 'decapture': - if (!msg.id) return; - log(`DECAPTURE: ${msg.id} by @${user.username}`); - subscriber.off(`note-stream:${msg.id}`, onNoteStream); - break; - } - }); -} diff --git a/src/server/api/stream/hybrid-timeline.ts b/src/server/api/stream/hybrid-timeline.ts deleted file mode 100644 index c401145abe..0000000000 --- a/src/server/api/stream/hybrid-timeline.ts +++ /dev/null @@ -1,46 +0,0 @@ -import * as websocket from 'websocket'; -import Xev from 'xev'; - -import { IUser } from '../../../models/user'; -import Mute from '../../../models/mute'; -import { pack } from '../../../models/note'; - -export default async function( - request: websocket.request, - connection: websocket.connection, - subscriber: Xev, - user: IUser -) { - const mute = await Mute.find({ muterId: user._id }); - const mutedUserIds = mute.map(m => m.muteeId.toString()); - - // Subscribe stream - subscriber.on('hybrid-timeline', onEvent); - subscriber.on(`hybrid-timeline:${user._id}`, onEvent); - - async function onEvent(note: any) { - //#region 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する - if (mutedUserIds.indexOf(note.userId) != -1) { - return; - } - if (note.reply != null && mutedUserIds.indexOf(note.reply.userId) != -1) { - return; - } - if (note.renote != null && mutedUserIds.indexOf(note.renote.userId) != -1) { - return; - } - //#endregion - - // Renoteなら再pack - if (note.renoteId != null) { - note.renote = await pack(note.renoteId, user, { - detail: true - }); - } - - connection.send(JSON.stringify({ - type: 'note', - body: note - })); - } -} diff --git a/src/server/api/stream/index.ts b/src/server/api/stream/index.ts new file mode 100644 index 0000000000..ef6397fcd9 --- /dev/null +++ b/src/server/api/stream/index.ts @@ -0,0 +1,220 @@ +import autobind from 'autobind-decorator'; +import * as websocket from 'websocket'; +import Xev from 'xev'; +import * as debug from 'debug'; + +import User, { IUser } from '../../../models/user'; +import readNotification from '../common/read-notification'; +import call from '../call'; +import { IApp } from '../../../models/app'; +import readNote from '../../../services/note/read'; + +import Channel from './channel'; +import channels from './channels'; + +const log = debug('misskey'); + +/** + * Main stream connection + */ +export default class Connection { + public user?: IUser; + public app: IApp; + private wsConnection: websocket.connection; + public subscriber: Xev; + private channels: Channel[] = []; + private subscribingNotes: any = {}; + public sendMessageToWsOverride: any = null; // 後方互換性のため + + constructor( + wsConnection: websocket.connection, + subscriber: Xev, + user: IUser, + app: IApp + ) { + this.wsConnection = wsConnection; + this.user = user; + this.app = app; + this.subscriber = subscriber; + + this.wsConnection.on('message', this.onWsConnectionMessage); + } + + /** + * クライアントからメッセージ受信時 + */ + @autobind + private async onWsConnectionMessage(data: websocket.IMessage) { + const { type, body } = JSON.parse(data.utf8Data); + + switch (type) { + case 'api': this.onApiRequest(body); break; + case 'alive': this.onAlive(); break; + case 'readNotification': this.onReadNotification(body); break; + case 'subNote': this.onSubscribeNote(body); break; + case 'sn': this.onSubscribeNote(body); break; // alias + case 'unsubNote': this.onUnsubscribeNote(body); break; + case 'un': this.onUnsubscribeNote(body); break; // alias + case 'connect': this.onChannelConnectRequested(body); break; + case 'disconnect': this.onChannelDisconnectRequested(body); break; + case 'channel': this.onChannelMessageRequested(body); break; + } + } + + /** + * APIリクエスト要求時 + */ + @autobind + private async onApiRequest(payload: any) { + // 新鮮なデータを利用するためにユーザーをフェッチ + const user = this.user ? await User.findOne({ _id: this.user._id }) : null; + + const endpoint = payload.endpoint || payload.ep; // alias + + // 呼び出し + call(endpoint, user, this.app, payload.data).then(res => { + this.sendMessageToWs(`api:${payload.id}`, { res }); + }).catch(e => { + this.sendMessageToWs(`api:${payload.id}`, { e }); + }); + } + + @autobind + private onAlive() { + // Update lastUsedAt + User.update({ _id: this.user._id }, { + $set: { + 'lastUsedAt': new Date() + } + }); + } + + @autobind + private onReadNotification(payload: any) { + if (!payload.id) return; + readNotification(this.user._id, payload.id); + } + + /** + * 投稿購読要求時 + */ + @autobind + private onSubscribeNote(payload: any) { + if (!payload.id) return; + + if (this.subscribingNotes[payload.id] == null) { + this.subscribingNotes[payload.id] = 0; + } + + this.subscribingNotes[payload.id]++; + + if (this.subscribingNotes[payload.id] == 1) { + this.subscriber.on(`noteStream:${payload.id}`, this.onNoteStreamMessage); + } + + if (payload.read) { + readNote(this.user._id, payload.id); + } + } + + /** + * 投稿購読解除要求時 + */ + @autobind + private onUnsubscribeNote(payload: any) { + if (!payload.id) return; + + this.subscribingNotes[payload.id]--; + if (this.subscribingNotes[payload.id] <= 0) { + delete this.subscribingNotes[payload.id]; + this.subscriber.off(`noteStream:${payload.id}`, this.onNoteStreamMessage); + } + } + + @autobind + private async onNoteStreamMessage(data: any) { + this.sendMessageToWs('noteUpdated', { + id: data.body.id, + type: data.type, + body: data.body.body, + }); + } + + /** + * チャンネル接続要求時 + */ + @autobind + private onChannelConnectRequested(payload: any) { + const { channel, id, params } = payload; + log(`CH CONNECT: ${id} ${channel} by @${this.user.username}`); + this.connectChannel(id, params, (channels as any)[channel]); + } + + /** + * チャンネル切断要求時 + */ + @autobind + private onChannelDisconnectRequested(payload: any) { + const { id } = payload; + log(`CH DISCONNECT: ${id} by @${this.user.username}`); + this.disconnectChannel(id); + } + + /** + * クライアントにメッセージ送信 + */ + @autobind + public sendMessageToWs(type: string, payload: any) { + if (this.sendMessageToWsOverride) return this.sendMessageToWsOverride(type, payload); // 後方互換性のため + this.wsConnection.send(JSON.stringify({ + type: type, + body: payload + })); + } + + /** + * チャンネルに接続 + */ + @autobind + public connectChannel(id: string, params: any, channelClass: { new(id: string, connection: Connection): Channel }) { + const channel = new channelClass(id, this); + this.channels.push(channel); + channel.init(params); + } + + /** + * チャンネルから切断 + * @param id チャンネルコネクションID + */ + @autobind + public disconnectChannel(id: string) { + const channel = this.channels.find(c => c.id === id); + + if (channel) { + if (channel.dispose) channel.dispose(); + this.channels = this.channels.filter(c => c.id !== id); + } + } + + /** + * チャンネルへメッセージ送信要求時 + * @param data メッセージ + */ + @autobind + private onChannelMessageRequested(data: any) { + const channel = this.channels.find(c => c.id === data.id); + if (channel != null && channel.onMessage != null) { + channel.onMessage(data.type, data.body); + } + } + + /** + * ストリームが切れたとき + */ + @autobind + public dispose() { + this.channels.forEach(c => { + if (c.dispose) c.dispose(); + }); + } +} diff --git a/src/server/api/stream/local-timeline.ts b/src/server/api/stream/local-timeline.ts deleted file mode 100644 index 82060a7aaa..0000000000 --- a/src/server/api/stream/local-timeline.ts +++ /dev/null @@ -1,43 +0,0 @@ -import * as websocket from 'websocket'; -import Xev from 'xev'; - -import { IUser } from '../../../models/user'; -import Mute from '../../../models/mute'; -import { pack } from '../../../models/note'; - -export default async function( - request: websocket.request, - connection: websocket.connection, - subscriber: Xev, - user: IUser -) { - const mute = await Mute.find({ muterId: user._id }); - const mutedUserIds = mute.map(m => m.muteeId.toString()); - - // Subscribe stream - subscriber.on('local-timeline', async note => { - //#region 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する - if (mutedUserIds.indexOf(note.userId) != -1) { - return; - } - if (note.reply != null && mutedUserIds.indexOf(note.reply.userId) != -1) { - return; - } - if (note.renote != null && mutedUserIds.indexOf(note.renote.userId) != -1) { - return; - } - //#endregion - - // Renoteなら再pack - if (note.renoteId != null) { - note.renote = await pack(note.renoteId, user, { - detail: true - }); - } - - connection.send(JSON.stringify({ - type: 'note', - body: note - })); - }); -} diff --git a/src/server/api/stream/messaging-index.ts b/src/server/api/stream/messaging-index.ts deleted file mode 100644 index 9af63f2812..0000000000 --- a/src/server/api/stream/messaging-index.ts +++ /dev/null @@ -1,9 +0,0 @@ -import * as websocket from 'websocket'; -import Xev from 'xev'; - -export default function(request: websocket.request, connection: websocket.connection, subscriber: Xev, user: any): void { - // Subscribe messaging index stream - subscriber.on(`messaging-index-stream:${user._id}`, data => { - connection.send(JSON.stringify(data)); - }); -} diff --git a/src/server/api/stream/messaging.ts b/src/server/api/stream/messaging.ts deleted file mode 100644 index 8b352cea3c..0000000000 --- a/src/server/api/stream/messaging.ts +++ /dev/null @@ -1,25 +0,0 @@ -import * as websocket from 'websocket'; -import Xev from 'xev'; -import read from '../common/read-messaging-message'; -import { ParsedUrlQuery } from 'querystring'; - -export default function(request: websocket.request, connection: websocket.connection, subscriber: Xev, user: any): void { - const q = request.resourceURL.query as ParsedUrlQuery; - const otherparty = q.otherparty as string; - - // Subscribe messaging stream - subscriber.on(`messaging-stream:${user._id}-${otherparty}`, data => { - connection.send(JSON.stringify(data)); - }); - - connection.on('message', async (data) => { - const msg = JSON.parse(data.utf8Data); - - switch (msg.type) { - case 'read': - if (!msg.id) return; - read(user._id, otherparty, msg.id); - break; - } - }); -} diff --git a/src/server/api/stream/notes-stats.ts b/src/server/api/stream/notes-stats.ts deleted file mode 100644 index ab00620018..0000000000 --- a/src/server/api/stream/notes-stats.ts +++ /dev/null @@ -1,35 +0,0 @@ -import * as websocket from 'websocket'; -import Xev from 'xev'; - -const ev = new Xev(); - -export default function(request: websocket.request, connection: websocket.connection): void { - const onStats = (stats: any) => { - connection.send(JSON.stringify({ - type: 'stats', - body: stats - })); - }; - - connection.on('message', async data => { - const msg = JSON.parse(data.utf8Data); - - switch (msg.type) { - case 'requestLog': - ev.once('notesStatsLog:' + msg.id, statsLog => { - connection.send(JSON.stringify({ - type: 'statsLog', - body: statsLog - })); - }); - ev.emit('requestNotesStatsLog', msg.id); - break; - } - }); - - ev.addListener('notesStats', onStats); - - connection.on('close', () => { - ev.removeListener('notesStats', onStats); - }); -} diff --git a/src/server/api/stream/server-stats.ts b/src/server/api/stream/server-stats.ts deleted file mode 100644 index f6c1f14ebe..0000000000 --- a/src/server/api/stream/server-stats.ts +++ /dev/null @@ -1,38 +0,0 @@ -import * as websocket from 'websocket'; -import Xev from 'xev'; - -const ev = new Xev(); - -export default function(request: websocket.request, connection: websocket.connection): void { - const onStats = (stats: any) => { - connection.send(JSON.stringify({ - type: 'stats', - body: stats - })); - }; - - connection.on('message', async data => { - const msg = JSON.parse(data.utf8Data); - - switch (msg.type) { - case 'requestLog': - ev.once('serverStatsLog:' + msg.id, statsLog => { - connection.send(JSON.stringify({ - type: 'statsLog', - body: statsLog - })); - }); - ev.emit('requestServerStatsLog', { - id: msg.id, - length: msg.length - }); - break; - } - }); - - ev.addListener('serverStats', onStats); - - connection.on('close', () => { - ev.removeListener('serverStats', onStats); - }); -} diff --git a/src/server/api/stream/user-list.ts b/src/server/api/stream/user-list.ts deleted file mode 100644 index 30f94d5251..0000000000 --- a/src/server/api/stream/user-list.ts +++ /dev/null @@ -1,13 +0,0 @@ -import * as websocket from 'websocket'; -import Xev from 'xev'; -import { ParsedUrlQuery } from 'querystring'; - -export default function(request: websocket.request, connection: websocket.connection, subscriber: Xev, user: any): void { - const q = request.resourceURL.query as ParsedUrlQuery; - const listId = q.listId as string; - - // Subscribe stream - subscriber.on(`user-list-stream:${listId}`, data => { - connection.send(JSON.stringify(data)); - }); -} diff --git a/src/server/api/streaming.ts b/src/server/api/streaming.ts index c8b2d4e0b9..c8c4a8a294 100644 --- a/src/server/api/streaming.ts +++ b/src/server/api/streaming.ts @@ -2,25 +2,13 @@ import * as http from 'http'; import * as websocket from 'websocket'; import Xev from 'xev'; -import homeStream from './stream/home'; -import localTimelineStream from './stream/local-timeline'; -import hybridTimelineStream from './stream/hybrid-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'; -import reversiGameStream from './stream/games/reversi-game'; -import reversiStream from './stream/games/reversi'; -import serverStatsStream from './stream/server-stats'; -import notesStatsStream from './stream/notes-stats'; +import MainStreamConnection from './stream'; import { ParsedUrlQuery } from 'querystring'; import authenticate from './authenticate'; +import channels from './stream/channels'; module.exports = (server: http.Server) => { - /** - * Init websocket server - */ + // Init websocket server const ws = new websocket.server({ httpServer: server }); @@ -28,52 +16,45 @@ module.exports = (server: http.Server) => { ws.on('request', async (request) => { const connection = request.accept(); - if (request.resourceURL.pathname === '/server-stats') { - serverStatsStream(request, connection); - return; - } - - if (request.resourceURL.pathname === '/notes-stats') { - notesStatsStream(request, connection); - return; - } - const ev = new Xev(); - connection.once('close', () => { - ev.removeAllListeners(); - }); - const q = request.resourceURL.query as ParsedUrlQuery; const [user, app] = await authenticate(q.i as string); - if (request.resourceURL.pathname === '/games/reversi-game') { - reversiGameStream(request, connection, ev, user); - return; - } + const main = new MainStreamConnection(connection, ev, user, app); + + // 後方互換性のため + if (request.resourceURL.pathname !== '/streaming') { + main.sendMessageToWsOverride = (type: string, payload: any) => { + if (type == 'channel') { + type = payload.type; + payload = payload.body; + } + if (type.startsWith('api:')) { + type = type.replace('api:', 'api-res:'); + } + connection.send(JSON.stringify({ + type: type, + body: payload + })); + }; - if (user == null) { - connection.send('authentication-failed'); - connection.close(); - return; + main.connectChannel(Math.random().toString(), null, + request.resourceURL.pathname === '/' ? channels.homeTimeline : + request.resourceURL.pathname === '/local-timeline' ? channels.localTimeline : + request.resourceURL.pathname === '/hybrid-timeline' ? channels.hybridTimeline : + request.resourceURL.pathname === '/global-timeline' ? channels.globalTimeline : null); } - const channel: any = - request.resourceURL.pathname === '/' ? homeStream : - request.resourceURL.pathname === '/local-timeline' ? localTimelineStream : - request.resourceURL.pathname === '/hybrid-timeline' ? hybridTimelineStream : - 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 : - request.resourceURL.pathname === '/games/reversi' ? reversiStream : - null; + connection.once('close', () => { + ev.removeAllListeners(); + main.dispose(); + }); - if (channel !== null) { - channel(request, connection, ev, user, app); - } else { - connection.close(); - } + connection.on('message', async (data) => { + if (data.utf8Data == 'ping') { + connection.send('pong'); + } + }); }); }; diff --git a/src/server/index.ts b/src/server/index.ts index f1fcf58c8d..dc60b0d9ec 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -11,11 +11,13 @@ 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 requestStats = require('request-stats'); //const slow = require('koa-slow'); import activityPub from './activitypub'; import webFinger from './webfinger'; import config from '../config'; +import { updateNetworkStats } from '../services/update-chart'; // Init app const app = new Koa(); @@ -81,4 +83,27 @@ export default () => new Promise(resolve => { // Listen server.listen(config.port, resolve); + + //#region Network stats + let queue: any[] = []; + + requestStats(server, (stats: any) => { + if (stats.ok) { + queue.push(stats); + } + }); + + // Bulk write + setInterval(() => { + if (queue.length == 0) return; + + const requests = queue.length; + const time = queue.reduce((a, b) => a + b.time, 0); + const incomingBytes = queue.reduce((a, b) => a + b.req.bytes, 0); + const outgoingBytes = queue.reduce((a, b) => a + b.res.bytes, 0); + queue = []; + + updateNetworkStats(requests, time, incomingBytes, outgoingBytes); + }, 5000); + //#endregion }); diff --git a/src/server/web/docs.ts b/src/server/web/docs.ts index 81e5ace3e8..3432861989 100644 --- a/src/server/web/docs.ts +++ b/src/server/web/docs.ts @@ -162,8 +162,7 @@ const router = new Router(); router.get('/assets/*', async ctx => { await send(ctx, ctx.params[0], { root: `${__dirname}/../../docs/assets/`, - maxage: ms('7 days'), - immutable: true + maxage: ms('1 days') }); }); @@ -196,7 +195,7 @@ router.get('/*/api/entities/*', async ctx => { const lang = ctx.params[0]; const entity = ctx.params[1]; - const x = yaml.safeLoad(fs.readFileSync(path.resolve(__dirname + '/../../../src/docs/api/entities/' + entity + '.yaml'), 'utf-8')) as any; + const x = yaml.safeLoad(fs.readFileSync(path.resolve(`${__dirname}/../../../src/docs/api/entities/${entity}.yaml`), 'utf-8')) as any; await ctx.render('../../../../src/docs/api/entities/view', Object.assign(await genVars(lang), { id: `api/entities/${entity}`, diff --git a/src/server/web/index.ts b/src/server/web/index.ts index 452e36fe95..e7332f4230 100644 --- a/src/server/web/index.ts +++ b/src/server/web/index.ts @@ -63,7 +63,7 @@ router.get('/apple-touch-icon.png', async ctx => { }); }); -// ServiceWroker +// ServiceWorker router.get(/^\/sw\.(.+?)\.js$/, async ctx => { await send(ctx, `/assets/sw.${ctx.params[0]}.js`, { root: client diff --git a/src/server/web/views/note.pug b/src/server/web/views/note.pug index 22f1834059..234ecabe22 100644 --- a/src/server/web/views/note.pug +++ b/src/server/web/views/note.pug @@ -6,7 +6,7 @@ block vars - const url = `${config.url}/notes/${note.id}`; block title - = `${title} | Misskey` + = `${title} | ${config.name}` block desc meta(name='description' content= summary) @@ -23,3 +23,6 @@ block meta link(rel='prev' href=`${config.url}/notes/${note.prev}`) if note.next link(rel='next' href=`${config.url}/notes/${note.next}`) + + if !user.host + link(rel='alternate' href=url type='application/activity+json') diff --git a/src/server/web/views/user.pug b/src/server/web/views/user.pug index b5ea2f6eb4..506a889d98 100644 --- a/src/server/web/views/user.pug +++ b/src/server/web/views/user.pug @@ -2,11 +2,11 @@ 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 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` + = `${title} | ${config.name}` block desc meta(name='description' content= user.description) @@ -18,3 +18,10 @@ block meta meta(property='og:description' content= user.description) meta(property='og:url' content= url) meta(property='og:image' content= img) + + if !user.host + link(rel='alternate' href=`${config.url}/users/${user._id}` type='application/activity+json') + if user.uri + link(rel='alternate' href=user.uri type='application/activity+json') + if user.url + link(rel='alternate' href=user.url type='text/html') diff --git a/src/services/drive/add-file.ts b/src/services/drive/add-file.ts index 1da0f49a24..f8c54b2af4 100644 --- a/src/services/drive/add-file.ts +++ b/src/services/drive/add-file.ts @@ -12,7 +12,7 @@ import * as sharp from 'sharp'; import DriveFile, { IMetadata, getDriveFileBucket, IDriveFile } from '../../models/drive-file'; import DriveFolder from '../../models/drive-folder'; import { pack } from '../../models/drive-file'; -import { publishUserStream, publishDriveStream } from '../../stream'; +import { publishMainStream, publishDriveStream } from '../../stream'; import { isLocalUser, IUser, IRemoteUser } from '../../models/user'; import delFile from './delete-file'; import config from '../../config'; @@ -36,11 +36,14 @@ async function save(path: string, name: string, type: string, hash: string, size if (config.drive && config.drive.storage == 'minio') { const minio = new Minio.Client(config.drive.config); - const key = `${config.drive.prefix}/${uuid.v4()}/${name}`; - const thumbnailKey = `${config.drive.prefix}/${uuid.v4()}/${name}.thumbnail.jpg`; + + const keyDir = `${config.drive.prefix}/${uuid.v4()}`; + const key = `${keyDir}/${name}`; + const thumbnailKeyDir = `${config.drive.prefix}/${uuid.v4()}`; + const thumbnailKey = `${thumbnailKeyDir}/${name}.thumbnail.jpg`; const baseUrl = config.drive.baseUrl - || `${ config.drive.config.useSSL ? 'https' : 'http' }://${ config.drive.config.endPoint }${ config.drive.config.port ? ':' + config.drive.config.port : '' }/${ config.drive.bucket }`; + || `${ config.drive.config.useSSL ? 'https' : 'http' }://${ config.drive.config.endPoint }${ config.drive.config.port ? `:${config.drive.config.port}` : '' }/${ config.drive.bucket }`; await minio.putObject(config.drive.bucket, key, fs.createReadStream(path), size, { 'Content-Type': type, @@ -61,8 +64,8 @@ async function save(path: string, name: string, type: string, hash: string, size key: key, thumbnailKey: thumbnailKey }, - url: `${ baseUrl }/${ key }`, - thumbnailUrl: thumbnail ? `${ baseUrl }/${ thumbnailKey }` : null + url: `${ baseUrl }/${ keyDir }/${ encodeURIComponent(name) }`, + thumbnailUrl: thumbnail ? `${ baseUrl }/${ thumbnailKeyDir }/${ encodeURIComponent(name) }.thumbnail.jpg` : null }); const file = await DriveFile.insert({ @@ -150,7 +153,7 @@ export default async function( isLink: boolean = false, url: string = null, uri: string = null, - sensitive = false + sensitive: boolean = null ): Promise<IDriveFile> { // Calc md5 hash const calcHash = new Promise<string>((res, rej) => { @@ -326,7 +329,13 @@ export default async function( properties: properties, withoutChunks: isLink, isRemote: isLink, - isSensitive: sensitive + isSensitive: (sensitive !== null && sensitive !== undefined) + ? sensitive + : isLocalUser(user) + ? user.settings.alwaysMarkNsfw + ? true + : false + : false } as IMetadata; if (url !== null) { @@ -374,8 +383,8 @@ export default async function( log(`drive file has been created ${driveFile._id}`); pack(driveFile).then(packedFile => { - // Publish drive_file_created event - publishUserStream(user._id, 'drive_file_created', packedFile); + // Publish driveFileCreated event + publishMainStream(user._id, 'driveFileCreated', packedFile); publishDriveStream(user._id, 'file_created', packedFile); }); diff --git a/src/services/drive/upload-from-url.ts b/src/services/drive/upload-from-url.ts index 4e297d3bb1..35d4ec9883 100644 --- a/src/services/drive/upload-from-url.ts +++ b/src/services/drive/upload-from-url.ts @@ -34,7 +34,13 @@ export default async (url: string, user: IUser, folderId: mongodb.ObjectID = nul // write content at URL to temp file await new Promise((res, rej) => { const writable = fs.createWriteStream(path); - request(url) + const requestUrl = URL.parse(url).pathname.match(/[^\u0021-\u00ff]/) ? encodeURI(url) : url; + request({ + url: requestUrl, + headers: { + 'User-Agent': config.user_agent + } + }) .on('error', rej) .on('end', () => { writable.close(); diff --git a/src/services/following/create.ts b/src/services/following/create.ts index bd39b8e183..637e3e8093 100644 --- a/src/services/following/create.ts +++ b/src/services/following/create.ts @@ -2,7 +2,7 @@ import User, { isLocalUser, isRemoteUser, pack as packUser, IUser } from '../../ import Following from '../../models/following'; import FollowingLog from '../../models/following-log'; import FollowedLog from '../../models/followed-log'; -import { publishUserStream } from '../../stream'; +import { publishMainStream } from '../../stream'; import notify from '../../notify'; import pack from '../../remote/activitypub/renderer'; import renderFollow from '../../remote/activitypub/renderer/follow'; @@ -11,7 +11,7 @@ import { deliver } from '../../queue'; import createFollowRequest from './requests/create'; export default async function(follower: IUser, followee: IUser) { - if (followee.isLocked) { + if (followee.isLocked || isLocalUser(follower) && isRemoteUser(followee)) { await createFollowRequest(follower, followee); } else { const following = await Following.insert({ @@ -61,22 +61,17 @@ export default async function(follower: IUser, followee: IUser) { // Publish follow event if (isLocalUser(follower)) { - packUser(followee, follower).then(packed => publishUserStream(follower._id, 'follow', packed)); + packUser(followee, follower).then(packed => publishMainStream(follower._id, 'follow', packed)); } // Publish followed event if (isLocalUser(followee)) { - packUser(follower, followee).then(packed => publishUserStream(followee._id, 'followed', packed)), + packUser(follower, followee).then(packed => publishMainStream(followee._id, 'followed', packed)), // 通知を作成 notify(followee._id, follower._id, 'follow'); } - if (isLocalUser(follower) && isRemoteUser(followee)) { - const content = pack(renderFollow(follower, followee)); - deliver(follower, content, followee.inbox); - } - if (isRemoteUser(follower) && isLocalUser(followee)) { const content = pack(renderAccept(renderFollow(follower, followee))); deliver(followee, content, follower.inbox); diff --git a/src/services/following/delete.ts b/src/services/following/delete.ts index 7c285e9eac..2a67acbf05 100644 --- a/src/services/following/delete.ts +++ b/src/services/following/delete.ts @@ -2,7 +2,7 @@ import User, { isLocalUser, isRemoteUser, pack as packUser, IUser } from '../../ import Following from '../../models/following'; import FollowingLog from '../../models/following-log'; import FollowedLog from '../../models/followed-log'; -import { publishUserStream } from '../../stream'; +import { publishMainStream } from '../../stream'; import pack from '../../remote/activitypub/renderer'; import renderFollow from '../../remote/activitypub/renderer/follow'; import renderUndo from '../../remote/activitypub/renderer/undo'; @@ -52,7 +52,7 @@ export default async function(follower: IUser, followee: IUser) { // Publish unfollow event if (isLocalUser(follower)) { - packUser(followee, follower).then(packed => publishUserStream(follower._id, 'unfollow', packed)); + packUser(followee, follower).then(packed => publishMainStream(follower._id, 'unfollow', packed)); } if (isLocalUser(follower) && isRemoteUser(followee)) { diff --git a/src/services/following/requests/accept.ts b/src/services/following/requests/accept.ts index bf8ed99e13..e7c8df844a 100644 --- a/src/services/following/requests/accept.ts +++ b/src/services/following/requests/accept.ts @@ -7,7 +7,7 @@ import { deliver } from '../../../queue'; import Following from '../../../models/following'; import FollowingLog from '../../../models/following-log'; import FollowedLog from '../../../models/followed-log'; -import { publishUserStream } from '../../../stream'; +import { publishMainStream } from '../../../stream'; export default async function(followee: IUser, follower: IUser) { const following = await Following.insert({ @@ -74,5 +74,7 @@ export default async function(followee: IUser, follower: IUser) { packUser(followee, followee, { detail: true - }).then(packed => publishUserStream(followee._id, 'meUpdated', packed)); + }).then(packed => publishMainStream(followee._id, 'meUpdated', packed)); + + packUser(followee, follower).then(packed => publishMainStream(follower._id, 'follow', packed)); } diff --git a/src/services/following/requests/cancel.ts b/src/services/following/requests/cancel.ts index 9655a95f04..def02d59d9 100644 --- a/src/services/following/requests/cancel.ts +++ b/src/services/following/requests/cancel.ts @@ -4,7 +4,7 @@ import pack from '../../../remote/activitypub/renderer'; import renderFollow from '../../../remote/activitypub/renderer/follow'; import renderUndo from '../../../remote/activitypub/renderer/undo'; import { deliver } from '../../../queue'; -import { publishUserStream } from '../../../stream'; +import { publishMainStream } from '../../../stream'; export default async function(followee: IUser, follower: IUser) { if (isRemoteUser(followee)) { @@ -34,5 +34,5 @@ export default async function(followee: IUser, follower: IUser) { packUser(followee, followee, { detail: true - }).then(packed => publishUserStream(followee._id, 'meUpdated', packed)); + }).then(packed => publishMainStream(followee._id, 'meUpdated', packed)); } diff --git a/src/services/following/requests/create.ts b/src/services/following/requests/create.ts index 4c7c90cc08..5e613fd053 100644 --- a/src/services/following/requests/create.ts +++ b/src/services/following/requests/create.ts @@ -1,5 +1,5 @@ import User, { isLocalUser, isRemoteUser, pack as packUser, IUser } from '../../../models/user'; -import { publishUserStream } from '../../../stream'; +import { publishMainStream } from '../../../stream'; import notify from '../../../notify'; import pack from '../../../remote/activitypub/renderer'; import renderFollow from '../../../remote/activitypub/renderer/follow'; @@ -7,8 +7,6 @@ import { deliver } from '../../../queue'; import FollowRequest from '../../../models/follow-request'; export default async function(follower: IUser, followee: IUser) { - if (!followee.isLocked) throw '対象のアカウントは鍵アカウントではありません'; - await FollowRequest.insert({ createdAt: new Date(), followerId: follower._id, @@ -35,11 +33,11 @@ export default async function(follower: IUser, followee: IUser) { // Publish receiveRequest event if (isLocalUser(followee)) { - packUser(follower, followee).then(packed => publishUserStream(followee._id, 'receiveFollowRequest', packed)); + packUser(follower, followee).then(packed => publishMainStream(followee._id, 'receiveFollowRequest', packed)); packUser(followee, followee, { detail: true - }).then(packed => publishUserStream(followee._id, 'meUpdated', packed)); + }).then(packed => publishMainStream(followee._id, 'meUpdated', packed)); // 通知を作成 notify(followee._id, follower._id, 'receiveFollowRequest'); diff --git a/src/services/following/requests/reject.ts b/src/services/following/requests/reject.ts index affcd2ef5a..91a49db997 100644 --- a/src/services/following/requests/reject.ts +++ b/src/services/following/requests/reject.ts @@ -1,9 +1,10 @@ -import User, { IUser, isRemoteUser, ILocalUser } from '../../../models/user'; +import User, { IUser, isRemoteUser, ILocalUser, pack as packUser } from '../../../models/user'; import FollowRequest from '../../../models/follow-request'; import pack from '../../../remote/activitypub/renderer'; import renderFollow from '../../../remote/activitypub/renderer/follow'; import renderReject from '../../../remote/activitypub/renderer/reject'; import { deliver } from '../../../queue'; +import { publishMainStream } from '../../../stream'; export default async function(followee: IUser, follower: IUser) { if (isRemoteUser(follower)) { @@ -21,4 +22,6 @@ export default async function(followee: IUser, follower: IUser) { pendingReceivedFollowRequestsCount: -1 } }); + + packUser(followee, follower).then(packed => publishMainStream(follower._id, 'unfollow', packed)); } diff --git a/src/services/i/pin.ts b/src/services/i/pin.ts new file mode 100644 index 0000000000..ff390eb781 --- /dev/null +++ b/src/services/i/pin.ts @@ -0,0 +1,130 @@ +import config from '../../config'; +import * as mongo from 'mongodb'; +import User, { isLocalUser, isRemoteUser, ILocalUser, IUser } from '../../models/user'; +import Note from '../../models/note'; +import Following from '../../models/following'; +import renderAdd from '../../remote/activitypub/renderer/add'; +import renderRemove from '../../remote/activitypub/renderer/remove'; +import packAp from '../../remote/activitypub/renderer'; +import { deliver } from '../../queue'; + +/** + * 指定した投稿をピン留めします + * @param user + * @param noteId + */ +export async function addPinned(user: IUser, noteId: mongo.ObjectID) { + // Fetch pinee + const note = await Note.findOne({ + _id: noteId, + userId: user._id + }); + + if (note === null) { + throw new Error('note not found'); + } + + let pinnedNoteIds = user.pinnedNoteIds || []; + + //#region 現在ピン留め投稿している投稿が実際にデータベースに存在しているのかチェック + // データベースの欠損などで存在していない場合があるので。 + // 存在していなかったらピン留め投稿から外す + const pinnedNotes = (await Promise.all(pinnedNoteIds.map(id => Note.findOne({ _id: id })))).filter(x => x != null); + + pinnedNoteIds = pinnedNoteIds.filter(id => pinnedNotes.some(n => n._id.equals(id))); + //#endregion + + if (pinnedNoteIds.length >= 5) { + throw new Error('cannot pin more notes'); + } + + if (pinnedNoteIds.some(id => id.equals(note._id))) { + throw new Error('already exists'); + } + + pinnedNoteIds.unshift(note._id); + + await User.update(user._id, { + $set: { + pinnedNoteIds: pinnedNoteIds + } + }); + + // Deliver to remote followers + if (isLocalUser(user)) { + deliverPinnedChange(user._id, note._id, true); + } +} + +/** + * 指定した投稿のピン留めを解除します + * @param user + * @param noteId + */ +export async function removePinned(user: IUser, noteId: mongo.ObjectID) { + // Fetch unpinee + const note = await Note.findOne({ + _id: noteId, + userId: user._id + }); + + if (note === null) { + throw new Error('note not found'); + } + + const pinnedNoteIds = (user.pinnedNoteIds || []).filter(id => !id.equals(note._id)); + + await User.update(user._id, { + $set: { + pinnedNoteIds: pinnedNoteIds + } + }); + + // Deliver to remote followers + if (isLocalUser(user)) { + deliverPinnedChange(user._id, noteId, false); + } +} + +export async function deliverPinnedChange(userId: mongo.ObjectID, noteId: mongo.ObjectID, isAddition: boolean) { + const user = await User.findOne({ + _id: userId + }); + + if (!isLocalUser(user)) return; + + const queue = await CreateRemoteInboxes(user); + + if (queue.length < 1) return; + + const target = `${config.url}/users/${user._id}/collections/featured`; + + const item = `${config.url}/notes/${noteId}`; + const content = packAp(isAddition ? renderAdd(user, target, item) : renderRemove(user, target, item)); + queue.forEach(inbox => { + deliver(user, content, inbox); + }); +} + +/** + * ローカルユーザーのリモートフォロワーのinboxリストを作成する + * @param user ローカルユーザー + */ +async function CreateRemoteInboxes(user: ILocalUser): Promise<string[]> { + const followers = await Following.find({ + followeeId: user._id + }); + + const queue: string[] = []; + + followers.map(following => { + const follower = following._follower; + + if (isRemoteUser(follower)) { + const inbox = follower.sharedInbox || follower.inbox; + if (!queue.includes(inbox)) queue.push(inbox); + } + }); + + return queue; +} diff --git a/src/services/i/update.ts b/src/services/i/update.ts new file mode 100644 index 0000000000..25b55b0355 --- /dev/null +++ b/src/services/i/update.ts @@ -0,0 +1,38 @@ +import * as mongo from 'mongodb'; +import User, { isLocalUser, isRemoteUser } from '../../models/user'; +import Following from '../../models/following'; +import renderPerson from '../../remote/activitypub/renderer/person'; +import renderUpdate from '../../remote/activitypub/renderer/update'; +import packAp from '../../remote/activitypub/renderer'; +import { deliver } from '../../queue'; + +export async function publishToFollowers(userId: mongo.ObjectID) { + const user = await User.findOne({ + _id: userId + }); + + const followers = await Following.find({ + followeeId: user._id + }); + + const queue: string[] = []; + + // フォロワーがリモートユーザーかつ投稿者がローカルユーザーならUpdateを配信 + if (isLocalUser(user)) { + followers.map(following => { + const follower = following._follower; + + if (isRemoteUser(follower)) { + const inbox = follower.sharedInbox || follower.inbox; + if (!queue.includes(inbox)) queue.push(inbox); + } + }); + + if (queue.length > 0) { + const content = packAp(renderUpdate(await renderPerson(user), user)); + queue.forEach(inbox => { + deliver(user, content, inbox); + }); + } + } +} diff --git a/src/services/note/create.ts b/src/services/note/create.ts index 63e3557828..3dc411d434 100644 --- a/src/services/note/create.ts +++ b/src/services/note/create.ts @@ -1,7 +1,7 @@ import es from '../../db/elasticsearch'; import Note, { pack, INote } from '../../models/note'; import User, { isLocalUser, IUser, isRemoteUser, IRemoteUser, ILocalUser } from '../../models/user'; -import { publishUserStream, publishLocalTimelineStream, publishHybridTimelineStream, publishGlobalTimelineStream, publishUserListStream } from '../../stream'; +import { publishMainStream, publishHomeTimelineStream, publishLocalTimelineStream, publishHybridTimelineStream, publishGlobalTimelineStream, publishUserListStream, publishHashtagStream } from '../../stream'; import Following from '../../models/following'; import { deliver } from '../../queue'; import renderNote from '../../remote/activitypub/renderer/note'; @@ -24,6 +24,8 @@ import isQuote from '../../misc/is-quote'; import { TextElementMention } from '../../mfm/parse/elements/mention'; import { TextElementHashtag } from '../../mfm/parse/elements/hashtag'; import { updateNoteStats } from '../update-chart'; +import { erase, unique } from '../../prelude/array'; +import insertNoteUnread from './unread'; type NotificationType = 'reply' | 'renote' | 'quote' | 'mention'; @@ -84,7 +86,7 @@ type Option = { text?: string; reply?: INote; renote?: INote; - media?: IDriveFile[]; + files?: IDriveFile[]; geo?: any; poll?: any; viaMobile?: boolean; @@ -103,23 +105,30 @@ export default async (user: IUser, data: Option, silent = false) => new Promise< if (data.viaMobile == null) data.viaMobile = false; if (data.visibleUsers) { - data.visibleUsers = data.visibleUsers.filter(x => x != null); + data.visibleUsers = erase(null, data.visibleUsers); } + // リプライ対象が削除された投稿だったらreject if (data.reply && data.reply.deletedAt != null) { return rej(); } + // Renote対象が削除された投稿だったらreject if (data.renote && data.renote.deletedAt != null) { return rej(); } - // リプライ先が自分以外の非公開の投稿なら禁止 + // Renote対象が「ホームまたは全体」以外の公開範囲ならreject + if (data.renote && data.renote.visibility != 'public' && data.renote.visibility != 'home') { + return rej(); + } + + // リプライ対象が自分以外の非公開の投稿なら禁止 if (data.reply && data.reply.visibility == 'private' && !data.reply.userId.equals(user._id)) { return rej(); } - // Renote先が自分以外の非公開の投稿なら禁止 + // Renote対象が自分以外の非公開の投稿なら禁止 if (data.renote && data.renote.visibility == 'private' && !data.renote.userId.equals(user._id)) { return rej(); } @@ -135,7 +144,19 @@ export default async (user: IUser, data: Option, silent = false) => new Promise< const mentionedUsers = await extractMentionedUsers(tokens); - const note = await insertNote(user, data, tokens, tags, mentionedUsers); + if (data.reply && !user._id.equals(data.reply.userId) && !mentionedUsers.some(u => u._id.equals(data.reply.userId))) { + mentionedUsers.push(await User.findOne({ _id: data.reply.userId })); + } + + if (data.visibility == 'specified') { + data.visibleUsers.forEach(u => { + if (!mentionedUsers.some(x => x._id.equals(u._id))) { + mentionedUsers.push(u); + } + }); + } + + const note = await insertNote(user, data, tags, mentionedUsers); res(note); @@ -155,6 +176,17 @@ export default async (user: IUser, data: Option, silent = false) => new Promise< // Increment notes count (user) incNotesCountOfUser(user); + // 未読通知を作成 + if (data.visibility == 'specified') { + data.visibleUsers.forEach(u => { + insertNoteUnread(u, note, true); + }); + } else { + mentionedUsers.forEach(u => { + insertNoteUnread(u, note, false); + }); + } + if (data.reply) { saveReply(data.reply, note); } @@ -174,14 +206,18 @@ export default async (user: IUser, data: Option, silent = false) => new Promise< noteObj.isFirstNote = true; } + if (tags.length > 0) { + publishHashtagStream(noteObj); + } + const nm = new NotificationManager(user, note); const nmRelatedPromises = []; - createMentionedEvents(mentionedUsers, noteObj, nm); + createMentionedEvents(mentionedUsers, note, nm); const noteActivity = await renderActivity(data, note); - if (isLocalUser(user)) { + if (isLocalUser(user) && note.visibility != 'private') { deliverNoteToMentionedRemoteUsers(mentionedUsers, user, noteActivity); } @@ -198,7 +234,7 @@ export default async (user: IUser, data: Option, silent = false) => new Promise< // 通知 if (isLocalUser(data.reply._user)) { nm.push(data.reply.userId, 'reply'); - publishUserStream(data.reply.userId, 'reply', noteObj); + publishMainStream(data.reply.userId, 'reply', noteObj); } } @@ -221,7 +257,7 @@ export default async (user: IUser, data: Option, silent = false) => new Promise< // Publish event if (!user._id.equals(data.renote.userId) && isLocalUser(data.renote._user)) { - publishUserStream(data.renote.userId, 'renote', noteObj); + publishMainStream(data.renote.userId, 'renote', noteObj); } } @@ -238,7 +274,7 @@ export default async (user: IUser, data: Option, silent = false) => new Promise< }); async function renderActivity(data: Option, note: INote) { - const content = data.renote && data.text == null + const content = data.renote && data.text == null && data.poll == null && (data.files == null || data.files.length == 0) ? renderAnnounce(data.renote.uri ? data.renote.uri : `${config.url}/notes/${data.renote._id}`, note) : renderCreate(await renderNote(note, false), note); @@ -266,13 +302,15 @@ async function publish(user: IUser, note: INote, noteObj: any, reply: INote, ren } if (['private', 'followers', 'specified'].includes(note.visibility)) { - // Publish event to myself's stream - publishUserStream(note.userId, 'note', await pack(note, user, { + const detailPackedNote = await pack(note, user, { detail: true - })); + }); + // Publish event to myself's stream + publishHomeTimelineStream(note.userId, detailPackedNote); + publishHybridTimelineStream(note.userId, detailPackedNote); } else { // Publish event to myself's stream - publishUserStream(note.userId, 'note', noteObj); + publishHomeTimelineStream(note.userId, noteObj); // Publish note to local and hybrid timeline stream if (note.visibility != 'home') { @@ -281,6 +319,9 @@ async function publish(user: IUser, note: INote, noteObj: any, reply: INote, ren if (note.visibility == 'public') { publishHybridTimelineStream(null, noteObj); + } else { + // Publish event to myself's stream + publishHybridTimelineStream(note.userId, noteObj); } } } @@ -290,29 +331,19 @@ async function publish(user: IUser, note: INote, noteObj: any, reply: INote, ren publishGlobalTimelineStream(noteObj); } - if (note.visibility == 'specified') { - visibleUsers.forEach(async (u) => { - const n = await pack(note, u, { - detail: true - }); - publishUserStream(u._id, 'note', n); - publishHybridTimelineStream(u._id, n); - }); - } - if (['public', 'home', 'followers'].includes(note.visibility)) { // フォロワーに配信 - publishToFollowers(note, noteObj, user, noteActivity); + publishToFollowers(note, user, noteActivity); } // リストに配信 publishToUserLists(note, noteObj); } -async function insertNote(user: IUser, data: Option, tokens: ReturnType<typeof parse>, tags: string[], mentionedUsers: IUser[]) { +async function insertNote(user: IUser, data: Option, tags: string[], mentionedUsers: IUser[]) { const insert: any = { createdAt: data.createdAt, - mediaIds: data.media ? data.media.map(file => file._id) : [], + fileIds: data.files ? data.files.map(file => file._id) : [], replyId: data.reply ? data.reply._id : null, renoteId: data.renote ? data.renote._id : null, text: data.text, @@ -347,7 +378,8 @@ async function insertNote(user: IUser, data: Option, tokens: ReturnType<typeof p _user: { host: user.host, inbox: isRemoteUser(user) ? user.inbox : undefined - } + }, + _files: data.files ? data.files : [] }; if (data.uri != null) insert.uri = data.uri; @@ -383,7 +415,7 @@ function extractHashtags(tokens: ReturnType<typeof parse>): string[] { .map(t => (t as TextElementHashtag).hashtag) .filter(tag => tag.length <= 100); - return [...new Set(hashtags)]; + return unique(hashtags); } function index(note: INote) { @@ -439,7 +471,12 @@ async function publishToUserLists(note: INote, noteObj: any) { }); } -async function publishToFollowers(note: INote, noteObj: any, user: IUser, noteActivity: any) { +async function publishToFollowers(note: INote, user: IUser, noteActivity: any) { + const detailPackedNote = await pack(note, null, { + detail: true, + skipHide: true + }); + const followers = await Following.find({ followeeId: note.userId }); @@ -458,10 +495,10 @@ async function publishToFollowers(note: INote, noteObj: any, user: IUser, noteAc } // Publish event to followers stream - publishUserStream(following.followerId, 'note', noteObj); + publishHomeTimelineStream(following.followerId, detailPackedNote); if (isRemoteUser(user) || note.visibility != 'public') { - publishHybridTimelineStream(following.followerId, noteObj); + publishHybridTimelineStream(following.followerId, detailPackedNote); } } else { // フォロワーがリモートユーザーかつ投稿者がローカルユーザーなら投稿を配信 @@ -483,9 +520,13 @@ function deliverNoteToMentionedRemoteUsers(mentionedUsers: IUser[], user: ILocal }); } -function createMentionedEvents(mentionedUsers: IUser[], noteObj: any, nm: NotificationManager) { +function createMentionedEvents(mentionedUsers: IUser[], note: INote, nm: NotificationManager) { mentionedUsers.filter(u => isLocalUser(u)).forEach(async (u) => { - publishUserStream(u._id, 'mention', noteObj); + const detailPackedNote = await pack(note, u, { + detail: true + }); + + publishMainStream(u._id, 'mention', detailPackedNote); // Create notification nm.push(u._id, 'mention'); @@ -540,20 +581,20 @@ function incNotesCount(user: IUser) { async function extractMentionedUsers(tokens: ReturnType<typeof parse>): Promise<IUser[]> { if (tokens == null) return []; - const mentionTokens = [...new Set( + const mentionTokens = unique( tokens .filter(t => t.type == 'mention') as TextElementMention[] - )]; + ); - const mentionedUsers = [...new Set( - (await Promise.all(mentionTokens.map(async m => { + const mentionedUsers = unique( + erase(null, await Promise.all(mentionTokens.map(async m => { try { return await resolveUser(m.username, m.host); } catch (e) { return null; } - }))).filter(x => x != null) - )]; + }))) + ); return mentionedUsers; } diff --git a/src/services/note/delete.ts b/src/services/note/delete.ts index d0e2b12b41..2b99b4b85e 100644 --- a/src/services/note/delete.ts +++ b/src/services/note/delete.ts @@ -5,8 +5,9 @@ import renderDelete from '../../remote/activitypub/renderer/delete'; import pack from '../../remote/activitypub/renderer'; import { deliver } from '../../queue'; import Following from '../../models/following'; -import renderNote from '../../remote/activitypub/renderer/note'; +import renderTombstone from '../../remote/activitypub/renderer/tombstone'; import { updateNoteStats } from '../update-chart'; +import config from '../../config'; /** * 投稿を削除します。 @@ -14,25 +15,30 @@ import { updateNoteStats } from '../update-chart'; * @param note 投稿 */ export default async function(user: IUser, note: INote) { + const deletedAt = new Date(); + await Note.update({ _id: note._id, userId: user._id }, { $set: { - deletedAt: new Date(), + deletedAt: deletedAt, text: null, tags: [], - mediaIds: [], + fileIds: [], poll: null, - geo: null + geo: null, + cw: null } }); - publishNoteStream(note._id, 'deleted'); + publishNoteStream(note._id, 'deleted', { + deletedAt: deletedAt + }); //#region ローカルの投稿なら削除アクティビティを配送 if (isLocalUser(user)) { - const content = pack(renderDelete(await renderNote(note), user)); + const content = pack(renderDelete(renderTombstone(`${config.url}/notes/${note._id}`), user)); const followings = await Following.find({ followeeId: user._id, diff --git a/src/services/note/reaction/create.ts b/src/services/note/reaction/create.ts index 5b6267b0dd..6884014e33 100644 --- a/src/services/note/reaction/create.ts +++ b/src/services/note/reaction/create.ts @@ -43,7 +43,9 @@ export default async (user: IUser, note: INote, reaction: string) => new Promise $inc: inc }); - publishNoteStream(note._id, 'reacted'); + publishNoteStream(note._id, 'reacted', { + reaction: reaction + }); // リアクションされたユーザーがローカルユーザーなら通知を作成 if (isLocalUser(note._user)) { diff --git a/src/services/note/read.ts b/src/services/note/read.ts new file mode 100644 index 0000000000..caf5cf318f --- /dev/null +++ b/src/services/note/read.ts @@ -0,0 +1,66 @@ +import * as mongo from 'mongodb'; +import { publishMainStream } from '../../stream'; +import User from '../../models/user'; +import NoteUnread from '../../models/note-unread'; + +/** + * Mark a note as read + */ +export default ( + user: string | mongo.ObjectID, + note: string | mongo.ObjectID +) => new Promise<any>(async (resolve, reject) => { + + const userId: mongo.ObjectID = mongo.ObjectID.prototype.isPrototypeOf(user) + ? user as mongo.ObjectID + : new mongo.ObjectID(user); + + const noteId: mongo.ObjectID = mongo.ObjectID.prototype.isPrototypeOf(note) + ? note as mongo.ObjectID + : new mongo.ObjectID(note); + + // Remove document + const res = await NoteUnread.remove({ + userId: userId, + noteId: noteId + }); + + if (res.deletedCount == 0) { + return; + } + + const count1 = await NoteUnread + .count({ + userId: userId, + isSpecified: false + }, { + limit: 1 + }); + + const count2 = await NoteUnread + .count({ + userId: userId, + isSpecified: true + }, { + limit: 1 + }); + + if (count1 == 0 || count2 == 0) { + User.update({ _id: userId }, { + $set: { + hasUnreadMentions: count1 != 0 || count2 != 0, + hasUnreadSpecifiedNotes: count2 != 0 + } + }); + } + + if (count1 == 0) { + // 全て既読になったイベントを発行 + publishMainStream(userId, 'readAllUnreadMentions'); + } + + if (count2 == 0) { + // 全て既読になったイベントを発行 + publishMainStream(userId, 'readAllUnreadSpecifiedNotes'); + } +}); diff --git a/src/services/note/unread.ts b/src/services/note/unread.ts new file mode 100644 index 0000000000..e84ac2a4bf --- /dev/null +++ b/src/services/note/unread.ts @@ -0,0 +1,47 @@ +import NoteUnread from '../../models/note-unread'; +import User, { IUser } from '../../models/user'; +import { INote } from '../../models/note'; +import Mute from '../../models/mute'; +import { publishMainStream } from '../../stream'; + +export default async function(user: IUser, note: INote, isSpecified = false) { + //#region ミュートしているなら無視 + const mute = await Mute.find({ + muterId: user._id + }); + const mutedUserIds = mute.map(m => m.muteeId.toString()); + if (mutedUserIds.includes(note.userId.toString())) return; + //#endregion + + const unread = await NoteUnread.insert({ + noteId: note._id, + userId: user._id, + isSpecified, + _note: { + userId: note.userId + } + }); + + // 2秒経っても既読にならなかったら「未読の投稿がありますよ」イベントを発行する + setTimeout(async () => { + const exist = await NoteUnread.findOne({ _id: unread._id }); + if (exist == null) return; + + User.update({ + _id: user._id + }, { + $set: isSpecified ? { + hasUnreadSpecifiedNotes: true, + hasUnreadMentions: true + } : { + hasUnreadMentions: true + } + }); + + publishMainStream(user._id, 'unreadMention', note._id); + + if (isSpecified) { + publishMainStream(user._id, 'unreadSpecifiedNote', note._id); + } + }, 2000); +} diff --git a/src/services/update-chart.ts b/src/services/update-chart.ts index 1f8da6be9f..78834ba601 100644 --- a/src/services/update-chart.ts +++ b/src/services/update-chart.ts @@ -96,6 +96,12 @@ async function getCurrentStats(span: 'day' | 'hour'): Promise<IStats> { decCount: 0, decSize: 0 } + }, + network: { + requests: 0, + totalTime: 0, + incomingBytes: 0, + outgoingBytes: 0 } }; @@ -161,6 +167,12 @@ async function getCurrentStats(span: 'day' | 'hour'): Promise<IStats> { decCount: 0, decSize: 0 } + }, + network: { + requests: 0, + totalTime: 0, + incomingBytes: 0, + outgoingBytes: 0 } }; @@ -243,3 +255,13 @@ export async function updateDriveStats(file: IDriveFile, isAdditional: boolean) await update(inc); } + +export async function updateNetworkStats(requests: number, time: number, incomingBytes: number, outgoingBytes: number) { + const inc = {} as any; + inc['network.requests'] = requests; + inc['network.totalTime'] = time; + inc['network.incomingBytes'] = incomingBytes; + inc['network.outgoingBytes'] = outgoingBytes; + + await update(inc); +} diff --git a/src/stream.ts b/src/stream.ts index be7a8c4ba1..45b353d904 100644 --- a/src/stream.ts +++ b/src/stream.ts @@ -1,58 +1,110 @@ import * as mongo from 'mongodb'; import Xev from 'xev'; - -const ev = new Xev(); +import Meta, { IMeta } from './models/meta'; type ID = string | mongo.ObjectID; -function publish(channel: string, type: string, value?: any): void { - const message = type == null ? value : value == null ? - { type: type } : - { type: type, body: value }; +class Publisher { + private ev: Xev; + private meta: IMeta; - ev.emit(channel, message); -} + constructor() { + this.ev = new Xev(); -export function publishUserStream(userId: ID, type: string, value?: any): void { - publish(`user-stream:${userId}`, type, typeof value === 'undefined' ? null : value); -} + setInterval(async () => { + this.meta = await Meta.findOne({}); + }, 5000); + } -export function publishDriveStream(userId: ID, type: string, value?: any): void { - publish(`drive-stream:${userId}`, type, typeof value === 'undefined' ? null : value); -} + public getMeta = async () => { + if (this.meta != null) return this.meta; -export function publishNoteStream(noteId: ID, type: string): void { - publish(`note-stream:${noteId}`, null, noteId); -} + this.meta = await Meta.findOne({}); + return this.meta; + } -export function publishUserListStream(listId: ID, type: string, value?: any): void { - publish(`user-list-stream:${listId}`, type, typeof value === 'undefined' ? null : value); -} + private publish = (channel: string, type: string, value?: any): void => { + const message = type == null ? value : value == null ? + { type: type, body: null } : + { type: type, body: value }; -export function publishMessagingStream(userId: ID, otherpartyId: ID, type: string, value?: any): void { - publish(`messaging-stream:${userId}-${otherpartyId}`, type, typeof value === 'undefined' ? null : value); -} + this.ev.emit(channel, message); + } -export function publishMessagingIndexStream(userId: ID, type: string, value?: any): void { - publish(`messaging-index-stream:${userId}`, type, typeof value === 'undefined' ? null : value); -} + public publishMainStream = (userId: ID, type: string, value?: any): void => { + this.publish(`mainStream:${userId}`, type, typeof value === 'undefined' ? null : value); + } -export function publishReversiStream(userId: ID, type: string, value?: any): void { - publish(`reversi-stream:${userId}`, type, typeof value === 'undefined' ? null : value); -} + public publishDriveStream = (userId: ID, type: string, value?: any): void => { + this.publish(`driveStream:${userId}`, type, typeof value === 'undefined' ? null : value); + } -export function publishReversiGameStream(gameId: ID, type: string, value?: any): void { - publish(`reversi-game-stream:${gameId}`, type, typeof value === 'undefined' ? null : value); -} + public publishNoteStream = (noteId: ID, type: string, value: any): void => { + this.publish(`noteStream:${noteId}`, type, { + id: noteId, + body: value + }); + } -export function publishLocalTimelineStream(note: any): void { - publish('local-timeline', null, note); -} + public publishUserListStream = (listId: ID, type: string, value?: any): void => { + this.publish(`userListStream:${listId}`, type, typeof value === 'undefined' ? null : value); + } -export function publishHybridTimelineStream(userId: ID, note: any): void { - publish(userId ? `hybrid-timeline:${userId}` : 'hybrid-timeline', null, note); -} + public publishMessagingStream = (userId: ID, otherpartyId: ID, type: string, value?: any): void => { + this.publish(`messagingStream:${userId}-${otherpartyId}`, type, typeof value === 'undefined' ? null : value); + } + + public publishMessagingIndexStream = (userId: ID, type: string, value?: any): void => { + this.publish(`messagingIndexStream:${userId}`, type, typeof value === 'undefined' ? null : value); + } + + public publishReversiStream = (userId: ID, type: string, value?: any): void => { + this.publish(`reversiStream:${userId}`, type, typeof value === 'undefined' ? null : value); + } + + public publishReversiGameStream = (gameId: ID, type: string, value?: any): void => { + this.publish(`reversiGameStream:${gameId}`, type, typeof value === 'undefined' ? null : value); + } + + public publishHomeTimelineStream = (userId: ID, note: any): void => { + this.publish(`homeTimeline:${userId}`, null, note); + } + + public publishLocalTimelineStream = async (note: any): Promise<void> => { + const meta = await this.getMeta(); + if (meta.disableLocalTimeline) return; + this.publish('localTimeline', null, note); + } -export function publishGlobalTimelineStream(note: any): void { - publish('global-timeline', null, note); + public publishHybridTimelineStream = async (userId: ID, note: any): Promise<void> => { + const meta = await this.getMeta(); + if (meta.disableLocalTimeline) return; + this.publish(userId ? `hybridTimeline:${userId}` : 'hybridTimeline', null, note); + } + + public publishGlobalTimelineStream = (note: any): void => { + this.publish('globalTimeline', null, note); + } + + public publishHashtagStream = (note: any): void => { + this.publish('hashtag', null, note); + } } + +const publisher = new Publisher(); + +export default publisher; + +export const publishMainStream = publisher.publishMainStream; +export const publishDriveStream = publisher.publishDriveStream; +export const publishNoteStream = publisher.publishNoteStream; +export const publishUserListStream = publisher.publishUserListStream; +export const publishMessagingStream = publisher.publishMessagingStream; +export const publishMessagingIndexStream = publisher.publishMessagingIndexStream; +export const publishReversiStream = publisher.publishReversiStream; +export const publishReversiGameStream = publisher.publishReversiGameStream; +export const publishHomeTimelineStream = publisher.publishHomeTimelineStream; +export const publishLocalTimelineStream = publisher.publishLocalTimelineStream; +export const publishHybridTimelineStream = publisher.publishHybridTimelineStream; +export const publishGlobalTimelineStream = publisher.publishGlobalTimelineStream; +export const publishHashtagStream = publisher.publishHashtagStream; |