diff options
Diffstat (limited to 'src')
277 files changed, 7017 insertions, 3706 deletions
diff --git a/src/chart/drive.ts b/src/chart/drive.ts new file mode 100644 index 0000000000..ff454c750a --- /dev/null +++ b/src/chart/drive.ts @@ -0,0 +1,122 @@ +import autobind from 'autobind-decorator'; +import Chart, { Obj } from './'; +import DriveFile, { IDriveFile } from '../models/drive-file'; +import { isLocalUser } from '../models/user'; + +/** + * ドライブに関するチャート + */ +type DriveLog = { + local: { + /** + * 集計期間時点での、全ドライブファイル数 + */ + totalCount: number; + + /** + * 集計期間時点での、全ドライブファイルの合計サイズ + */ + totalSize: number; + + /** + * 増加したドライブファイル数 + */ + incCount: number; + + /** + * 増加したドライブ使用量 + */ + incSize: number; + + /** + * 減少したドライブファイル数 + */ + decCount: number; + + /** + * 減少したドライブ使用量 + */ + decSize: number; + }; + + remote: DriveLog['local']; +}; + +class DriveChart extends Chart<DriveLog> { + constructor() { + super('drive'); + } + + @autobind + protected async getTemplate(init: boolean, latest?: DriveLog): Promise<DriveLog> { + const calcSize = (local: boolean) => DriveFile + .aggregate([{ + $match: { + 'metadata._user.host': local ? null : { $ne: null }, + 'metadata.deletedAt': { $exists: false } + } + }, { + $project: { + length: true + } + }, { + $group: { + _id: null, + usage: { $sum: '$length' } + } + }]) + .then(res => res.length > 0 ? res[0].usage : 0); + + const [localCount, remoteCount, localSize, remoteSize] = init ? await Promise.all([ + DriveFile.count({ 'metadata._user.host': null }), + DriveFile.count({ 'metadata._user.host': { $ne: null } }), + calcSize(true), + calcSize(false) + ]) : [ + latest ? latest.local.totalCount : 0, + latest ? latest.remote.totalCount : 0, + latest ? latest.local.totalSize : 0, + latest ? latest.remote.totalSize : 0 + ]; + + return { + local: { + totalCount: localCount, + totalSize: localSize, + incCount: 0, + incSize: 0, + decCount: 0, + decSize: 0 + }, + remote: { + totalCount: remoteCount, + totalSize: remoteSize, + incCount: 0, + incSize: 0, + decCount: 0, + decSize: 0 + } + }; + } + + @autobind + public async update(file: IDriveFile, isAdditional: boolean) { + const update: Obj = {}; + + update.totalCount = isAdditional ? 1 : -1; + update.totalSize = isAdditional ? file.length : -file.length; + if (isAdditional) { + update.incCount = 1; + update.incSize = file.length; + } else { + update.decCount = 1; + update.decSize = file.length; + } + + await this.inc({ + [isLocalUser(file.metadata._user) ? 'local' : 'remote']: update + }); + } +} + +export default new DriveChart(); diff --git a/src/chart/federation.ts b/src/chart/federation.ts new file mode 100644 index 0000000000..5bb41f00a2 --- /dev/null +++ b/src/chart/federation.ts @@ -0,0 +1,66 @@ +import autobind from 'autobind-decorator'; +import Chart, { Obj } from '.'; +import Instance from '../models/instance'; + +/** + * フェデレーションに関するチャート + */ +type FederationLog = { + instance: { + /** + * インスタンス数の合計 + */ + total: number; + + /** + * 増加インスタンス数 + */ + inc: number; + + /** + * 減少インスタンス数 + */ + dec: number; + }; +}; + +class FederationChart extends Chart<FederationLog> { + constructor() { + super('federation'); + } + + @autobind + protected async getTemplate(init: boolean, latest?: FederationLog): Promise<FederationLog> { + const [total] = init ? await Promise.all([ + Instance.count({}) + ]) : [ + latest ? latest.instance.total : 0 + ]; + + return { + instance: { + total: total, + inc: 0, + dec: 0 + } + }; + } + + @autobind + public async update(isAdditional: boolean) { + const update: Obj = {}; + + update.total = isAdditional ? 1 : -1; + if (isAdditional) { + update.inc = 1; + } else { + update.dec = 1; + } + + await this.inc({ + instance: update + }); + } +} + +export default new FederationChart(); diff --git a/src/chart/hashtag.ts b/src/chart/hashtag.ts new file mode 100644 index 0000000000..5b03d8ba34 --- /dev/null +++ b/src/chart/hashtag.ts @@ -0,0 +1,56 @@ +import autobind from 'autobind-decorator'; +import Chart, { Obj } from './'; +import { IUser, isLocalUser } from '../models/user'; +import db from '../db/mongodb'; + +/** + * ハッシュタグに関するチャート + */ +type HashtagLog = { + local: { + /** + * 投稿された数 + */ + count: number; + }; + + remote: HashtagLog['local']; +}; + +class HashtagChart extends Chart<HashtagLog> { + constructor() { + super('hashtag', true); + + // 後方互換性のため + db.get('chart.hashtag').findOne().then(doc => { + if (doc != null && doc.data.local == null) { + db.get('chart.hashtag').drop(); + } + }); + } + + @autobind + protected async getTemplate(init: boolean, latest?: HashtagLog): Promise<HashtagLog> { + return { + local: { + count: 0 + }, + remote: { + count: 0 + } + }; + } + + @autobind + public async update(hashtag: string, user: IUser) { + const update: Obj = { + count: 1 + }; + + await this.incIfUnique({ + [isLocalUser(user) ? 'local' : 'remote']: update + }, 'users', user._id.toHexString(), hashtag); + } +} + +export default new HashtagChart(); diff --git a/src/chart/index.ts b/src/chart/index.ts new file mode 100644 index 0000000000..491f26ce70 --- /dev/null +++ b/src/chart/index.ts @@ -0,0 +1,306 @@ +/** + * チャートエンジン + */ + +const nestedProperty = require('nested-property'); +import autobind from 'autobind-decorator'; +import * as mongo from 'mongodb'; +import db from '../db/mongodb'; +import { ICollection } from 'monk'; + +export type Obj = { [key: string]: any }; + +export type Partial<T> = { + [P in keyof T]?: Partial<T[P]>; +}; + +type ArrayValue<T> = { + [P in keyof T]: T[P] extends number ? Array<T[P]> : ArrayValue<T[P]>; +}; + +type Span = 'day' | 'hour'; + +type Log<T extends Obj> = { + _id: mongo.ObjectID; + + /** + * 集計のグループ + */ + group?: any; + + /** + * 集計日時 + */ + date: Date; + + /** + * 集計期間 + */ + span: Span; + + /** + * データ + */ + data: T; + + /** + * ユニークインクリメント用 + */ + unique?: Obj; +}; + +/** + * 様々なチャートの管理を司るクラス + */ +export default abstract class Chart<T> { + protected collection: ICollection<Log<T>>; + protected abstract async getTemplate(init: boolean, latest?: T, group?: any): Promise<T>; + + constructor(name: string, grouped = false) { + this.collection = db.get<Log<T>>(`chart.${name}`); + if (grouped) { + this.collection.createIndex({ span: -1, date: -1, group: -1 }, { unique: true }); + } else { + this.collection.createIndex({ span: -1, date: -1 }, { unique: true }); + } + } + + @autobind + private convertQuery(x: Obj, path: string): Obj { + const query: Obj = {}; + + const dive = (x: Obj, path: string) => { + Object.entries(x).forEach(([k, v]) => { + const p = path ? `${path}.${k}` : k; + if (typeof v === 'number') { + query[p] = v; + } else { + dive(v, p); + } + }); + }; + + dive(x, path); + + return query; + } + + @autobind + private getCurrentDate(): [number, number, number, number] { + const now = new Date(); + + const y = now.getFullYear(); + const m = now.getMonth(); + const d = now.getDate(); + const h = now.getHours(); + + return [y, m, d, h]; + } + + @autobind + private getLatestLog(span: Span, group?: any): Promise<Log<T>> { + return this.collection.findOne({ + group: group, + span: span + }, { + sort: { + date: -1 + } + }); + } + + @autobind + private async getCurrentLog(span: Span, group?: any): Promise<Log<T>> { + const [y, m, d, h] = this.getCurrentDate(); + + const current = + span == 'day' ? new Date(y, m, d) : + span == 'hour' ? new Date(y, m, d, h) : + null; + + // 現在(今日または今のHour)のログ + const currentLog = await this.collection.findOne({ + group: group, + span: span, + date: current + }); + + // ログがあればそれを返して終了 + if (currentLog != null) { + return currentLog; + } + + let log: Log<T>; + let data: T; + + // 集計期間が変わってから、初めてのチャート更新なら + // 最も最近のログを持ってくる + // * 例えば集計期間が「日」である場合で考えると、 + // * 昨日何もチャートを更新するような出来事がなかった場合は、 + // * ログがそもそも作られずドキュメントが存在しないということがあり得るため、 + // * 「昨日の」と決め打ちせずに「もっとも最近の」とします + const latest = await this.getLatestLog(span, group); + + if (latest != null) { + // 空ログデータを作成 + data = await this.getTemplate(false, latest.data); + } else { + // ログが存在しなかったら + // (Misskeyインスタンスを建てて初めてのチャート更新時など + // または何らかの理由でチャートコレクションを抹消した場合) + + // 初期ログデータを作成 + data = await this.getTemplate(true, null, group); + } + + try { + // 新規ログ挿入 + log = await this.collection.insert({ + group: group, + span: span, + date: current, + data: data + }); + } catch (e) { + // 11000 is duplicate key error + // 並列動作している他のチャートエンジンプロセスと処理が重なる場合がある + // その場合は再度最も新しいログを持ってくる + if (e.code === 11000) { + log = await this.getLatestLog(span, group); + } else { + console.error(e); + throw e; + } + } + + return log; + } + + @autobind + protected commit(query: Obj, group?: any, uniqueKey?: string, uniqueValue?: string): void { + const update = (log: Log<T>) => { + // ユニークインクリメントの場合、指定のキーに指定の値が既に存在していたら弾く + if ( + uniqueKey && + log.unique && + log.unique[uniqueKey] && + log.unique[uniqueKey].includes(uniqueValue) + ) return; + + // ユニークインクリメントの指定のキーに値を追加 + if (uniqueKey) { + query['$push'] = { + [`unique.${uniqueKey}`]: uniqueValue + }; + } + + // ログ更新 + this.collection.update({ + _id: log._id + }, query); + }; + + this.getCurrentLog('day', group).then(log => update(log)); + this.getCurrentLog('hour', group).then(log => update(log)); + } + + @autobind + protected inc(inc: Partial<T>, group?: any): void { + this.commit({ + $inc: this.convertQuery(inc, 'data') + }, group); + } + + @autobind + protected incIfUnique(inc: Partial<T>, key: string, value: string, group?: any): void { + this.commit({ + $inc: this.convertQuery(inc, 'data') + }, group, key, value); + } + + @autobind + public async getChart(span: Span, range: number, group?: any): Promise<ArrayValue<T>> { + const promisedChart: Promise<T>[] = []; + + const [y, m, d, h] = this.getCurrentDate(); + + const gt = + span == 'day' ? new Date(y, m, d - range) : + span == 'hour' ? new Date(y, m, d, h - range) : + null; + + // ログ取得 + const logs = await this.collection.find({ + group: group, + span: span, + date: { + $gt: gt + } + }, { + sort: { + date: -1 + }, + fields: { + _id: 0 + } + }); + + // 整形 + for (let i = (range - 1); i >= 0; i--) { + const current = + span == 'day' ? new Date(y, m, d - i) : + span == 'hour' ? new Date(y, m, d, h - i) : + null; + + const log = logs.find(l => l.date.getTime() == current.getTime()); + + if (log) { + promisedChart.unshift(Promise.resolve(log.data)); + } else { + // 隙間埋め + const latest = logs.find(l => l.date.getTime() < current.getTime()); + promisedChart.unshift(this.getTemplate(false, latest ? latest.data : null)); + } + } + + const chart = await Promise.all(promisedChart); + + const res: ArrayValue<T> = {} as any; + + /** + * [{ + * xxxxx: 1, + * yyyyy: 5 + * }, { + * xxxxx: 2, + * yyyyy: 6 + * }, { + * xxxxx: 3, + * yyyyy: 7 + * }] + * + * を + * + * { + * xxxxx: [1, 2, 3], + * yyyyy: [5, 6, 7] + * } + * + * にする + */ + const dive = (x: Obj, path?: string) => { + Object.entries(x).forEach(([k, v]) => { + const p = path ? `${path}.${k}` : k; + if (typeof v == 'object') { + dive(v, p); + } else { + nestedProperty.set(res, p, chart.map(s => nestedProperty.get(s, p))); + } + }); + }; + + dive(chart[0]); + + return res; + } +} diff --git a/src/chart/network.ts b/src/chart/network.ts new file mode 100644 index 0000000000..fce47099d1 --- /dev/null +++ b/src/chart/network.ts @@ -0,0 +1,64 @@ +import autobind from 'autobind-decorator'; +import Chart, { Partial } from './'; + +/** + * ネットワークに関するチャート + */ +type NetworkLog = { + /** + * 受信したリクエスト数 + */ + incomingRequests: number; + + /** + * 送信したリクエスト数 + */ + outgoingRequests: number; + + /** + * 応答時間の合計 + * TIP: (totalTime / incomingRequests) でひとつのリクエストに平均でどれくらいの時間がかかったか知れる + */ + totalTime: number; + + /** + * 合計受信データ量 + */ + incomingBytes: number; + + /** + * 合計送信データ量 + */ + outgoingBytes: number; +}; + +class NetworkChart extends Chart<NetworkLog> { + constructor() { + super('network'); + } + + @autobind + protected async getTemplate(init: boolean, latest?: NetworkLog): Promise<NetworkLog> { + return { + incomingRequests: 0, + outgoingRequests: 0, + totalTime: 0, + incomingBytes: 0, + outgoingBytes: 0 + }; + } + + @autobind + public async update(incomingRequests: number, time: number, incomingBytes: number, outgoingBytes: number) { + const inc: Partial<NetworkLog> = { + incomingRequests: incomingRequests, + totalTime: time, + incomingBytes: incomingBytes, + outgoingBytes: outgoingBytes + }; + + await this.inc(inc); + } +} + +export default new NetworkChart(); diff --git a/src/chart/notes.ts b/src/chart/notes.ts new file mode 100644 index 0000000000..738778e72a --- /dev/null +++ b/src/chart/notes.ts @@ -0,0 +1,114 @@ +import autobind from 'autobind-decorator'; +import Chart, { Obj } from '.'; +import Note, { INote } from '../models/note'; +import { isLocalUser } from '../models/user'; + +/** + * 投稿に関するチャート + */ +type NotesLog = { + local: { + /** + * 集計期間時点での、全投稿数 + */ + total: number; + + /** + * 増加した投稿数 + */ + inc: number; + + /** + * 減少した投稿数 + */ + dec: number; + + diffs: { + /** + * 通常の投稿数の差分 + */ + normal: number; + + /** + * リプライの投稿数の差分 + */ + reply: number; + + /** + * Renoteの投稿数の差分 + */ + renote: number; + }; + }; + + remote: NotesLog['local']; +}; + +class NotesChart extends Chart<NotesLog> { + constructor() { + super('notes'); + } + + @autobind + protected async getTemplate(init: boolean, latest?: NotesLog): Promise<NotesLog> { + const [localCount, remoteCount] = init ? await Promise.all([ + Note.count({ '_user.host': null }), + Note.count({ '_user.host': { $ne: null } }) + ]) : [ + latest ? latest.local.total : 0, + latest ? latest.remote.total : 0 + ]; + + return { + local: { + total: localCount, + inc: 0, + dec: 0, + diffs: { + normal: 0, + reply: 0, + renote: 0 + } + }, + remote: { + total: remoteCount, + inc: 0, + dec: 0, + diffs: { + normal: 0, + reply: 0, + renote: 0 + } + } + }; + } + + @autobind + public async update(note: INote, isAdditional: boolean) { + const update: Obj = { + diffs: {} + }; + + update.total = isAdditional ? 1 : -1; + + if (isAdditional) { + update.inc = 1; + } else { + update.dec = 1; + } + + if (note.replyId != null) { + update.diffs.reply = isAdditional ? 1 : -1; + } else if (note.renoteId != null) { + update.diffs.renote = isAdditional ? 1 : -1; + } else { + update.diffs.normal = isAdditional ? 1 : -1; + } + + await this.inc({ + [isLocalUser(note._user) ? 'local' : 'remote']: update + }); + } +} + +export default new NotesChart(); diff --git a/src/chart/per-user-drive.ts b/src/chart/per-user-drive.ts new file mode 100644 index 0000000000..3decedeb3b --- /dev/null +++ b/src/chart/per-user-drive.ts @@ -0,0 +1,101 @@ +import autobind from 'autobind-decorator'; +import Chart, { Obj } from './'; +import DriveFile, { IDriveFile } from '../models/drive-file'; + +/** + * ユーザーごとのドライブに関するチャート + */ +type PerUserDriveLog = { + /** + * 集計期間時点での、全ドライブファイル数 + */ + totalCount: number; + + /** + * 集計期間時点での、全ドライブファイルの合計サイズ + */ + totalSize: number; + + /** + * 増加したドライブファイル数 + */ + incCount: number; + + /** + * 増加したドライブ使用量 + */ + incSize: number; + + /** + * 減少したドライブファイル数 + */ + decCount: number; + + /** + * 減少したドライブ使用量 + */ + decSize: number; +}; + +class PerUserDriveChart extends Chart<PerUserDriveLog> { + constructor() { + super('perUserDrive', true); + } + + @autobind + protected async getTemplate(init: boolean, latest?: PerUserDriveLog, group?: any): Promise<PerUserDriveLog> { + const calcSize = () => DriveFile + .aggregate([{ + $match: { + 'metadata.userId': group, + 'metadata.deletedAt': { $exists: false } + } + }, { + $project: { + length: true + } + }, { + $group: { + _id: null, + usage: { $sum: '$length' } + } + }]) + .then(res => res.length > 0 ? res[0].usage : 0); + + const [count, size] = init ? await Promise.all([ + DriveFile.count({ 'metadata.userId': group }), + calcSize() + ]) : [ + latest ? latest.totalCount : 0, + latest ? latest.totalSize : 0 + ]; + + return { + totalCount: count, + totalSize: size, + incCount: 0, + incSize: 0, + decCount: 0, + decSize: 0 + }; + } + + @autobind + public async update(file: IDriveFile, isAdditional: boolean) { + const update: Obj = {}; + + update.totalCount = isAdditional ? 1 : -1; + update.totalSize = isAdditional ? file.length : -file.length; + if (isAdditional) { + update.incCount = 1; + update.incSize = file.length; + } else { + update.decCount = 1; + update.decSize = file.length; + } + + await this.inc(update, file.metadata.userId); + } +} + +export default new PerUserDriveChart(); diff --git a/src/chart/per-user-following.ts b/src/chart/per-user-following.ts new file mode 100644 index 0000000000..fac4a1619f --- /dev/null +++ b/src/chart/per-user-following.ts @@ -0,0 +1,128 @@ +import autobind from 'autobind-decorator'; +import Chart, { Obj } from './'; +import Following from '../models/following'; +import { IUser, isLocalUser } from '../models/user'; + +/** + * ユーザーごとのフォローに関するチャート + */ +type PerUserFollowingLog = { + local: { + /** + * フォローしている + */ + followings: { + /** + * 合計 + */ + total: number; + + /** + * フォローした数 + */ + inc: number; + + /** + * フォロー解除した数 + */ + dec: number; + }; + + /** + * フォローされている + */ + followers: { + /** + * 合計 + */ + total: number; + + /** + * フォローされた数 + */ + inc: number; + + /** + * フォロー解除された数 + */ + dec: number; + }; + }; + + remote: PerUserFollowingLog['local']; +}; + +class PerUserFollowingChart extends Chart<PerUserFollowingLog> { + constructor() { + super('perUserFollowing', true); + } + + @autobind + protected async getTemplate(init: boolean, latest?: PerUserFollowingLog, group?: any): Promise<PerUserFollowingLog> { + const [ + localFollowingsCount, + localFollowersCount, + remoteFollowingsCount, + remoteFollowersCount + ] = init ? await Promise.all([ + Following.count({ followerId: group, '_followee.host': null }), + Following.count({ followeeId: group, '_follower.host': null }), + Following.count({ followerId: group, '_followee.host': { $ne: null } }), + Following.count({ followeeId: group, '_follower.host': { $ne: null } }) + ]) : [ + latest ? latest.local.followings.total : 0, + latest ? latest.local.followers.total : 0, + latest ? latest.remote.followings.total : 0, + latest ? latest.remote.followers.total : 0 + ]; + + return { + local: { + followings: { + total: localFollowingsCount, + inc: 0, + dec: 0 + }, + followers: { + total: localFollowersCount, + inc: 0, + dec: 0 + } + }, + remote: { + followings: { + total: remoteFollowingsCount, + inc: 0, + dec: 0 + }, + followers: { + total: remoteFollowersCount, + inc: 0, + dec: 0 + } + } + }; + } + + @autobind + public async update(follower: IUser, followee: IUser, isFollow: boolean) { + const update: Obj = {}; + + update.total = isFollow ? 1 : -1; + + if (isFollow) { + update.inc = 1; + } else { + update.dec = 1; + } + + this.inc({ + [isLocalUser(follower) ? 'local' : 'remote']: { followings: update } + }, follower._id); + this.inc({ + [isLocalUser(followee) ? 'local' : 'remote']: { followers: update } + }, followee._id); + } +} + +export default new PerUserFollowingChart(); diff --git a/src/chart/per-user-notes.ts b/src/chart/per-user-notes.ts new file mode 100644 index 0000000000..9558f5c839 --- /dev/null +++ b/src/chart/per-user-notes.ts @@ -0,0 +1,94 @@ +import autobind from 'autobind-decorator'; +import Chart, { Obj } from './'; +import Note, { INote } from '../models/note'; +import { IUser } from '../models/user'; + +/** + * ユーザーごとの投稿に関するチャート + */ +type PerUserNotesLog = { + /** + * 集計期間時点での、全投稿数 + */ + total: number; + + /** + * 増加した投稿数 + */ + inc: number; + + /** + * 減少した投稿数 + */ + dec: number; + + diffs: { + /** + * 通常の投稿数の差分 + */ + normal: number; + + /** + * リプライの投稿数の差分 + */ + reply: number; + + /** + * Renoteの投稿数の差分 + */ + renote: number; + }; +}; + +class PerUserNotesChart extends Chart<PerUserNotesLog> { + constructor() { + super('perUserNotes', true); + } + + @autobind + protected async getTemplate(init: boolean, latest?: PerUserNotesLog, group?: any): Promise<PerUserNotesLog> { + const [count] = init ? await Promise.all([ + Note.count({ userId: group, deletedAt: null }), + ]) : [ + latest ? latest.total : 0 + ]; + + return { + total: count, + inc: 0, + dec: 0, + diffs: { + normal: 0, + reply: 0, + renote: 0 + } + }; + } + + @autobind + public async update(user: IUser, note: INote, isAdditional: boolean) { + const update: Obj = { + diffs: {} + }; + + update.total = isAdditional ? 1 : -1; + + if (isAdditional) { + update.inc = 1; + } else { + update.dec = 1; + } + + if (note.replyId != null) { + update.diffs.reply = isAdditional ? 1 : -1; + } else if (note.renoteId != null) { + update.diffs.renote = isAdditional ? 1 : -1; + } else { + update.diffs.normal = isAdditional ? 1 : -1; + } + + await this.inc(update, user._id); + } +} + +export default new PerUserNotesChart(); diff --git a/src/chart/per-user-reactions.ts b/src/chart/per-user-reactions.ts new file mode 100644 index 0000000000..a31952ea22 --- /dev/null +++ b/src/chart/per-user-reactions.ts @@ -0,0 +1,45 @@ +import autobind from 'autobind-decorator'; +import Chart from './'; +import { IUser, isLocalUser } from '../models/user'; +import { INote } from '../models/note'; + +/** + * ユーザーごとのリアクションに関するチャート + */ +type PerUserReactionsLog = { + local: { + /** + * リアクションされた数 + */ + count: number; + }; + + remote: PerUserReactionsLog['local']; +}; + +class PerUserReactionsChart extends Chart<PerUserReactionsLog> { + constructor() { + super('perUserReaction', true); + } + + @autobind + protected async getTemplate(init: boolean, latest?: PerUserReactionsLog, group?: any): Promise<PerUserReactionsLog> { + return { + local: { + count: 0 + }, + remote: { + count: 0 + } + }; + } + + @autobind + public async update(user: IUser, note: INote) { + this.inc({ + [isLocalUser(user) ? 'local' : 'remote']: { count: 1 } + }, note.userId); + } +} + +export default new PerUserReactionsChart(); diff --git a/src/chart/users.ts b/src/chart/users.ts new file mode 100644 index 0000000000..547e595b01 --- /dev/null +++ b/src/chart/users.ts @@ -0,0 +1,75 @@ +import autobind from 'autobind-decorator'; +import Chart, { Obj } from './'; +import User, { IUser, isLocalUser } from '../models/user'; + +/** + * ユーザーに関するチャート + */ +type UsersLog = { + local: { + /** + * 集計期間時点での、全ユーザー数 + */ + total: number; + + /** + * 増加したユーザー数 + */ + inc: number; + + /** + * 減少したユーザー数 + */ + dec: number; + }; + + remote: UsersLog['local']; +}; + +class UsersChart extends Chart<UsersLog> { + constructor() { + super('users'); + } + + @autobind + protected async getTemplate(init: boolean, latest?: UsersLog): Promise<UsersLog> { + const [localCount, remoteCount] = init ? await Promise.all([ + User.count({ host: null }), + User.count({ host: { $ne: null } }) + ]) : [ + latest ? latest.local.total : 0, + latest ? latest.remote.total : 0 + ]; + + return { + local: { + total: localCount, + inc: 0, + dec: 0 + }, + remote: { + total: remoteCount, + inc: 0, + dec: 0 + } + }; + } + + @autobind + public async update(user: IUser, isAdditional: boolean) { + const update: Obj = {}; + + update.total = isAdditional ? 1 : -1; + if (isAdditional) { + update.inc = 1; + } else { + update.dec = 1; + } + + await this.inc({ + [isLocalUser(user) ? 'local' : 'remote']: update + }); + } +} + +export default new UsersChart(); diff --git a/src/client/app/app.styl b/src/client/app/app.styl index 2f0095944c..725147f251 100644 --- a/src/client/app/app.styl +++ b/src/client/app/app.styl @@ -130,3 +130,29 @@ pre [data-fa] display inline-block + +.swal2-container + z-index 10000 !important + + &.swal2-shown + background-color rgba(0, 0, 0, 0.5) !important + +.swal2-popup + background var(--face) !important + +.swal2-content + color var(--text) !important + +.swal2-confirm + background-color var(--primary) !important + border-left-color var(--primary) !important + border-right-color var(--primary) !important + color var(--primaryForeground) !important + + &:hover + background-image none !important + background-color var(--primaryDarken5) !important + + &:active + background-image none !important + background-color var(--primaryDarken5) !important diff --git a/src/client/app/boot.js b/src/client/app/boot.js index 6e06a88aa3..063749caee 100644 --- a/src/client/app/boot.js +++ b/src/client/app/boot.js @@ -142,7 +142,7 @@ localStorage.setItem('shouldFlush', 'false'); // Random - localStorage.setItem('salt', Math.random().toString()); + localStorage.setItem('salt', Math.random().toString().substr(2, 8)); // Clear cache (service worker) try { diff --git a/src/client/app/common/hotkey.ts b/src/client/app/common/hotkey.ts index dc1a34338a..f7366e35cb 100644 --- a/src/client/app/common/hotkey.ts +++ b/src/client/app/common/hotkey.ts @@ -46,6 +46,16 @@ const getKeyMap = keymap => Object.entries(keymap).map(([patterns, callback]): a const ignoreElemens = ['input', 'textarea']; +function match(e: KeyboardEvent, patterns: action['patterns']): boolean { + const key = e.code.toLowerCase(); + return patterns.some(pattern => pattern.which.includes(key) && + pattern.ctrl == e.ctrlKey && + pattern.shift == e.shiftKey && + pattern.alt == e.altKey && + e.metaKey == false + ); +} + export default { install(Vue) { Vue.directive('hotkey', { @@ -55,37 +65,27 @@ export default { const actions = getKeyMap(binding.value); // flatten - const reservedKeys = concat(concat(actions.map(a => a.patterns.map(p => p.which)))); + const reservedKeys = concat(actions.map(a => a.patterns)); - el.dataset.reservedKeys = reservedKeys.map(key => `'${key}'`).join(' '); + el._misskey_reservedKeys = reservedKeys; el._keyHandler = (e: KeyboardEvent) => { - const key = e.code.toLowerCase(); - - const targetReservedKeys = document.activeElement ? ((document.activeElement as any).dataset || {}).reservedKeys || '' : ''; + const targetReservedKeys = document.activeElement ? ((document.activeElement as any)._misskey_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; + const matched = match(e, action.patterns); - if (matched) { - e.preventDefault(); - e.stopPropagation(); - action.callback(e); - return true; - } else { - return false; + if (matched) { + if (el._hotkey_global) { + if (match(e, targetReservedKeys)) { + return; + } } - }); - if (matched) { + e.preventDefault(); + e.stopPropagation(); + action.callback(e); break; } } diff --git a/src/client/app/common/scripts/note-mixin.ts b/src/client/app/common/scripts/note-mixin.ts new file mode 100644 index 0000000000..cd3d3ae760 --- /dev/null +++ b/src/client/app/common/scripts/note-mixin.ts @@ -0,0 +1,178 @@ +import parse from '../../../../mfm/parse'; +import { sum } from '../../../../prelude/array'; +import MkNoteMenu from '../views/components/note-menu.vue'; +import MkReactionPicker from '../views/components/reaction-picker.vue'; +import Ok from '../views/components/ok.vue'; + +function focus(el, fn) { + const target = fn(el); + if (target) { + if (target.hasAttribute('tabindex')) { + target.focus(); + } else { + focus(target, fn); + } + } +} + +type Opts = { + mobile?: boolean; +}; + +export default (opts: Opts = {}) => ({ + data() { + return { + showContent: false + }; + }, + + computed: { + keymap(): any { + return { + 'r': () => this.reply(true), + 'e|a|plus': () => this.react(true), + 'q': () => this.renote(true), + 'f|b': this.favorite, + 'delete|ctrl+d': this.del, + 'ctrl+q': 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.fileIds.length == 0 && + this.note.poll == null); + }, + + appearNote(): any { + return this.isRenote ? this.note.renote : this.note; + }, + + reactionsCount(): number { + return this.appearNote.reactionCounts + ? sum(Object.values(this.appearNote.reactionCounts)) + : 0; + }, + + title(): string { + return new Date(this.appearNote.createdAt).toLocaleString(); + }, + + urls(): string[] { + if (this.appearNote.text) { + const ast = parse(this.appearNote.text); + return ast + .filter(t => (t.type == 'url' || t.type == 'link') && !t.silent) + .map(t => t.url); + } else { + return null; + } + } + }, + + methods: { + reply(viaKeyboard = false) { + (this as any).apis.post({ + reply: this.appearNote, + animation: !viaKeyboard, + cb: () => { + this.focus(); + } + }); + }, + + renote(viaKeyboard = false) { + (this as any).apis.post({ + renote: this.appearNote, + animation: !viaKeyboard, + cb: () => { + this.focus(); + } + }); + }, + + renoteDirectly() { + (this as any).api('notes/create', { + renoteId: this.appearNote.id + }); + }, + + react(viaKeyboard = false) { + this.blur(); + (this as any).os.new(MkReactionPicker, { + source: this.$refs.reactButton, + note: this.appearNote, + showFocus: viaKeyboard, + animation: !viaKeyboard, + compact: opts.mobile, + big: opts.mobile + }).$once('closed', this.focus); + }, + + reactDirectly(reaction) { + (this as any).api('notes/reactions/create', { + noteId: this.appearNote.id, + reaction: reaction + }); + }, + + favorite() { + (this as any).api('notes/favorites/create', { + noteId: this.appearNote.id + }).then(() => { + (this as any).os.new(Ok); + }); + }, + + del() { + (this as any).api('notes/delete', { + noteId: this.appearNote.id + }); + }, + + menu(viaKeyboard = false) { + (this as any).os.new(MkNoteMenu, { + source: this.$refs.menuButton, + note: this.appearNote, + animation: !viaKeyboard, + compact: opts.mobile, + }).$once('closed', this.focus); + }, + + toggleShowContent() { + this.showContent = !this.showContent; + }, + + focus() { + this.$el.focus(); + }, + + blur() { + this.$el.blur(); + }, + + focusBefore() { + focus(this.$el, e => e.previousElementSibling); + }, + + focusAfter() { + focus(this.$el, e => e.nextElementSibling); + } + } +}); diff --git a/src/client/app/common/scripts/note-subscriber.ts b/src/client/app/common/scripts/note-subscriber.ts index c41897e70f..1a82dd3918 100644 --- a/src/client/app/common/scripts/note-subscriber.ts +++ b/src/client/app/common/scripts/note-subscriber.ts @@ -13,14 +13,14 @@ export default prop => ({ }, $_ns_isRenote(): boolean { - return (this.$_ns_note_.renote && + return (this.$_ns_note_.renote != null && 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_; + return this.$_ns_isRenote ? this.$_ns_note_.renote : this.$_ns_note_; }, }, @@ -86,8 +86,20 @@ export default prop => ({ 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; + + if (this.$_ns_target.reactionCounts == null) { + Vue.set(this.$_ns_target, 'reactionCounts', {}); + } + + if (this.$_ns_target.reactionCounts[reaction] == null) { + Vue.set(this.$_ns_target.reactionCounts, reaction, 0); + } + + this.$_ns_target.reactionCounts[reaction]++; + + if (body.userId == this.$store.state.i.id) { + Vue.set(this.$_ns_target, 'myReaction', reaction); + } break; } diff --git a/src/client/app/common/scripts/stream.ts b/src/client/app/common/scripts/stream.ts index 3b1a94adf9..345b112b15 100644 --- a/src/client/app/common/scripts/stream.ts +++ b/src/client/app/common/scripts/stream.ts @@ -9,8 +9,8 @@ import MiOS from '../../mios'; */ export default class Stream extends EventEmitter { private stream: ReconnectingWebsocket; - private state: string; - private buffer: any[]; + public state: string; + private sharedConnectionPools: Pool[] = []; private sharedConnections: SharedConnection[] = []; private nonSharedConnections: NonSharedConnection[] = []; @@ -18,7 +18,6 @@ export default class Stream extends EventEmitter { super(); this.state = 'initializing'; - this.buffer = []; const user = os.store.state.i; @@ -26,114 +25,34 @@ export default class Stream extends EventEmitter { 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); + @autobind + public useSharedConnection(channel: string): SharedConnection { + let pool = this.sharedConnectionPools.find(p => p.channel === channel); - if (existConnection) { - existConnection.use(); - return existConnection; - } else { - const connection = new SharedConnection(this, channel); - connection.use(); - this.sharedConnections.push(connection); - return connection; + if (pool == null) { + pool = new Pool(this, channel); + this.sharedConnectionPools.push(pool); } + + const connection = new SharedConnection(this, channel, pool); + this.sharedConnections.push(connection); + return connection; } @autobind public removeSharedConnection(connection: SharedConnection) { - this.sharedConnections = this.sharedConnections.filter(c => c.id !== connection.id); + this.sharedConnections = this.sharedConnections.filter(c => c !== connection); + } + + @autobind + public removeSharedConnectionPool(pool: Pool) { + this.sharedConnectionPools = this.sharedConnectionPools.filter(p => p !== pool); } - public connectToChannel = (channel: string, params?: any): NonSharedConnection => { + @autobind + public connectToChannel(channel: string, params?: any): NonSharedConnection { const connection = new NonSharedConnection(this, channel, params); this.nonSharedConnections.push(connection); return connection; @@ -141,7 +60,7 @@ export default class Stream extends EventEmitter { @autobind public disconnectToChannel(connection: NonSharedConnection) { - this.nonSharedConnections = this.nonSharedConnections.filter(c => c.id !== connection.id); + this.nonSharedConnections = this.nonSharedConnections.filter(c => c !== connection); } /** @@ -154,17 +73,10 @@ export default class Stream extends EventEmitter { 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.sharedConnectionPools.forEach(p => { + p.connect(); }); this.nonSharedConnections.forEach(c => { c.connect(); @@ -177,8 +89,10 @@ export default class Stream extends EventEmitter { */ @autobind private onClose() { - this.state = 'reconnecting'; - this.emit('_disconnected_'); + if (this.state == 'connected') { + this.state = 'reconnecting'; + this.emit('_disconnected_'); + } } /** @@ -190,8 +104,18 @@ export default class Stream extends EventEmitter { 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); + + let connections: Connection[]; + + connections = this.sharedConnections.filter(c => c.id === id); + + if (connections.length === 0) { + connections = [this.nonSharedConnections.find(c => c.id === id)]; + } + + connections.filter(c => c != null).forEach(c => { + c.emit(body.type, body.body); + }); } else { this.emit(type, body); } @@ -207,12 +131,6 @@ export default class Stream extends EventEmitter { body: payload }; - // まだ接続が確立されていなかったらバッファリングして次に接続した時に送信する - if (this.state != 'connected') { - this.buffer.push(data); - return; - } - this.stream.send(JSON.stringify(data)); } @@ -226,57 +144,34 @@ export default class Stream extends EventEmitter { } } -abstract class Connection extends EventEmitter { +class Pool { public channel: string; public id: string; - protected params: any; protected stream: Stream; + public users = 0; + private disposeTimerId: any; + private isConnected = false; - constructor(stream: Stream, channel: string, params?: any) { - super(); - - this.stream = stream; + constructor(stream: Stream, channel: string) { this.channel = channel; - this.params = params; - this.id = Math.random().toString(); - this.connect(); - } + this.stream = stream; - @autobind - public connect() { - this.stream.send('connect', { - channel: this.channel, - id: this.id, - params: this.params - }); - } + this.id = Math.random().toString().substr(2, 8); - @autobind - public send(typeOrPayload, payload?) { - const data = payload === undefined ? typeOrPayload : { - type: typeOrPayload, - body: payload - }; - - this.stream.send('channel', { - id: this.id, - body: data - }); + this.stream.on('_disconnected_', this.onStreamDisconnected); } - public abstract dispose: () => void; -} - -class SharedConnection extends Connection { - private users = 0; - private disposeTimerId: any; - - constructor(stream: Stream, channel: string) { - super(stream, channel); + @autobind + private onStreamDisconnected() { + this.isConnected = false; } @autobind - public use() { + public inc() { + if (this.users === 0 && !this.isConnected) { + this.connect(); + } + this.users++; // タイマー解除 @@ -287,7 +182,7 @@ class SharedConnection extends Connection { } @autobind - public dispose() { + public dec() { this.users--; // そのコネクションの利用者が誰もいなくなったら @@ -295,18 +190,108 @@ class SharedConnection extends Connection { // また直ぐに再利用される可能性があるので、一定時間待ち、 // 新たな利用者が現れなければコネクションを切断する this.disposeTimerId = setTimeout(() => { - this.disposeTimerId = null; - this.removeAllListeners(); - this.stream.send('disconnect', { id: this.id }); - this.stream.removeSharedConnection(this); + this.disconnect(); }, 3000); } } + + @autobind + public connect() { + if (this.isConnected) return; + this.isConnected = true; + this.stream.send('connect', { + channel: this.channel, + id: this.id + }); + } + + @autobind + private disconnect() { + this.stream.off('_disconnected_', this.onStreamDisconnected); + this.stream.send('disconnect', { id: this.id }); + this.stream.removeSharedConnectionPool(this); + } +} + +abstract class Connection extends EventEmitter { + public channel: string; + protected stream: Stream; + public abstract id: string; + + constructor(stream: Stream, channel: string) { + super(); + + this.stream = stream; + this.channel = channel; + } + + @autobind + public send(id: string, typeOrPayload, payload?) { + const type = payload === undefined ? typeOrPayload.type : typeOrPayload; + const body = payload === undefined ? typeOrPayload.body : payload; + + this.stream.send('ch', { + id: id, + type: type, + body: body + }); + } + + public abstract dispose(): void; +} + +class SharedConnection extends Connection { + private pool: Pool; + + public get id(): string { + return this.pool.id; + } + + constructor(stream: Stream, channel: string, pool: Pool) { + super(stream, channel); + + this.pool = pool; + this.pool.inc(); + } + + @autobind + public send(typeOrPayload, payload?) { + super.send(this.pool.id, typeOrPayload, payload); + } + + @autobind + public dispose() { + this.pool.dec(); + this.removeAllListeners(); + this.stream.removeSharedConnection(this); + } } class NonSharedConnection extends Connection { + public id: string; + protected params: any; + constructor(stream: Stream, channel: string, params?: any) { - super(stream, channel, params); + super(stream, channel); + + this.params = params; + this.id = Math.random().toString().substr(2, 8); + + this.connect(); + } + + @autobind + public connect() { + this.stream.send('connect', { + channel: this.channel, + id: this.id, + params: this.params + }); + } + + @autobind + public send(typeOrPayload, payload?) { + super.send(this.id, typeOrPayload, payload); } @autobind diff --git a/src/client/app/common/views/components/api-settings.vue b/src/client/app/common/views/components/api-settings.vue new file mode 100644 index 0000000000..98750b44a8 --- /dev/null +++ b/src/client/app/common/views/components/api-settings.vue @@ -0,0 +1,72 @@ +<template> +<ui-card> + <div slot="title">%fa:key% API</div> + + <section class="fit-top"> + <ui-input :value="$store.state.i.token" readonly> + <span>%i18n:@token%</span> + </ui-input> + <p>%i18n:@intro%</p> + <ui-info warn>%i18n:@caution%</ui-info> + <p>%i18n:@regeneration-of-token%</p> + <ui-button @click="regenerateToken">%fa:sync-alt% %i18n:@regenerate-token%</ui-button> + </section> + + <section> + <header>%fa:terminal% %i18n:@console.title%</header> + <ui-input v-model="endpoint"> + <span>%i18n:@console.endpoint%</span> + </ui-input> + <ui-textarea v-model="body"> + <span>%i18n:@console.parameter% (JSON or JSON5)</span> + </ui-textarea> + <ui-button @click="send" :disabled="sending"> + <template v-if="sending">%i18n:@console.sending%</template> + <template v-else>%fa:paper-plane% %i18n:@console.send%</template> + </ui-button> + <ui-textarea v-if="res" v-model="res"> + <span>%i18n:@console.response%</span> + </ui-textarea> + </section> +</ui-card> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import * as JSON5 from 'json5'; + +export default Vue.extend({ + data() { + return { + endpoint: '', + body: '{}', + res: null, + sending: false + }; + }, + + methods: { + regenerateToken() { + (this as any).apis.input({ + title: '%i18n:@enter-password%', + type: 'password' + }).then(password => { + (this as any).api('i/regenerate_token', { + password: password + }); + }); + }, + + send() { + this.sending = true; + (this as any).api(this.endpoint, JSON5.parse(this.body)).then(res => { + this.sending = false; + this.res = JSON5.stringify(res, null, 2); + }, err => { + this.sending = false; + this.res = JSON5.stringify(err, null, 2); + }); + } + } +}); +</script> diff --git a/src/client/app/common/views/components/drive-settings.vue b/src/client/app/common/views/components/drive-settings.vue new file mode 100644 index 0000000000..3b45a68730 --- /dev/null +++ b/src/client/app/common/views/components/drive-settings.vue @@ -0,0 +1,171 @@ +<template> +<ui-card> + <div slot="title">%fa:cloud% %i18n:common.drive%</div> + + <section v-if="!fetching" class="juakhbxthdewydyreaphkepoxgxvfogn"> + <div class="meter"><div :style="meterStyle"></div></div> + <p>%i18n:@max%: <b>{{ capacity | bytes }}</b> %i18n:@in-use%: <b>{{ usage | bytes }}</b></p> + </section> + + <section> + <header>%i18n:@stats%</header> + <div ref="chart" style="margin-bottom: -16px; color: #000;"></div> + </section> +</ui-card> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import * as tinycolor from 'tinycolor2'; +import * as ApexCharts from 'apexcharts'; + +export default Vue.extend({ + data() { + return { + fetching: true, + usage: null, + capacity: null + }; + }, + + computed: { + meterStyle(): any { + return { + width: `${this.usage / this.capacity * 100}%`, + background: tinycolor({ + h: 180 - (this.usage / this.capacity * 180), + s: 0.7, + l: 0.5 + }) + }; + } + }, + + mounted() { + (this as any).api('drive').then(info => { + this.capacity = info.capacity; + this.usage = info.usage; + this.fetching = false; + + this.$nextTick(() => { + this.renderChart(); + }); + }); + }, + + methods: { + renderChart() { + (this as any).api('charts/user/drive', { + userId: this.$store.state.i.id, + span: 'day', + limit: 21 + }).then(stats => { + const addition = []; + const deletion = []; + + const now = new Date(); + const y = now.getFullYear(); + const m = now.getMonth(); + const d = now.getDate(); + + for (let i = 0; i < 21; i++) { + const x = new Date(y, m, d - i); + addition.push([ + x, + stats.incSize[i] + ]); + deletion.push([ + x, + -stats.decSize[i] + ]); + } + + const chart = new ApexCharts(this.$refs.chart, { + chart: { + type: 'bar', + stacked: true, + height: 150, + zoom: { + enabled: false + } + }, + plotOptions: { + bar: { + columnWidth: '90%', + endingShape: 'rounded' + } + }, + grid: { + clipMarkers: false, + borderColor: 'rgba(0, 0, 0, 0.1)' + }, + tooltip: { + shared: true, + intersect: false + }, + dataLabels: { + enabled: false + }, + legend: { + show: false + }, + series: [{ + name: 'Additions', + data: addition + }, { + name: 'Deletions', + data: deletion + }], + xaxis: { + type: 'datetime', + labels: { + style: { + colors: tinycolor(getComputedStyle(document.documentElement).getPropertyValue('--text')).toRgbString() + } + }, + axisBorder: { + color: 'rgba(0, 0, 0, 0.1)' + }, + axisTicks: { + color: 'rgba(0, 0, 0, 0.1)' + }, + crosshairs: { + width: 1, + opacity: 1 + } + }, + yaxis: { + labels: { + formatter: v => Vue.filter('bytes')(v, 0), + style: { + color: tinycolor(getComputedStyle(document.documentElement).getPropertyValue('--text')).toRgbString() + } + } + } + }); + + chart.render(); + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +.juakhbxthdewydyreaphkepoxgxvfogn + > .meter + $size = 12px + + margin-bottom 16px + background rgba(0, 0, 0, 0.1) + border-radius ($size / 2) + overflow hidden + + > div + height $size + border-radius ($size / 2) + + > p + margin 0 + +</style> diff --git a/src/client/app/common/views/components/games/reversi/reversi.game.vue b/src/client/app/common/views/components/games/reversi/reversi.game.vue index 751abe2ecd..608e1c182d 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 @@ -186,9 +186,8 @@ export default Vue.extend({ if (this.game.isStarted && !this.game.isEnded) { this.pollingClock = setInterval(() => { const crc32 = CRC32.str(this.logs.map(x => x.pos.toString()).join('')); - this.connection.send({ - type: 'check', - crc32 + this.connection.send('check', { + crc32: crc32 }); }, 3000); } @@ -224,9 +223,8 @@ export default Vue.extend({ sound.play(); } - this.connection.send({ - type: 'set', - pos + this.connection.send('set', { + pos: pos }); this.checkEnd(); 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 9f0d9c23fb..29c6794f69 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 @@ -149,9 +149,9 @@ export default Vue.extend({ }, created() { - this.connection.on('change-accepts', this.onChangeAccepts); - this.connection.on('update-settings', this.onUpdateSettings); - this.connection.on('init-form', this.onInitForm); + this.connection.on('changeAccepts', this.onChangeAccepts); + this.connection.on('updateSettings', this.onUpdateSettings); + this.connection.on('initForm', this.onInitForm); this.connection.on('message', this.onMessage); if (this.game.user1Id != this.$store.state.i.id && this.game.settings.form1) this.form = this.game.settings.form1; @@ -159,9 +159,9 @@ export default Vue.extend({ }, beforeDestroy() { - this.connection.off('change-accepts', this.onChangeAccepts); - this.connection.off('update-settings', this.onUpdateSettings); - this.connection.off('init-form', this.onInitForm); + this.connection.off('changeAccepts', this.onChangeAccepts); + this.connection.off('updateSettings', this.onUpdateSettings); + this.connection.off('initForm', this.onInitForm); this.connection.off('message', this.onMessage); }, @@ -171,15 +171,11 @@ export default Vue.extend({ }, accept() { - this.connection.send({ - type: 'accept' - }); + this.connection.send('accept', {}); }, cancel() { - this.connection.send({ - type: 'cancel-accept' - }); + this.connection.send('cancelAccept', {}); }, onChangeAccepts(accepts) { @@ -189,8 +185,7 @@ export default Vue.extend({ }, updateSettings() { - this.connection.send({ - type: 'update-settings', + this.connection.send('updateSettings', { settings: this.game.settings }); }, @@ -216,8 +211,7 @@ export default Vue.extend({ }, onChangeForm(item) { - this.connection.send({ - type: 'update-form', + this.connection.send('updateForm', { id: item.id, value: item.value }); @@ -238,9 +232,9 @@ export default Vue.extend({ const y = Math.floor(pos / this.game.settings.map[0].length); const newPixel = pixel == ' ' ? '-' : - pixel == '-' ? 'b' : - pixel == 'b' ? 'w' : - ' '; + pixel == '-' ? 'b' : + pixel == 'b' ? 'w' : + ' '; const line = this.game.settings.map[y].split(''); line[x] = newPixel; this.$set(this.game.settings.map, y, line.join('')); 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 f2156bc41b..7c9e1b6a5e 100644 --- a/src/client/app/common/views/components/games/reversi/reversi.vue +++ b/src/client/app/common/views/components/games/reversi/reversi.vue @@ -71,8 +71,7 @@ export default Vue.extend({ this.pingClock = setInterval(() => { if (this.matching) { - this.connection.send({ - type: 'ping', + this.connection.send('ping', { id: this.matching.id }); } diff --git a/src/client/app/common/views/components/index.ts b/src/client/app/common/views/components/index.ts index 0dea38a7a1..54880e3c25 100644 --- a/src/client/app/common/views/components/index.ts +++ b/src/client/app/common/views/components/index.ts @@ -1,5 +1,9 @@ import Vue from 'vue'; +import apiSettings from './api-settings.vue'; +import driveSettings from './drive-settings.vue'; +import profileEditor from './profile-editor.vue'; +import noteSkeleton from './note-skeleton.vue'; import theme from './theme.vue'; import instance from './instance.vue'; import cwButton from './cw-button.vue'; @@ -41,9 +45,14 @@ import uiTextarea from './ui/textarea.vue'; import uiSwitch from './ui/switch.vue'; import uiRadio from './ui/radio.vue'; import uiSelect from './ui/select.vue'; +import uiInfo from './ui/info.vue'; import formButton from './ui/form/button.vue'; import formRadio from './ui/form/radio.vue'; +Vue.component('mk-api-settings', apiSettings); +Vue.component('mk-drive-settings', driveSettings); +Vue.component('mk-profile-editor', profileEditor); +Vue.component('mk-note-skeleton', noteSkeleton); Vue.component('mk-theme', theme); Vue.component('mk-instance', instance); Vue.component('mk-cw-button', cwButton); @@ -85,5 +94,6 @@ Vue.component('ui-textarea', uiTextarea); Vue.component('ui-switch', uiSwitch); Vue.component('ui-radio', uiRadio); Vue.component('ui-select', uiSelect); +Vue.component('ui-info', uiInfo); Vue.component('form-button', formButton); Vue.component('form-radio', formRadio); diff --git a/src/client/app/common/views/components/messaging-room.vue b/src/client/app/common/views/components/messaging-room.vue index 488dff528f..d982b10a25 100644 --- a/src/client/app/common/views/components/messaging-room.vue +++ b/src/client/app/common/views/components/messaging-room.vue @@ -71,7 +71,7 @@ export default Vue.extend({ }, mounted() { - this.connection =((this as any).os.stream.connectToChannel('messaging', { otherparty: 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); @@ -174,8 +174,7 @@ export default Vue.extend({ this.messages.push(message); if (message.userId != this.$store.state.i.id && !document.hidden) { - this.connection.send({ - type: 'read', + this.connection.send('read', { id: message.id }); } @@ -247,8 +246,7 @@ export default Vue.extend({ if (document.hidden) return; this.messages.forEach(message => { if (message.userId !== this.$store.state.i.id && !message.isRead) { - this.connection.send({ - type: 'read', + this.connection.send('read', { id: message.id }); } @@ -356,7 +354,7 @@ export default Vue.extend({ max-width 600px margin 0 auto padding 0 - //background rgba(var(--face), 0.95) + background var(--messagingRoomBg) background-clip content-box > .new-message 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 224bd6f5de..d36a0a3714 100644 --- a/src/client/app/common/views/components/misskey-flavored-markdown.ts +++ b/src/client/app/common/views/components/misskey-flavored-markdown.ts @@ -95,7 +95,8 @@ export default Vue.component('misskey-flavored-markdown', { return [createElement(MkUrl, { props: { url: token.content, - target: '_blank' + target: '_blank', + style: 'color:var(--mfmLink);' } })]; } @@ -106,30 +107,31 @@ export default Vue.component('misskey-flavored-markdown', { class: 'link', href: token.url, target: '_blank', - title: token.url + title: token.url, + style: 'color:var(--mfmLink);' } }, token.title)]; } case 'mention': { - return (createElement as any)('a', { + return (createElement as any)('router-link', { attrs: { - href: `${url}/@${getAcct(token)}`, - target: '_blank', - dataIsMe: (this as any).i && getAcct((this as any).i) == getAcct(token) + to: `/${token.canonical}`, + dataIsMe: (this as any).i && getAcct((this as any).i) == getAcct(token), + style: 'color:var(--mfmMention);' }, directives: [{ name: 'user-preview', - value: token.content + value: token.canonical }] - }, token.content); + }, token.canonical); } case 'hashtag': { - return [createElement('a', { + return [createElement('router-link', { attrs: { - href: `${url}/tags/${encodeURIComponent(token.hashtag)}`, - target: '_blank' + to: `/tags/${encodeURIComponent(token.hashtag)}`, + style: 'color:var(--mfmHashtag);' } }, token.content)]; } diff --git a/src/client/app/common/views/components/note-menu.vue b/src/client/app/common/views/components/note-menu.vue index c8ed1225cc..6b96974d5b 100644 --- a/src/client/app/common/views/components/note-menu.vue +++ b/src/client/app/common/views/components/note-menu.vue @@ -8,6 +8,7 @@ import Vue from 'vue'; import { url } from '../../../config'; import copyToClipboard from '../../../common/scripts/copy-to-clipboard'; +import Ok from './ok.vue'; export default Vue.extend({ props: ['note', 'source', 'compact'], @@ -21,12 +22,34 @@ export default Vue.extend({ icon: '%fa:link%', text: '%i18n:@copy-link%', action: this.copyLink - }, null, { - icon: '%fa:star%', - text: '%i18n:@favorite%', - action: this.favorite }]; + if (this.note.uri) { + items.push({ + icon: '%fa:external-link-square-alt%', + text: '%i18n:@remote%', + action: () => { + window.open(this.note.uri, '_blank'); + } + }); + } + + items.push(null); + + if (this.note.isFavorited) { + items.push({ + icon: '%fa:star%', + text: '%i18n:@unfavorite%', + action: this.unfavorite + }); + } else { + items.push({ + icon: '%fa:star%', + text: '%i18n:@favorite%', + action: this.favorite + }); + } + if (this.note.userId == this.$store.state.i.id) { if ((this.$store.state.i.pinnedNoteIds || []).includes(this.note.id)) { items.push({ @@ -44,6 +67,7 @@ export default Vue.extend({ } if (this.note.userId == this.$store.state.i.id || this.$store.state.i.isAdmin) { + items.push(null); items.push({ icon: '%fa:trash-alt R%', text: '%i18n:@delete%', @@ -51,16 +75,6 @@ export default Vue.extend({ }); } - if (this.note.uri) { - items.push({ - icon: '%fa:external-link-square-alt%', - text: '%i18n:@remote%', - action: () => { - window.open(this.note.uri, '_blank'); - } - }); - } - return items; } }, @@ -78,6 +92,7 @@ export default Vue.extend({ (this as any).api('i/pin', { noteId: this.note.id }).then(() => { + (this as any).os.new(Ok); this.destroyDom(); }); }, @@ -103,6 +118,16 @@ export default Vue.extend({ (this as any).api('notes/favorites/create', { noteId: this.note.id }).then(() => { + (this as any).os.new(Ok); + this.destroyDom(); + }); + }, + + unfavorite() { + (this as any).api('notes/favorites/delete', { + noteId: this.note.id + }).then(() => { + (this as any).os.new(Ok); this.destroyDom(); }); }, diff --git a/src/client/app/common/views/components/note-skeleton.vue b/src/client/app/common/views/components/note-skeleton.vue new file mode 100644 index 0000000000..a2e09e3222 --- /dev/null +++ b/src/client/app/common/views/components/note-skeleton.vue @@ -0,0 +1,52 @@ +<template> +<div> + <vue-content-loading v-if="width" :width="width" :height="100" :primary="primary" :secondary="secondary"> + <circle cx="30" cy="30" r="30" /> + <rect x="75" y="13" rx="4" ry="4" :width="150 + r1" height="15" /> + <rect x="75" y="39" rx="4" ry="4" :width="260 + r2" height="10" /> + <rect x="75" y="59" rx="4" ry="4" :width="230 + r3" height="10" /> + </vue-content-loading> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import VueContentLoading from 'vue-content-loading'; +import * as tinycolor from 'tinycolor2'; + +export default Vue.extend({ + components: { + VueContentLoading, + }, + + data() { + return { + width: 0, + r1: (Math.random() * 100) - 50, + r2: (Math.random() * 100) - 50, + r3: (Math.random() * 100) - 50 + }; + }, + + computed: { + text(): tinycolor.Instance { + const text = tinycolor(getComputedStyle(document.documentElement).getPropertyValue('--text')); + return text; + }, + + primary(): string { + return '#' + this.text.clone().toHex(); + }, + + secondary(): string { + return '#' + this.text.clone().darken(20).toHex(); + } + }, + + mounted() { + let width = this.$el.clientWidth; + if (width < 400) width = 400; + this.width = width; + } +}); +</script> diff --git a/src/client/app/common/views/components/ok.vue b/src/client/app/common/views/components/ok.vue new file mode 100644 index 0000000000..63bd784b18 --- /dev/null +++ b/src/client/app/common/views/components/ok.vue @@ -0,0 +1,175 @@ +<template> +<div class="yvbkymdqeusiqucuuloahhiqflzinufs"> + <div class="bg" ref="bg"></div> + <div class="body" ref="body"> + <div class="icon"> + <div class="circle left"></div> + <span class="check tip"></span> + <span class="check long"></span> + <div class="ring"></div> + <div class="fix"></div> + <div class="circle right"></div> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import * as anime from 'animejs'; + +export default Vue.extend({ + mounted() { + this.$nextTick(() => { + anime({ + targets: this.$refs.bg, + opacity: 1, + duration: 300, + easing: 'linear' + }); + + anime({ + targets: this.$refs.body, + opacity: 1, + scale: [1.2, 1], + duration: 300, + easing: [0, 0.5, 0.5, 1] + }); + }); + + setTimeout(() => { + anime({ + targets: this.$refs.bg, + opacity: 0, + duration: 300, + easing: 'linear' + }); + + anime({ + targets: this.$refs.body, + opacity: 0, + scale: 0.8, + duration: 300, + easing: [0.5, 0, 1, 0.5], + complete: () => this.destroyDom() + }); + }, 1250); + } +}); +</script> + +<style lang="stylus" scoped> +.yvbkymdqeusiqucuuloahhiqflzinufs + pointer-events none + + > .bg + display block + position fixed + z-index 10000 + top 0 + left 0 + width 100% + height 100% + background rgba(#000, 0.7) + opacity 0 + + > .body + position fixed + z-index 10000 + top 0 + right 0 + left 0 + bottom 0 + margin auto + width 150px + height 150px + background var(--face) + border-radius 8px + opacity 0 + + > .icon + display flex + justify-content center + position absolute + top 0 + right 0 + left 0 + bottom 0 + width 5em + height 5em + margin auto + border .25em solid transparent + border-radius 50% + line-height 5em + cursor default + box-sizing content-box + user-select none + zoom normal + border-color #a5dc86 + + > .circle + position absolute + width 3.75em + height 7.5em + transform rotate(45deg) + border-radius 50% + background var(--face) + + &.left + top -.4375em + left -2.0635em + transform rotate(-45deg) + transform-origin 3.75em 3.75em + border-radius 7.5em 0 0 7.5em + + &.right + top -.6875em + left 1.875em + transform rotate(-45deg) + transform-origin 0 3.75em + border-radius 0 7.5em 7.5em 0 + animation swal2-rotate-success-circular-line 4.25s ease-in + + > .check + display block + position absolute + height .3125em + border-radius .125em + background-color #a5dc86 + z-index 2 + + &.tip + top 2.875em + left .875em + width 1.5625em + transform rotate(45deg) + animation swal2-animate-success-line-tip .75s + + &.long + top 2.375em + right .5em + width 2.9375em + transform rotate(-45deg) + animation swal2-animate-success-line-long .75s + + > .fix + position absolute + top .5em + left 1.625em + width .4375em + height 5.625em + transform rotate(-45deg) + z-index 1 + background var(--face) + + > .ring + position absolute + top -.25em + left -.25em + width 100% + height 100% + border .25em solid rgba(165,220,134,.3) + border-radius 50% + z-index 2 + box-sizing content-box +</style> diff --git a/src/client/app/mobile/views/pages/settings/settings.profile.vue b/src/client/app/common/views/components/profile-editor.vue index 127f531902..10bdc0b579 100644 --- a/src/client/app/mobile/views/pages/settings/settings.profile.vue +++ b/src/client/app/common/views/components/profile-editor.vue @@ -49,6 +49,7 @@ <div> <ui-switch v-model="isCat" @change="save(false)">%i18n:@is-cat%</ui-switch> + <ui-switch v-model="isBot" @change="save(false)">%i18n:@is-bot%</ui-switch> <ui-switch v-model="alwaysMarkNsfw">%i18n:common.always-mark-nsfw%</ui-switch> </div> </section> @@ -58,6 +59,7 @@ <div> <ui-switch v-model="isLocked" @change="save(false)">%i18n:@is-locked%</ui-switch> + <ui-switch v-model="carefulBot" @change="save(false)">%i18n:@careful-bot%</ui-switch> </div> </section> </ui-card> @@ -65,7 +67,7 @@ <script lang="ts"> import Vue from 'vue'; -import { apiUrl, host } from '../../../../config'; +import { apiUrl, host } from '../../../config'; export default Vue.extend({ data() { @@ -79,7 +81,9 @@ export default Vue.extend({ avatarId: null, bannerId: null, isCat: false, + isBot: false, isLocked: false, + carefulBot: false, saving: false, avatarUploading: false, bannerUploading: false @@ -102,7 +106,9 @@ 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.isBot = this.$store.state.i.isBot; this.isLocked = this.$store.state.i.isLocked; + this.carefulBot = this.$store.state.i.carefulBot; }, methods: { @@ -161,7 +167,9 @@ export default Vue.extend({ avatarId: this.avatarId, bannerId: this.bannerId, isCat: this.isCat, - isLocked: this.isLocked + isBot: this.isBot, + isLocked: this.isLocked, + carefulBot: this.carefulBot }).then(i => { this.saving = false; this.$store.state.i.avatarId = i.avatarId; @@ -170,7 +178,10 @@ export default Vue.extend({ this.$store.state.i.bannerUrl = i.bannerUrl; if (notify) { - alert('%i18n:@saved%'); + this.$swal({ + type: 'success', + text: '%i18n:@saved%' + }); } }); } diff --git a/src/client/app/common/views/components/reactions-viewer.vue b/src/client/app/common/views/components/reactions-viewer.vue index 9212a84b31..15989ff4d8 100644 --- a/src/client/app/common/views/components/reactions-viewer.vue +++ b/src/client/app/common/views/components/reactions-viewer.vue @@ -1,16 +1,16 @@ <template> <div class="mk-reactions-viewer"> <template v-if="reactions"> - <span :class="{notReacted}" @click="react('like')" v-if="reactions.like"><mk-reaction-icon reaction="like"/><span>{{ reactions.like }}</span></span> - <span :class="{notReacted}" @click="react('love')" v-if="reactions.love"><mk-reaction-icon reaction="love"/><span>{{ reactions.love }}</span></span> - <span :class="{notReacted}" @click="react('laugh')" v-if="reactions.laugh"><mk-reaction-icon reaction="laugh"/><span>{{ reactions.laugh }}</span></span> - <span :class="{notReacted}" @click="react('hmm')" v-if="reactions.hmm"><mk-reaction-icon reaction="hmm"/><span>{{ reactions.hmm }}</span></span> - <span :class="{notReacted}" @click="react('surprise')" v-if="reactions.surprise"><mk-reaction-icon reaction="surprise"/><span>{{ reactions.surprise }}</span></span> - <span :class="{notReacted}" @click="react('congrats')" v-if="reactions.congrats"><mk-reaction-icon reaction="congrats"/><span>{{ reactions.congrats }}</span></span> - <span :class="{notReacted}" @click="react('angry')" v-if="reactions.angry"><mk-reaction-icon reaction="angry"/><span>{{ reactions.angry }}</span></span> - <span :class="{notReacted}" @click="react('confused')" v-if="reactions.confused"><mk-reaction-icon reaction="confused"/><span>{{ reactions.confused }}</span></span> - <span :class="{notReacted}" @click="react('rip')" v-if="reactions.rip"><mk-reaction-icon reaction="rip"/><span>{{ reactions.rip }}</span></span> - <span :class="{notReacted}" @click="react('pudding')" v-if="reactions.pudding"><mk-reaction-icon reaction="pudding"/><span>{{ reactions.pudding }}</span></span> + <span :class="{ reacted: note.myReaction == 'like' }" @click="react('like')" v-if="reactions.like"><mk-reaction-icon reaction="like"/><span>{{ reactions.like }}</span></span> + <span :class="{ reacted: note.myReaction == 'love' }" @click="react('love')" v-if="reactions.love"><mk-reaction-icon reaction="love"/><span>{{ reactions.love }}</span></span> + <span :class="{ reacted: note.myReaction == 'laugh' }" @click="react('laugh')" v-if="reactions.laugh"><mk-reaction-icon reaction="laugh"/><span>{{ reactions.laugh }}</span></span> + <span :class="{ reacted: note.myReaction == 'hmm' }" @click="react('hmm')" v-if="reactions.hmm"><mk-reaction-icon reaction="hmm"/><span>{{ reactions.hmm }}</span></span> + <span :class="{ reacted: note.myReaction == 'surprise' }" @click="react('surprise')" v-if="reactions.surprise"><mk-reaction-icon reaction="surprise"/><span>{{ reactions.surprise }}</span></span> + <span :class="{ reacted: note.myReaction == 'congrats' }" @click="react('congrats')" v-if="reactions.congrats"><mk-reaction-icon reaction="congrats"/><span>{{ reactions.congrats }}</span></span> + <span :class="{ reacted: note.myReaction == 'angry' }" @click="react('angry')" v-if="reactions.angry"><mk-reaction-icon reaction="angry"/><span>{{ reactions.angry }}</span></span> + <span :class="{ reacted: note.myReaction == 'confused' }" @click="react('confused')" v-if="reactions.confused"><mk-reaction-icon reaction="confused"/><span>{{ reactions.confused }}</span></span> + <span :class="{ reacted: note.myReaction == 'rip' }" @click="react('rip')" v-if="reactions.rip"><mk-reaction-icon reaction="rip"/><span>{{ reactions.rip }}</span></span> + <span :class="{ reacted: note.myReaction == 'pudding' }" @click="react('pudding')" v-if="reactions.pudding"><mk-reaction-icon reaction="pudding"/><span>{{ reactions.pudding }}</span></span> </template> </div> </template> @@ -22,9 +22,6 @@ export default Vue.extend({ computed: { reactions(): number { return this.note.reactionCounts; - }, - notReacted(): boolean { - return this.note.myReaction == null; } }, methods: { @@ -40,25 +37,42 @@ export default Vue.extend({ <style lang="stylus" scoped> .mk-reactions-viewer - border-top dashed 1px var(--reactionViewerBorder) - border-bottom dashed 1px var(--reactionViewerBorder) - margin 4px 0 + margin 6px 0 &:empty display none > span - margin-right 8px + display inline-block + height 32px + margin-right 6px + padding 0 6px + border-radius 4px - &.notReacted + * + user-select none + pointer-events none + + &.reacted + background var(--primary) + + > span + color var(--primaryForeground) + + &:not(.reacted) cursor pointer + background var(--reactionViewerButtonBg) + + &:hover + background var(--reactionViewerButtonHoverBg) > .mk-reaction-icon font-size 1.4em > span - margin-left 4px - font-size 1.2em + font-size 1.1em + line-height 32px + vertical-align middle color var(--text) </style> diff --git a/src/client/app/common/views/components/theme.vue b/src/client/app/common/views/components/theme.vue index 9eda3c5796..c1c73b97cd 100644 --- a/src/client/app/common/views/components/theme.vue +++ b/src/client/app/common/views/components/theme.vue @@ -67,22 +67,30 @@ </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> + <summary>%fa:folder-open% %i18n:@manage-themes%</summary> + <ui-select v-model="selectedThemeId" placeholder="%i18n:@select-theme%"> + <optgroup label="%i18n:@builtin-themes%"> + <option v-for="x in builtinThemes" :value="x.id" :key="x.id">{{ x.name }}</option> + </optgroup> + <optgroup label="%i18n:@my-themes%"> + <option v-for="x in installedThemes.filter(t => t.author == this.$store.state.i.username)" :value="x.id" :key="x.id">{{ x.name }}</option> + </optgroup> + <optgroup label="%i18n:@installed-themes%"> + <option v-for="x in installedThemes.filter(t => t.author != this.$store.state.i.username)" :value="x.id" :key="x.id">{{ x.name }}</option> + </optgroup> </ui-select> - <template v-if="selectedInstalledTheme"> - <ui-input readonly :value="selectedInstalledTheme.author"> + <template v-if="selectedTheme"> + <ui-input readonly :value="selectedTheme.author"> <span>%i18n:@author%</span> </ui-input> - <ui-textarea v-if="selectedInstalledTheme.desc" readonly :value="selectedInstalledTheme.desc"> + <ui-textarea v-if="selectedTheme.desc" readonly :value="selectedTheme.desc"> <span>%i18n:@desc%</span> </ui-textarea> - <ui-textarea readonly :value="selectedInstalledThemeCode"> + <ui-textarea readonly :value="selectedThemeCode"> <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> + <ui-button @click="export_()" link :download="`${selectedTheme.name}.misskeytheme`" ref="export">%fa:box% %i18n:@export%</ui-button> + <ui-button @click="uninstall()" v-if="!builtinThemes.some(t => t.id == selectedTheme.id)">%fa:trash-alt R% %i18n:@uninstall%</ui-button> </template> </details> </div> @@ -117,8 +125,9 @@ export default Vue.extend({ data() { return { + builtinThemes: builtinThemes, installThemeCode: null, - selectedInstalledThemeId: null, + selectedThemeId: null, myThemeBase: 'light', myThemeName: '', myThemeDesc: '', @@ -155,14 +164,14 @@ export default Vue.extend({ 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); + selectedTheme() { + if (this.selectedThemeId == null) return null; + return this.themes.find(x => x.id == this.selectedThemeId); }, - selectedInstalledThemeCode() { - if (this.selectedInstalledTheme == null) return null; - return JSON5.stringify(this.selectedInstalledTheme, null, '\t'); + selectedThemeCode() { + if (this.selectedTheme == null) return null; + return JSON5.stringify(this.selectedTheme, null, '\t'); }, myTheme(): any { @@ -210,7 +219,10 @@ export default Vue.extend({ try { theme = JSON5.parse(code); } catch (e) { - alert('%i18n:@invalid-theme%'); + this.$swal({ + type: 'error', + text: '%i18n:@invalid-theme%' + }); return; } @@ -220,12 +232,18 @@ export default Vue.extend({ } if (theme.id == null) { - alert('%i18n:@invalid-theme%'); + this.$swal({ + type: 'error', + text: '%i18n:@invalid-theme%' + }); return; } if (this.$store.state.device.themes.some(t => t.id == theme.id)) { - alert('%i18n:@already-installed%'); + this.$swal({ + type: 'info', + text: '%i18n:@already-installed%' + }); return; } @@ -234,16 +252,23 @@ export default Vue.extend({ key: 'themes', value: themes }); - alert('%i18n:@installed%'.replace('{}', theme.name)); + this.$swal({ + type: 'success', + text: '%i18n:@installed%'.replace('{}', theme.name) + }); }, uninstall() { - const theme = this.selectedInstalledTheme; + const theme = this.selectedTheme; 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)); + + this.$swal({ + type: 'info', + text: '%i18n:@uninstalled%'.replace('{}', theme.name) + }); }, import_() { @@ -251,7 +276,7 @@ export default Vue.extend({ } export_() { - const blob = new Blob([this.selectedInstalledThemeCode], { + const blob = new Blob([this.selectedThemeCode], { type: 'application/json5' }); this.$refs.export.$el.href = window.URL.createObjectURL(blob); @@ -275,16 +300,26 @@ export default Vue.extend({ gen() { const theme = this.myTheme; + if (theme.name == null || theme.name.trim() == '') { - alert('%i18n:@theme-name-required%'); + this.$swal({ + type: 'warning', + text: '%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%'); + + this.$swal({ + type: 'success', + text: '%i18n:@saved%' + }); } } }); diff --git a/src/client/app/common/views/components/ui/card.vue b/src/client/app/common/views/components/ui/card.vue index a37a38d340..988c5ad3cf 100644 --- a/src/client/app/common/views/components/ui/card.vue +++ b/src/client/app/common/views/components/ui/card.vue @@ -1,5 +1,5 @@ <template> -<div class="ui-card"> +<div class="ui-card" :class="{ shadow: $store.state.settings.useShadow }"> <header> <slot name="title"></slot> </header> @@ -24,7 +24,10 @@ export default Vue.extend({ margin 16px 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) + border-radius var(--round) + + &.shadow + 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) > header padding 16px diff --git a/src/client/app/common/views/components/ui/info.vue b/src/client/app/common/views/components/ui/info.vue new file mode 100644 index 0000000000..e2ea1d7164 --- /dev/null +++ b/src/client/app/common/views/components/ui/info.vue @@ -0,0 +1,33 @@ +<template> +<div class="ymxyweixqwsxauxldgpvecjepnwxbylu" :class="{ warn }"> + <i v-if="warn">%fa:exclamation-triangle%</i> + <slot></slot> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + props: { + warn: { + type: Boolean, + required: false, + default: false + }, + }, +}); +</script> + +<style lang="stylus" scoped> +.ymxyweixqwsxauxldgpvecjepnwxbylu + margin 16px 0 + padding 16px + font-size 90% + + > i + margin-right 4px + + &.warn + background var(--infoWarnBg) + color var(--infoWarnFg) +</style> diff --git a/src/client/app/common/views/components/ui/input.vue b/src/client/app/common/views/components/ui/input.vue index abbd5a2feb..7e1a16bb3f 100644 --- a/src/client/app/common/views/components/ui/input.vue +++ b/src/client/app/common/views/components/ui/input.vue @@ -122,17 +122,19 @@ export default Vue.extend({ } }, mounted() { - if (this.$refs.prefix) { - this.$refs.label.style.left = (this.$refs.prefix.offsetLeft + this.$refs.prefix.offsetWidth) + 'px'; - if (this.$refs.prefix.offsetWidth) { - this.$refs.input.style.paddingLeft = this.$refs.prefix.offsetWidth + 'px'; + this.$nextTick(() => { + if (this.$refs.prefix) { + this.$refs.label.style.left = (this.$refs.prefix.offsetLeft + this.$refs.prefix.offsetWidth) + 'px'; + if (this.$refs.prefix.offsetWidth) { + this.$refs.input.style.paddingLeft = this.$refs.prefix.offsetWidth + 'px'; + } } - } - if (this.$refs.suffix) { - if (this.$refs.suffix.offsetWidth) { - this.$refs.input.style.paddingRight = this.$refs.suffix.offsetWidth + 'px'; + if (this.$refs.suffix) { + if (this.$refs.suffix.offsetWidth) { + this.$refs.input.style.paddingRight = this.$refs.suffix.offsetWidth + 'px'; + } } - } + }); }, methods: { focus() { diff --git a/src/client/app/common/views/components/ui/textarea.vue b/src/client/app/common/views/components/ui/textarea.vue index 67898ee059..6f8def1ae3 100644 --- a/src/client/app/common/views/components/ui/textarea.vue +++ b/src/client/app/common/views/components/ui/textarea.vue @@ -66,6 +66,9 @@ export default Vue.extend({ root(fill) margin 42px 0 32px 0 + &:last-child + margin-bottom 0 + > .input padding 12px diff --git a/src/client/app/common/views/directives/autocomplete.ts b/src/client/app/common/views/directives/autocomplete.ts index f7f8e9bf16..e2cc64d79f 100644 --- a/src/client/app/common/views/directives/autocomplete.ts +++ b/src/client/app/common/views/directives/autocomplete.ts @@ -1,6 +1,6 @@ import * as getCaretCoordinates from 'textarea-caret'; import MkAutocomplete from '../components/autocomplete.vue'; -import renderAcct from '../../../../../misc/acct/render'; +import { toASCII } from 'punycode'; export default { bind(el, binding, vn) { @@ -188,7 +188,7 @@ class Autocomplete { const trimmedBefore = before.substring(0, before.lastIndexOf('@')); const after = source.substr(caret); - const acct = renderAcct(value); + const acct = value.host === null ? value.username : `${value.username}@${toASCII(value.host)}`; // 挿入 this.text = `${trimmedBefore}@${acct} ${after}`; diff --git a/src/client/app/common/views/widgets/photo-stream.vue b/src/client/app/common/views/widgets/photo-stream.vue index 047b01df4f..fd711b2761 100644 --- a/src/client/app/common/views/widgets/photo-stream.vue +++ b/src/client/app/common/views/widgets/photo-stream.vue @@ -5,7 +5,7 @@ <p :class="$style.fetching" v-if="fetching">%fa:spinner .pulse .fw%%i18n:common.loading%<mk-ellipsis/></p> <div :class="$style.stream" v-if="!fetching && images.length > 0"> - <div v-for="image in images" :class="$style.img" :style="`background-image: url(${image.url})`"></div> + <div v-for="image in images" :class="$style.img" :style="`background-image: url(${image.thumbnailUrl || image.url})`"></div> </div> <p :class="$style.empty" v-if="!fetching && images.length == 0">%i18n:@no-photos%</p> </mk-widget-container> @@ -73,9 +73,6 @@ export default define({ border-radius 8px .stream - display -webkit-flex - display -moz-flex - display -ms-flex display flex justify-content center flex-wrap wrap diff --git a/src/client/app/common/views/widgets/posts-monitor.vue b/src/client/app/common/views/widgets/posts-monitor.vue index 1c70e6dbc4..c62533d1ee 100644 --- a/src/client/app/common/views/widgets/posts-monitor.vue +++ b/src/client/app/common/views/widgets/posts-monitor.vue @@ -113,9 +113,8 @@ export default define({ this.connection.on('stats', this.onStats); this.connection.on('statsLog', this.onStatsLog); - this.connection.send({ - type: 'requestLog', - id: Math.random().toString() + this.connection.send('requestLog',{ + id: Math.random().toString().substr(2, 8) }); }, beforeDestroy() { diff --git a/src/client/app/common/views/widgets/rss.vue b/src/client/app/common/views/widgets/rss.vue index 448eee9fb6..68ab8e3a57 100644 --- a/src/client/app/common/views/widgets/rss.vue +++ b/src/client/app/common/views/widgets/rss.vue @@ -44,7 +44,6 @@ export default define({ }, fetch() { fetch(`https://api.rss2json.com/v1/api.json?rss_url=${this.props.url}`, { - cache: 'no-cache' }).then(res => { res.json().then(feed => { this.items = feed.items; 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 55aa1ea895..4a0341ddcd 100644 --- a/src/client/app/common/views/widgets/server.cpu-memory.vue +++ b/src/client/app/common/views/widgets/server.cpu-memory.vue @@ -91,9 +91,8 @@ export default Vue.extend({ mounted() { this.connection.on('stats', this.onStats); this.connection.on('statsLog', this.onStatsLog); - this.connection.send({ - type: 'requestLog', - id: Math.random().toString() + this.connection.send('requestLog', { + id: Math.random().toString().substr(2, 8) }); }, beforeDestroy() { diff --git a/src/client/app/desktop/api/post.ts b/src/client/app/desktop/api/post.ts index cfc78e50fa..3ff9c5bb8c 100644 --- a/src/client/app/desktop/api/post.ts +++ b/src/client/app/desktop/api/post.ts @@ -6,13 +6,17 @@ export default (os: OS) => opts => { const o = opts || {}; if (o.renote) { const vm = os.new(RenoteFormWindow, { - note: o.renote + note: o.renote, + animation: o.animation == null ? true : o.animation }); + if (o.cb) vm.$once('closed', o.cb); document.body.appendChild(vm.$el); } else { const vm = os.new(PostFormWindow, { - reply: o.reply + reply: o.reply, + animation: o.animation == null ? true : o.animation }); + if (o.cb) vm.$once('closed', o.cb); document.body.appendChild(vm.$el); } }; diff --git a/src/client/app/desktop/script.ts b/src/client/app/desktop/script.ts index 85c81d73a2..765ba0202e 100644 --- a/src/client/app/desktop/script.ts +++ b/src/client/app/desktop/script.ts @@ -21,6 +21,7 @@ import updateAvatar from './api/update-avatar'; import updateBanner from './api/update-banner'; import MkIndex from './views/pages/index.vue'; +import MkHome from './views/pages/home.vue'; import MkDeck from './views/pages/deck/deck.vue'; import MkAdmin from './views/pages/admin/admin.vue'; import MkStats from './views/pages/stats/stats.vue'; @@ -54,6 +55,7 @@ init(async (launch) => { mode: 'history', routes: [ { path: '/', name: 'index', component: MkIndex }, + { path: '/home', name: 'home', component: MkHome }, { path: '/deck', name: 'deck', component: MkDeck }, { path: '/admin', name: 'admin', component: MkAdmin }, { path: '/stats', name: 'stats', component: MkStats }, @@ -64,11 +66,11 @@ init(async (launch) => { { path: '/i/drive/folder/:folder', component: MkDrive }, { path: '/selectdrive', component: MkSelectDrive }, { path: '/search', component: MkSearch }, - { path: '/tags/:tag', component: MkTag }, + { path: '/tags/:tag', name: 'tag', component: MkTag }, { path: '/share', component: MkShare }, { path: '/reversi/:game?', component: MkReversi }, - { path: '/@:user', component: MkUser }, - { path: '/notes/:note', component: MkNote }, + { path: '/@:user', name: 'user', component: MkUser }, + { path: '/notes/:note', name: 'note', component: MkNote }, { path: '/authorize-follow', component: MkFollow } ] }); diff --git a/src/client/app/desktop/views/components/charts.chart.ts b/src/client/app/desktop/views/components/charts.chart.ts index 6a241631e9..513db6076f 100644 --- a/src/client/app/desktop/views/components/charts.chart.ts +++ b/src/client/app/desktop/views/components/charts.chart.ts @@ -33,7 +33,7 @@ export default Vue.extend({ }, tooltips: { intersect: false, - mode: 'x', + mode: 'index', position: 'nearest' } }, this.opts || {})); diff --git a/src/client/app/desktop/views/components/charts.vue b/src/client/app/desktop/views/components/charts.vue index 6d6f3a3596..2601e830ea 100644 --- a/src/client/app/desktop/views/components/charts.vue +++ b/src/client/app/desktop/views/components/charts.vue @@ -3,6 +3,10 @@ <header> <b>%i18n:@title%:</b> <select v-model="chartType"> + <optgroup label="%i18n:@federation%"> + <option value="federation-instances">%i18n:@charts.federation-instances%</option> + <option value="federation-instances-total">%i18n:@charts.federation-instances-total%</option> + </optgroup> <optgroup label="%i18n:@users%"> <option value="users">%i18n:@charts.users%</option> <option value="users-total">%i18n:@charts.users-total%</option> @@ -56,6 +60,11 @@ const rgba = (color: string): string => { return color.replace('rgb', 'rgba').replace(')', ', 0.1)'); }; +const limit = 35; + +const sum = (...arr) => arr.reduce((r, a) => r.map((b, i) => a[i] + b)); +const negate = arr => arr.map(x => -x); + export default Vue.extend({ components: { XChart @@ -63,6 +72,7 @@ export default Vue.extend({ data() { return { + now: null, chart: null, chartType: 'notes', span: 'hour' @@ -73,6 +83,8 @@ export default Vue.extend({ data(): any { if (this.chart == null) return null; switch (this.chartType) { + case 'federation-instances': return this.federationInstancesChart(false); + case 'federation-instances-total': return this.federationInstancesChart(true); case 'users': return this.usersChart(false); case 'users-total': return this.usersChart(true); case 'notes': return this.notesChart('combined'); @@ -90,32 +102,88 @@ export default Vue.extend({ }, stats(): any[] { - return ( + const stats = this.span == 'day' ? this.chart.perDay : this.span == 'hour' ? this.chart.perHour : - null - ); + null; + + return stats; } }, - created() { - (this as any).api('chart', { - limit: 35 - }).then(chart => { - this.chart = chart; - }); + async created() { + this.now = new Date(); + + const [perHour, perDay] = await Promise.all([Promise.all([ + (this as any).api('charts/federation', { limit: limit, span: 'hour' }), + (this as any).api('charts/users', { limit: limit, span: 'hour' }), + (this as any).api('charts/notes', { limit: limit, span: 'hour' }), + (this as any).api('charts/drive', { limit: limit, span: 'hour' }), + (this as any).api('charts/network', { limit: limit, span: 'hour' }) + ]), Promise.all([ + (this as any).api('charts/federation', { limit: limit, span: 'day' }), + (this as any).api('charts/users', { limit: limit, span: 'day' }), + (this as any).api('charts/notes', { limit: limit, span: 'day' }), + (this as any).api('charts/drive', { limit: limit, span: 'day' }), + (this as any).api('charts/network', { limit: limit, span: 'day' }) + ])]); + + const chart = { + perHour: { + federation: perHour[0], + users: perHour[1], + notes: perHour[2], + drive: perHour[3], + network: perHour[4] + }, + perDay: { + federation: perDay[0], + users: perDay[1], + notes: perDay[2], + drive: perDay[3], + network: perDay[4] + } + }; + + this.chart = chart; }, methods: { - notesChart(type: string): any { - const data = this.stats.slice().reverse().map(x => ({ - date: new Date(x.date), - normal: type == 'local' ? x.notes.local.diffs.normal : type == 'remote' ? x.notes.remote.diffs.normal : x.notes.local.diffs.normal + x.notes.remote.diffs.normal, - reply: type == 'local' ? x.notes.local.diffs.reply : type == 'remote' ? x.notes.remote.diffs.reply : x.notes.local.diffs.reply + x.notes.remote.diffs.reply, - renote: type == 'local' ? x.notes.local.diffs.renote : type == 'remote' ? x.notes.remote.diffs.renote : x.notes.local.diffs.renote + x.notes.remote.diffs.renote, - all: type == 'local' ? (x.notes.local.inc + -x.notes.local.dec) : type == 'remote' ? (x.notes.remote.inc + -x.notes.remote.dec) : (x.notes.local.inc + -x.notes.local.dec) + (x.notes.remote.inc + -x.notes.remote.dec) - })); + getDate(i: number) { + const y = this.now.getFullYear(); + const m = this.now.getMonth(); + const d = this.now.getDate(); + const h = this.now.getHours(); + + return ( + this.span == 'day' ? new Date(y, m, d - i) : + this.span == 'hour' ? new Date(y, m, d, h - i) : + null + ); + }, + + format(arr) { + return arr.map((v, i) => ({ t: this.getDate(i).getTime(), y: v })); + }, + + federationInstancesChart(total: boolean): any { + return [{ + datasets: [{ + label: 'Instances', + fill: true, + backgroundColor: rgba(colors.localPlus), + borderColor: colors.localPlus, + borderWidth: 2, + pointBackgroundColor: '#fff', + lineTension: 0, + data: this.format(total + ? this.stats.federation.instance.total + : sum(this.stats.federation.instance.inc, negate(this.stats.federation.instance.dec))) + }] + }]; + }, + notesChart(type: string): any { return [{ datasets: [{ label: 'All', @@ -125,7 +193,10 @@ export default Vue.extend({ borderDash: [4, 4], pointBackgroundColor: '#fff', lineTension: 0, - data: data.map(x => ({ t: x.date, y: x.all })) + data: this.format(type == 'combined' + ? sum(this.stats.notes.local.inc, negate(this.stats.notes.local.dec), this.stats.notes.remote.inc, negate(this.stats.notes.remote.dec)) + : sum(this.stats.notes[type].inc, negate(this.stats.notes[type].dec)) + ) }, { label: 'Renotes', fill: true, @@ -134,7 +205,10 @@ export default Vue.extend({ borderWidth: 2, pointBackgroundColor: '#fff', lineTension: 0, - data: data.map(x => ({ t: x.date, y: x.renote })) + data: this.format(type == 'combined' + ? sum(this.stats.notes.local.diffs.renote, this.stats.notes.remote.diffs.renote) + : this.stats.notes[type].diffs.renote + ) }, { label: 'Replies', fill: true, @@ -143,7 +217,10 @@ export default Vue.extend({ borderWidth: 2, pointBackgroundColor: '#fff', lineTension: 0, - data: data.map(x => ({ t: x.date, y: x.reply })) + data: this.format(type == 'combined' + ? sum(this.stats.notes.local.diffs.reply, this.stats.notes.remote.diffs.reply) + : this.stats.notes[type].diffs.reply + ) }, { label: 'Normal', fill: true, @@ -152,7 +229,10 @@ export default Vue.extend({ borderWidth: 2, pointBackgroundColor: '#fff', lineTension: 0, - data: data.map(x => ({ t: x.date, y: x.normal })) + data: this.format(type == 'combined' + ? sum(this.stats.notes.local.diffs.normal, this.stats.notes.remote.diffs.normal) + : this.stats.notes[type].diffs.normal + ) }] }, { scales: { @@ -176,12 +256,6 @@ export default Vue.extend({ }, notesTotalChart(): any { - const data = this.stats.slice().reverse().map(x => ({ - date: new Date(x.date), - localCount: x.notes.local.total, - remoteCount: x.notes.remote.total - })); - return [{ datasets: [{ label: 'Combined', @@ -191,7 +265,7 @@ export default Vue.extend({ borderDash: [4, 4], pointBackgroundColor: '#fff', lineTension: 0, - data: data.map(x => ({ t: x.date, y: x.remoteCount + x.localCount })) + data: this.format(sum(this.stats.notes.local.total, this.stats.notes.remote.total)) }, { label: 'Local', fill: true, @@ -200,7 +274,7 @@ export default Vue.extend({ borderWidth: 2, pointBackgroundColor: '#fff', lineTension: 0, - data: data.map(x => ({ t: x.date, y: x.localCount })) + data: this.format(this.stats.notes.local.total) }, { label: 'Remote', fill: true, @@ -209,7 +283,7 @@ export default Vue.extend({ borderWidth: 2, pointBackgroundColor: '#fff', lineTension: 0, - data: data.map(x => ({ t: x.date, y: x.remoteCount })) + data: this.format(this.stats.notes.remote.total) }] }, { scales: { @@ -233,12 +307,6 @@ export default Vue.extend({ }, usersChart(total: boolean): any { - const data = this.stats.slice().reverse().map(x => ({ - date: new Date(x.date), - localCount: total ? x.users.local.total : (x.users.local.inc + -x.users.local.dec), - remoteCount: total ? x.users.remote.total : (x.users.remote.inc + -x.users.remote.dec) - })); - return [{ datasets: [{ label: 'Combined', @@ -248,7 +316,10 @@ export default Vue.extend({ borderDash: [4, 4], pointBackgroundColor: '#fff', lineTension: 0, - data: data.map(x => ({ t: x.date, y: x.remoteCount + x.localCount })) + data: this.format(total + ? sum(this.stats.users.local.total, this.stats.users.remote.total) + : sum(this.stats.users.local.inc, negate(this.stats.users.local.dec), this.stats.users.remote.inc, negate(this.stats.users.remote.dec)) + ) }, { label: 'Local', fill: true, @@ -257,7 +328,10 @@ export default Vue.extend({ borderWidth: 2, pointBackgroundColor: '#fff', lineTension: 0, - data: data.map(x => ({ t: x.date, y: x.localCount })) + data: this.format(total + ? this.stats.users.local.total + : sum(this.stats.users.local.inc, negate(this.stats.users.local.dec)) + ) }, { label: 'Remote', fill: true, @@ -266,7 +340,10 @@ export default Vue.extend({ borderWidth: 2, pointBackgroundColor: '#fff', lineTension: 0, - data: data.map(x => ({ t: x.date, y: x.remoteCount })) + data: this.format(total + ? this.stats.users.remote.total + : sum(this.stats.users.remote.inc, negate(this.stats.users.remote.dec)) + ) }] }, { scales: { @@ -290,14 +367,6 @@ export default Vue.extend({ }, driveChart(): any { - const data = this.stats.slice().reverse().map(x => ({ - date: new Date(x.date), - localInc: x.drive.local.incSize, - localDec: -x.drive.local.decSize, - remoteInc: x.drive.remote.incSize, - remoteDec: -x.drive.remote.decSize, - })); - return [{ datasets: [{ label: 'All', @@ -307,7 +376,7 @@ export default Vue.extend({ borderDash: [4, 4], pointBackgroundColor: '#fff', lineTension: 0, - data: data.map(x => ({ t: x.date, y: x.localInc + x.localDec + x.remoteInc + x.remoteDec })) + data: this.format(sum(this.stats.drive.local.incSize, negate(this.stats.drive.local.decSize), this.stats.drive.remote.incSize, negate(this.stats.drive.remote.decSize))) }, { label: 'Local +', fill: true, @@ -316,7 +385,7 @@ export default Vue.extend({ borderWidth: 2, pointBackgroundColor: '#fff', lineTension: 0, - data: data.map(x => ({ t: x.date, y: x.localInc })) + data: this.format(this.stats.drive.local.incSize) }, { label: 'Local -', fill: true, @@ -325,7 +394,7 @@ export default Vue.extend({ borderWidth: 2, pointBackgroundColor: '#fff', lineTension: 0, - data: data.map(x => ({ t: x.date, y: x.localDec })) + data: this.format(negate(this.stats.drive.local.decSize)) }, { label: 'Remote +', fill: true, @@ -334,7 +403,7 @@ export default Vue.extend({ borderWidth: 2, pointBackgroundColor: '#fff', lineTension: 0, - data: data.map(x => ({ t: x.date, y: x.remoteInc })) + data: this.format(this.stats.drive.remote.incSize) }, { label: 'Remote -', fill: true, @@ -343,7 +412,7 @@ export default Vue.extend({ borderWidth: 2, pointBackgroundColor: '#fff', lineTension: 0, - data: data.map(x => ({ t: x.date, y: x.remoteDec })) + data: this.format(negate(this.stats.drive.remote.decSize)) }] }, { scales: { @@ -367,12 +436,6 @@ export default Vue.extend({ }, driveTotalChart(): any { - const data = this.stats.slice().reverse().map(x => ({ - date: new Date(x.date), - localSize: x.drive.local.totalSize, - remoteSize: x.drive.remote.totalSize - })); - return [{ datasets: [{ label: 'Combined', @@ -382,7 +445,7 @@ export default Vue.extend({ borderDash: [4, 4], pointBackgroundColor: '#fff', lineTension: 0, - data: data.map(x => ({ t: x.date, y: x.remoteSize + x.localSize })) + data: this.format(sum(this.stats.drive.local.totalSize, this.stats.drive.remote.totalSize)) }, { label: 'Local', fill: true, @@ -391,7 +454,7 @@ export default Vue.extend({ borderWidth: 2, pointBackgroundColor: '#fff', lineTension: 0, - data: data.map(x => ({ t: x.date, y: x.localSize })) + data: this.format(this.stats.drive.local.totalSize) }, { label: 'Remote', fill: true, @@ -400,7 +463,7 @@ export default Vue.extend({ borderWidth: 2, pointBackgroundColor: '#fff', lineTension: 0, - data: data.map(x => ({ t: x.date, y: x.remoteSize })) + data: this.format(this.stats.drive.remote.totalSize) }] }, { scales: { @@ -424,14 +487,6 @@ export default Vue.extend({ }, driveFilesChart(): any { - const data = this.stats.slice().reverse().map(x => ({ - date: new Date(x.date), - localInc: x.drive.local.incCount, - localDec: -x.drive.local.decCount, - remoteInc: x.drive.remote.incCount, - remoteDec: -x.drive.remote.decCount - })); - return [{ datasets: [{ label: 'All', @@ -441,7 +496,7 @@ export default Vue.extend({ borderDash: [4, 4], pointBackgroundColor: '#fff', lineTension: 0, - data: data.map(x => ({ t: x.date, y: x.localInc + x.localDec + x.remoteInc + x.remoteDec })) + data: this.format(sum(this.stats.drive.local.incCount, negate(this.stats.drive.local.decCount), this.stats.drive.remote.incCount, negate(this.stats.drive.remote.decCount))) }, { label: 'Local +', fill: true, @@ -450,7 +505,7 @@ export default Vue.extend({ borderWidth: 2, pointBackgroundColor: '#fff', lineTension: 0, - data: data.map(x => ({ t: x.date, y: x.localInc })) + data: this.format(this.stats.drive.local.incCount) }, { label: 'Local -', fill: true, @@ -459,7 +514,7 @@ export default Vue.extend({ borderWidth: 2, pointBackgroundColor: '#fff', lineTension: 0, - data: data.map(x => ({ t: x.date, y: x.localDec })) + data: this.format(negate(this.stats.drive.local.decCount)) }, { label: 'Remote +', fill: true, @@ -468,7 +523,7 @@ export default Vue.extend({ borderWidth: 2, pointBackgroundColor: '#fff', lineTension: 0, - data: data.map(x => ({ t: x.date, y: x.remoteInc })) + data: this.format(this.stats.drive.remote.incCount) }, { label: 'Remote -', fill: true, @@ -477,7 +532,7 @@ export default Vue.extend({ borderWidth: 2, pointBackgroundColor: '#fff', lineTension: 0, - data: data.map(x => ({ t: x.date, y: x.remoteDec })) + data: this.format(negate(this.stats.drive.remote.decCount)) }] }, { scales: { @@ -501,12 +556,6 @@ export default Vue.extend({ }, driveFilesTotalChart(): any { - const data = this.stats.slice().reverse().map(x => ({ - date: new Date(x.date), - localCount: x.drive.local.totalCount, - remoteCount: x.drive.remote.totalCount, - })); - return [{ datasets: [{ label: 'Combined', @@ -516,7 +565,7 @@ export default Vue.extend({ borderDash: [4, 4], pointBackgroundColor: '#fff', lineTension: 0, - data: data.map(x => ({ t: x.date, y: x.localCount + x.remoteCount })) + data: this.format(sum(this.stats.drive.local.totalCount, this.stats.drive.remote.totalCount)) }, { label: 'Local', fill: true, @@ -525,7 +574,7 @@ export default Vue.extend({ borderWidth: 2, pointBackgroundColor: '#fff', lineTension: 0, - data: data.map(x => ({ t: x.date, y: x.localCount })) + data: this.format(this.stats.drive.local.totalCount) }, { label: 'Remote', fill: true, @@ -534,7 +583,7 @@ export default Vue.extend({ borderWidth: 2, pointBackgroundColor: '#fff', lineTension: 0, - data: data.map(x => ({ t: x.date, y: x.remoteCount })) + data: this.format(this.stats.drive.remote.totalCount) }] }, { scales: { @@ -558,30 +607,26 @@ 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', + label: 'Incoming', 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 })) + data: this.format(this.stats.network.incomingRequests) }] }]; }, 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, - })); + const data = []; + + for (let i = 0; i < limit; i++) { + data.push(this.stats.network.incomingRequests[i] != 0 ? (this.stats.network.totalTime[i] / this.stats.network.incomingRequests[i]) : 0); + } return [{ datasets: [{ @@ -592,18 +637,12 @@ export default Vue.extend({ borderWidth: 2, pointBackgroundColor: '#fff', lineTension: 0, - data: data.map(x => ({ t: x.date, y: x.time })) + data: this.format(data) }] }]; }, 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', @@ -613,7 +652,7 @@ export default Vue.extend({ borderWidth: 2, pointBackgroundColor: '#fff', lineTension: 0, - data: data.map(x => ({ t: x.date, y: x.incoming })) + data: this.format(this.stats.network.incomingBytes) }, { label: 'Outgoing', fill: true, @@ -622,7 +661,7 @@ export default Vue.extend({ borderWidth: 2, pointBackgroundColor: '#fff', lineTension: 0, - data: data.map(x => ({ t: x.date, y: x.outgoing })) + data: this.format(this.stats.network.outgoingBytes) }] }, { scales: { @@ -649,8 +688,6 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> - - .gkgckalzgidaygcxnugepioremxvxvpt padding 32px background #fff diff --git a/src/client/app/desktop/views/components/drive-window.vue b/src/client/app/desktop/views/components/drive-window.vue index 191579538d..aa3c2b6b36 100644 --- a/src/client/app/desktop/views/components/drive-window.vue +++ b/src/client/app/desktop/views/components/drive-window.vue @@ -2,7 +2,7 @@ <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> + <span :class="$style.title">%fa:cloud%%i18n:common.drive%</span> </template> <mk-drive :class="$style.browser" multiple :init-folder="folder" ref="browser"/> </mk-window> diff --git a/src/client/app/desktop/views/components/drive.nav-folder.vue b/src/client/app/desktop/views/components/drive.nav-folder.vue index 40f620875e..4c20e139aa 100644 --- a/src/client/app/desktop/views/components/drive.nav-folder.vue +++ b/src/client/app/desktop/views/components/drive.nav-folder.vue @@ -8,7 +8,7 @@ @drop.stop="onDrop" > <template v-if="folder == null">%fa:cloud%</template> - <span>{{ folder == null ? '%i18n:@drive%' : folder.name }}</span> + <span>{{ folder == null ? '%i18n:common.drive%' : folder.name }}</span> </div> </template> diff --git a/src/client/app/desktop/views/components/drive.vue b/src/client/app/desktop/views/components/drive.vue index 1376a04d99..054ba8b358 100644 --- a/src/client/app/desktop/views/components/drive.vue +++ b/src/client/app/desktop/views/components/drive.vue @@ -117,11 +117,11 @@ export default Vue.extend({ mounted() { this.connection = (this as any).os.stream.useSharedConnection('drive'); - this.connection.on('file_created', this.onStreamDriveFileCreated); - this.connection.on('file_updated', this.onStreamDriveFileUpdated); - this.connection.on('file_deleted', this.onStreamDriveFileDeleted); - this.connection.on('folder_created', this.onStreamDriveFolderCreated); - this.connection.on('folder_updated', this.onStreamDriveFolderUpdated); + this.connection.on('fileCreated', this.onStreamDriveFileCreated); + this.connection.on('fileUpdated', this.onStreamDriveFileUpdated); + this.connection.on('fileDeleted', this.onStreamDriveFileDeleted); + this.connection.on('folderCreated', this.onStreamDriveFolderCreated); + this.connection.on('folderUpdated', this.onStreamDriveFolderUpdated); if (this.initFolder) { this.move(this.initFolder); diff --git a/src/client/app/desktop/views/components/ellipsis-icon.vue b/src/client/app/desktop/views/components/ellipsis-icon.vue deleted file mode 100644 index 4a5a0f23dc..0000000000 --- a/src/client/app/desktop/views/components/ellipsis-icon.vue +++ /dev/null @@ -1,37 +0,0 @@ -<template> -<div class="mk-ellipsis-icon"> - <div></div><div></div><div></div> -</div> -</template> - -<style lang="stylus" scoped> -.mk-ellipsis-icon - width 70px - margin 0 auto - text-align center - - > div - display inline-block - width 18px - height 18px - background-color rgba(#000, 0.3) - border-radius 100% - animation bounce 1.4s infinite ease-in-out both - - &:nth-child(1) - animation-delay 0s - - &:nth-child(2) - margin 0 6px - animation-delay 0.16s - - &:nth-child(3) - animation-delay 0.32s - - @keyframes bounce - 0%, 80%, 100% - transform scale(0) - 40% - transform scale(1) - -</style> diff --git a/src/client/app/desktop/views/components/friends-maker.vue b/src/client/app/desktop/views/components/friends-maker.vue index 4e8a212b00..d64890fdbc 100644 --- a/src/client/app/desktop/views/components/friends-maker.vue +++ b/src/client/app/desktop/views/components/friends-maker.vue @@ -8,7 +8,6 @@ <router-link class="name" :to="user | userPage" v-user-preview="user.id">{{ user | userName }}</router-link> <p class="username">@{{ user | acct }}</p> </div> - <mk-follow-button :user="user"/> </div> </div> <p class="empty" v-if="!fetching && users.length == 0">%i18n:@empty%</p> diff --git a/src/client/app/desktop/views/components/home.vue b/src/client/app/desktop/views/components/home.vue index 9008e26263..42e936edd1 100644 --- a/src/client/app/desktop/views/components/home.vue +++ b/src/client/app/desktop/views/components/home.vue @@ -38,7 +38,7 @@ </div> </div> </div> - <div class="main"> + <div class="main" :class="{ side: widgets.left.length == 0 || widgets.right.length == 0 }"> <template v-if="customize"> <x-draggable v-for="place in ['left', 'right']" :list="widgets[place]" @@ -359,12 +359,10 @@ export default Vue.extend({ box-shadow var(--shadow) border-radius var(--round) - @media (max-width 700px) - padding 0 - - > .tl - border none - border-radius 0 + &.side + > .main + width calc(100% - 280px) + max-width 680px > *:not(.main) width 280px @@ -381,14 +379,24 @@ export default Vue.extend({ padding-right 16px order 3 - @media (max-width 1100px) - > *:not(.main) - display none + &.side + @media (max-width 1000px) + > *:not(.main) + display none - > .main - float none - width 100% - max-width 700px - margin 0 auto + > .main + width 100% + max-width 700px + margin 0 auto + + &:not(.side) + @media (max-width 1200px) + > *:not(.main) + display none + + > .main + width 100% + max-width 700px + margin 0 auto </style> diff --git a/src/client/app/desktop/views/components/index.ts b/src/client/app/desktop/views/components/index.ts index 7b7a38afa2..38b1547448 100644 --- a/src/client/app/desktop/views/components/index.ts +++ b/src/client/app/desktop/views/components/index.ts @@ -9,7 +9,6 @@ import subNoteContent from './sub-note-content.vue'; import window from './window.vue'; import noteFormWindow from './post-form-window.vue'; import renoteFormWindow from './renote-form-window.vue'; -import ellipsisIcon from './ellipsis-icon.vue'; import mediaImage from './media-image.vue'; import mediaImageDialog from './media-image-dialog.vue'; import mediaVideo from './media-video.vue'; @@ -39,7 +38,6 @@ Vue.component('mk-sub-note-content', subNoteContent); Vue.component('mk-window', window); Vue.component('mk-post-form-window', noteFormWindow); Vue.component('mk-renote-form-window', renoteFormWindow); -Vue.component('mk-ellipsis-icon', ellipsisIcon); Vue.component('mk-media-image', mediaImage); Vue.component('mk-media-image-dialog', mediaImageDialog); Vue.component('mk-media-video', mediaVideo); diff --git a/src/client/app/desktop/views/components/note-detail.vue b/src/client/app/desktop/views/components/note-detail.vue index b119f23d7a..dce5b12615 100644 --- a/src/client/app/desktop/views/components/note-detail.vue +++ b/src/client/app/desktop/views/components/note-detail.vue @@ -91,7 +91,7 @@ import MkPostFormWindow from './post-form-window.vue'; 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 XSub from './note.sub.vue'; import { sum } from '../../../../../prelude/array'; import noteSubscriber from '../../../common/scripts/note-subscriber'; diff --git a/src/client/app/desktop/views/components/notes.note.sub.vue b/src/client/app/desktop/views/components/note.sub.vue index ee52670f8f..5ba22fc76f 100644 --- a/src/client/app/desktop/views/components/notes.note.sub.vue +++ b/src/client/app/desktop/views/components/note.sub.vue @@ -1,5 +1,5 @@ <template> -<div class="tkfdzaxtkdeianobciwadajxzbddorql" :title="title"> +<div class="tkfdzaxtkdeianobciwadajxzbddorql" :class="{ mini }" :title="title"> <mk-avatar class="avatar" :user="note.user"/> <div class="main"> <mk-note-header class="header" :note="note"/> @@ -24,6 +24,11 @@ export default Vue.extend({ note: { type: Object, required: true + }, + mini: { + type: Boolean, + required: false, + default: false } }, @@ -44,11 +49,19 @@ export default Vue.extend({ <style lang="stylus" scoped> .tkfdzaxtkdeianobciwadajxzbddorql display flex - margin 0 padding 16px 32px font-size 0.9em background var(--subNoteBg) + &.mini + padding 16px + font-size 10px + + > .avatar + margin 0 8px 0 0 + width 38px + height 38px + > .avatar flex-shrink 0 display block diff --git a/src/client/app/desktop/views/components/note.vue b/src/client/app/desktop/views/components/note.vue new file mode 100644 index 0000000000..c42b863b2a --- /dev/null +++ b/src/client/app/desktop/views/components/note.vue @@ -0,0 +1,379 @@ +<template> +<div + class="note" + :class="{ mini }" + v-show="appearNote.deletedAt == null" + :tabindex="appearNote.deletedAt == null ? '-1' : null" + v-hotkey="keymap" + :title="title" +> + <div class="conversation" v-if="detail && conversation.length > 0"> + <x-sub v-for="note in conversation" :key="note.id" :note="note" :mini="mini"/> + </div> + <div class="reply-to" v-if="appearNote.reply && (!$store.getters.isSignedIn || $store.state.settings.showReplyTarget)"> + <x-sub :note="appearNote.reply" :mini="mini"/> + </div> + <div class="renote" v-if="isRenote"> + <mk-avatar class="avatar" :user="note.user"/> + %fa:retweet% + <span>{{ '%i18n:@reposted-by%'.substr(0, '%i18n:@reposted-by%'.indexOf('{')) }}</span> + <router-link class="name" :to="note.user | userPage" v-user-preview="note.userId">{{ note.user | userName }}</router-link> + <span>{{ '%i18n:@reposted-by%'.substr('%i18n:@reposted-by%'.indexOf('}') + 1) }}</span> + <mk-time :time="note.createdAt"/> + </div> + <article> + <mk-avatar class="avatar" :user="appearNote.user"/> + <div class="main"> + <mk-note-header class="header" :note="appearNote"/> + <div class="body"> + <p v-if="appearNote.cw != null" class="cw"> + <span class="text" v-if="appearNote.cw != ''">{{ appearNote.cw }}</span> + <mk-cw-button v-model="showContent"/> + </p> + <div class="content" v-show="appearNote.cw == null || showContent"> + <div class="text"> + <span v-if="appearNote.isHidden" style="opacity: 0.5">%i18n:@private%</span> + <a class="reply" v-if="appearNote.reply">%fa:reply%</a> + <misskey-flavored-markdown v-if="appearNote.text" :text="appearNote.text" :i="$store.state.i" :class="$style.text"/> + <a class="rp" v-if="appearNote.renote">RN:</a> + </div> + <div class="files" v-if="appearNote.files.length > 0"> + <mk-media-list :media-list="appearNote.files"/> + </div> + <mk-poll v-if="appearNote.poll" :note="appearNote" ref="pollViewer"/> + <a class="location" v-if="appearNote.geo" :href="`https://maps.google.com/maps?q=${appearNote.geo.coordinates[1]},${appearNote.geo.coordinates[0]}`" target="_blank">%fa:map-marker-alt% 位置情報</a> + <div class="renote" v-if="appearNote.renote"><mk-note-preview :note="appearNote.renote" :mini="mini"/></div> + <mk-url-preview v-for="url in urls" :url="url" :key="url" :mini="mini"/> + </div> + </div> + <footer> + <mk-reactions-viewer :note="appearNote" ref="reactionsViewer"/> + <button class="replyButton" @click="reply()" title="%i18n:@reply%"> + <template v-if="appearNote.reply">%fa:reply-all%</template> + <template v-else>%fa:reply%</template> + <p class="count" v-if="appearNote.repliesCount > 0">{{ appearNote.repliesCount }}</p> + </button> + <button class="renoteButton" @click="renote()" title="%i18n:@renote%"> + %fa:retweet%<p class="count" v-if="appearNote.renoteCount > 0">{{ appearNote.renoteCount }}</p> + </button> + <button class="reactionButton" :class="{ reacted: appearNote.myReaction != null }" @click="react()" ref="reactButton" title="%i18n:@add-reaction%"> + %fa:plus%<p class="count" v-if="appearNote.reactions_count > 0">{{ appearNote.reactions_count }}</p> + </button> + <button @click="menu()" ref="menuButton"> + %fa:ellipsis-h% + </button> + </footer> + </div> + </article> + <div class="replies" v-if="detail && replies.length > 0"> + <x-sub v-for="note in replies" :key="note.id" :note="note" :mini="mini"/> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +import XSub from './note.sub.vue'; +import noteMixin from '../../../common/scripts/note-mixin'; +import noteSubscriber from '../../../common/scripts/note-subscriber'; + +export default Vue.extend({ + components: { + XSub + }, + + mixins: [ + noteMixin(), + noteSubscriber('note') + ], + + props: { + note: { + type: Object, + required: true + }, + detail: { + type: Boolean, + required: false, + default: false + }, + mini: { + type: Boolean, + required: false, + default: false + } + }, + + data() { + return { + conversation: [], + replies: [] + }; + }, + + created() { + if (this.detail) { + (this as any).api('notes/replies', { + noteId: this.appearNote.id, + limit: 8 + }).then(replies => { + this.replies = replies; + }); + + (this as any).api('notes/conversation', { + noteId: this.appearNote.replyId + }).then(conversation => { + this.conversation = conversation.reverse(); + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +.note + margin 0 + padding 0 + background var(--face) + border-bottom solid 1px var(--faceDivider) + + &.mini + font-size 13px + + > .renote + padding 8px 16px 0 16px + + .avatar + width 20px + height 20px + + > article + padding 16px 16px 4px + + > .avatar + margin 0 10px 8px 0 + width 42px + height 42px + + &:last-of-type + border-bottom none + + &:focus + z-index 1 + + &:after + content "" + pointer-events none + position absolute + top 2px + right 2px + bottom 2px + left 2px + border 2px solid var(--primaryAlpha03) + border-radius 4px + + > .renote + display flex + align-items center + padding 16px 32px 8px 32px + line-height 28px + white-space pre + color var(--renoteText) + background linear-gradient(to bottom, var(--renoteGradient) 0%, var(--face) 100%) + + .avatar + flex-shrink 0 + display inline-block + width 28px + height 28px + margin 0 8px 0 0 + border-radius 6px + + [data-fa] + margin-right 4px + + > span + flex-shrink 0 + + &:last-of-type + margin-right 8px + + .name + overflow hidden + flex-shrink 1 + text-overflow ellipsis + white-space nowrap + font-weight bold + + > .mk-time + display block + margin-left auto + flex-shrink 0 + font-size 0.9em + + & + article + padding-top 8px + + > article + display flex + padding 28px 32px 18px 32px + + &:hover + > .main > footer > button + color var(--noteActionsHighlighted) + + > .avatar + flex-shrink 0 + display block + margin 0 16px 10px 0 + width 58px + height 58px + border-radius 8px + //position -webkit-sticky + //position sticky + //top 74px + + > .main + flex 1 + min-width 0 + + > .header + margin-bottom 4px + + > .body + + > .cw + cursor default + display block + margin 0 + padding 0 + overflow-wrap break-word + color var(--noteText) + + > .text + margin-right 8px + + > .content + + > .text + cursor default + display block + margin 0 + padding 0 + overflow-wrap break-word + color var(--noteText) + + >>> .title + display block + margin-bottom 4px + padding 4px + font-size 90% + text-align center + background var(--mfmTitleBg) + border-radius 4px + + >>> .code + margin 8px 0 + + >>> .quote + margin 8px + padding 6px 12px + color var(--mfmQuote) + border-left solid 3px var(--mfmQuoteLine) + + > .reply + margin-right 8px + color var(--text) + + > .rp + margin-left 4px + font-style oblique + color var(--renoteText) + + > .location + margin 4px 0 + font-size 12px + color #ccc + + > .map + width 100% + height 300px + + &:empty + display none + + .mk-url-preview + margin-top 8px + + > .mk-poll + font-size 80% + + > .renote + margin 8px 0 + + > * + padding 16px + border dashed 1px var(--quoteBorder) + border-radius 8px + + > footer + > button + margin 0 28px 0 0 + padding 0 8px + line-height 32px + font-size 1em + color var(--noteActions) + background transparent + border none + cursor pointer + + &:last-child + margin-right 0 + + &:hover + color var(--noteActionsHover) + + &.replyButton:hover + color var(--noteActionsReplyHover) + + &.renoteButton:hover + color var(--noteActionsRenoteHover) + + &.reactionButton:hover + color var(--noteActionsReactionHover) + + > .count + display inline + margin 0 0 0 8px + color #999 + + &.reacted, &.reacted:hover + color var(--noteActionsReactionHover) + +</style> + +<style lang="stylus" module> +.text + + code + padding 4px 8px + margin 0 0.5em + font-size 80% + color #525252 + background #f8f8f8 + border-radius 2px + + pre > code + padding 16px + margin 0 + + [data-is-me]:after + content "you" + padding 0 4px + margin-left 4px + font-size 80% + color var(--primaryForeground) + background var(--primary) + border-radius 4px +</style> diff --git a/src/client/app/desktop/views/components/notes.note.vue b/src/client/app/desktop/views/components/notes.note.vue deleted file mode 100644 index 2db1479823..0000000000 --- a/src/client/app/desktop/views/components/notes.note.vue +++ /dev/null @@ -1,477 +0,0 @@ -<template> -<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> - <div class="renote" v-if="isRenote"> - <mk-avatar class="avatar" :user="note.user"/> - %fa:retweet% - <span>{{ '%i18n:@reposted-by%'.substr(0, '%i18n:@reposted-by%'.indexOf('{')) }}</span> - <a class="name" :href="note.user | userPage" v-user-preview="note.userId">{{ note.user | userName }}</a> - <span>{{ '%i18n:@reposted-by%'.substr('%i18n:@reposted-by%'.indexOf('}') + 1) }}</span> - <mk-time :time="note.createdAt"/> - </div> - <article> - <mk-avatar class="avatar" :user="p.user"/> - <div class="main"> - <mk-note-header class="header" :note="p"/> - <div class="body"> - <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> - <a class="reply" v-if="p.reply">%fa:reply%</a> - <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="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> - <mk-url-preview v-for="url in urls" :url="url" :key="url"/> - </div> - </div> - <footer v-if="p.deletedAt == null"> - <mk-reactions-viewer :note="p" ref="reactionsViewer"/> - <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%"> - %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%"> - %fa:plus%<p class="count" v-if="p.reactions_count > 0">{{ p.reactions_count }}</p> - </button> - <button @click="menu()" ref="menuButton"> - %fa:ellipsis-h% - </button> - <!-- <button title="%i18n:@detail"> - <template v-if="!isDetailOpened">%fa:caret-down%</template> - <template v-if="isDetailOpened">%fa:caret-up%</template> - </button> --> - </footer> - </div> - </article> - <div class="detail" v-if="isDetailOpened"> - <mk-note-status-graph width="462" height="130" :note="p"/> - </div> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import parse from '../../../../../mfm/parse'; - -import MkPostFormWindow from './post-form-window.vue'; -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); - if (target) { - if (target.hasAttribute('tabindex')) { - target.focus(); - } else { - focus(target, fn); - } - } -} - -export default Vue.extend({ - components: { - XSub - }, - - mixins: [noteSubscriber('note')], - - props: { - note: { - type: Object, - required: true - } - }, - - data() { - return { - showContent: false, - 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.fileIds.length == 0 && - this.note.poll == null); - }, - - p(): any { - return this.isRenote ? this.note.renote : this.note; - }, - - reactionsCount(): number { - return this.p.reactionCounts - ? 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); - return ast - .filter(t => (t.type == 'url' || t.type == 'link') && !t.silent) - .map(t => t.url); - } else { - return null; - } - } - }, - - methods: { - reply(viaKeyboard = false) { - (this as any).os.new(MkPostFormWindow, { - reply: this.p, - animation: !viaKeyboard - }).$once('closed', this.focus); - }, - - renote(viaKeyboard = false) { - (this as any).os.new(MkRenoteFormWindow, { - note: this.p, - animation: !viaKeyboard - }).$once('closed', this.focus); - }, - - renoteDirectly() { - (this as any).api('notes/create', { - renoteId: this.p.id - }); - }, - - react(viaKeyboard = false) { - this.blur(); - (this as any).os.new(MkReactionPicker, { - source: this.$refs.reactButton, - 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(viaKeyboard = false) { - (this as any).os.new(MkNoteMenu, { - source: this.$refs.menuButton, - note: this.p, - animation: !viaKeyboard - }).$once('closed', this.focus); - }, - - toggleShowContent() { - this.showContent = !this.showContent; - }, - - focus() { - this.$el.focus(); - }, - - blur() { - this.$el.blur(); - }, - - focusBefore() { - focus(this.$el, e => e.previousElementSibling); - }, - - focusAfter() { - focus(this.$el, e => e.nextElementSibling); - } - } -}); -</script> - -<style lang="stylus" scoped> -.note - margin 0 - padding 0 - background var(--face) - border-bottom solid 1px var(--faceDivider) - - &[data-round] - &:first-child - border-top-left-radius 6px - border-top-right-radius 6px - - > .renote - border-top-left-radius 6px - border-top-right-radius 6px - - &:last-of-type - border-bottom none - - &:focus - z-index 1 - - &:after - content "" - pointer-events none - position absolute - top 2px - right 2px - bottom 2px - left 2px - border 2px solid var(--primaryAlpha03) - border-radius 4px - - > .renote - display flex - align-items center - padding 16px 32px 8px 32px - line-height 28px - white-space pre - color var(--renoteText) - background linear-gradient(to bottom, var(--renoteGradient) 0%, var(--face) 100%) - - .avatar - display inline-block - width 28px - height 28px - margin 0 8px 0 0 - border-radius 6px - - [data-fa] - margin-right 4px - - > span - flex-shrink 0 - - &:last-of-type - margin-right 8px - - .name - overflow hidden - flex-shrink 1 - text-overflow ellipsis - white-space nowrap - font-weight bold - - > .mk-time - display block - margin-left auto - flex-shrink 0 - font-size 0.9em - - & + article - padding-top 8px - - > article - display flex - padding 28px 32px 18px 32px - - &:hover - > .main > footer > button - color var(--noteActionsHighlighted) - - > .avatar - flex-shrink 0 - display block - margin 0 16px 10px 0 - width 58px - height 58px - border-radius 8px - //position -webkit-sticky - //position sticky - //top 74px - - > .main - flex 1 - min-width 0 - - > .header - margin-bottom 4px - - > .body - - > .cw - cursor default - display block - margin 0 - padding 0 - overflow-wrap break-word - color var(--noteText) - - > .text - margin-right 8px - - > .content - - > .text - cursor default - display block - margin 0 - padding 0 - overflow-wrap break-word - color var(--noteText) - - >>> .title - display block - margin-bottom 4px - padding 4px - font-size 90% - text-align center - background var(--mfmTitleBg) - border-radius 4px - - >>> .code - margin 8px 0 - - >>> .quote - margin 8px - padding 6px 12px - color var(--mfmQuote) - border-left solid 3px var(--mfmQuoteLine) - - > .reply - margin-right 8px - color var(--text) - - > .rp - margin-left 4px - font-style oblique - color var(--renoteText) - - > .location - margin 4px 0 - font-size 12px - color #ccc - - > .map - width 100% - height 300px - - &:empty - display none - - .mk-url-preview - margin-top 8px - - > .mk-poll - font-size 80% - - > .renote - margin 8px 0 - - > * - padding 16px - border dashed 1px var(--quoteBorder) - border-radius 8px - - > footer - > button - margin 0 28px 0 0 - padding 0 8px - line-height 32px - font-size 1em - color var(--noteActions) - background transparent - border none - cursor pointer - - &:hover - color var(--noteActionsHover) - - &.replyButton:hover - color var(--noteActionsReplyHover) - - &.renoteButton:hover - color var(--noteActionsRenoteHover) - - &.reactionButton:hover - color var(--noteActionsReactionHover) - - > .count - display inline - margin 0 0 0 8px - color #999 - - &.reacted, &.reacted:hover - color var(--noteActionsReactionHover) - - > .detail - padding-top 4px - background rgba(#000, 0.0125) - -</style> - -<style lang="stylus" module> -.text - - code - padding 4px 8px - margin 0 0.5em - font-size 80% - color #525252 - background #f8f8f8 - border-radius 2px - - pre > code - padding 16px - margin 0 - - [data-is-me]:after - content "you" - padding 0 4px - margin-left 4px - font-size 80% - 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 84b13ed84e..54299f9335 100644 --- a/src/client/app/desktop/views/components/notes.vue +++ b/src/client/app/desktop/views/components/notes.vue @@ -4,9 +4,15 @@ <slot name="empty" v-if="notes.length == 0 && !fetching && requestInitPromise == null"></slot> - <div v-if="!fetching && requestInitPromise != null"> - <p>%i18n:@error%</p> - <button @click="resolveInitPromise">%i18n:@retry%</button> + <div v-if="!fetching && requestInitPromise != null" class="error"> + <p>%fa:exclamation-triangle% %i18n:common.error.title%</p> + <ui-button @click="resolveInitPromise">%i18n:common.error.retry%</ui-button> + </div> + + <div class="placeholder" v-if="fetching"> + <template v-for="i in 10"> + <mk-note-skeleton :key="i"/> + </template> </div> <!-- トランジションを有効にするとなぜかメモリリークする --> @@ -32,9 +38,8 @@ <script lang="ts"> import Vue from 'vue'; import * as config from '../../../config'; -import getNoteSummary from '../../../../../misc/get-note-summary'; -import XNote from './notes.note.vue'; +import XNote from './note.vue'; const displayLimit = 30; @@ -55,7 +60,6 @@ export default Vue.extend({ requestInitPromise: null as () => Promise<any[]>, notes: [], queue: [], - unreadCount: 0, fetching: true, moreFetching: false }; @@ -74,12 +78,10 @@ export default Vue.extend({ }, mounted() { - document.addEventListener('visibilitychange', this.onVisibilitychange, false); window.addEventListener('scroll', this.onScroll, { passive: true }); }, beforeDestroy() { - document.removeEventListener('visibilitychange', this.onVisibilitychange); window.removeEventListener('scroll', this.onScroll); }, @@ -141,10 +143,9 @@ export default Vue.extend({ } //#endregion - // 投稿が自分のものではないかつ、タブが非表示またはスクロール位置が最上部ではないならタイトルで通知 - if ((document.hidden || !this.isScrollTop()) && note.userId !== this.$store.state.i.id) { - this.unreadCount++; - document.title = `(${this.unreadCount}) ${getNoteSummary(note)}`; + // タブが非表示またはスクロール位置が最上部ではないならタイトルで通知 + if (document.hidden || !this.isScrollTop()) { + this.$store.commit('pushBehindNote', note); } if (this.isScrollTop()) { @@ -189,21 +190,9 @@ export default Vue.extend({ this.moreFetching = false; }, - clearNotification() { - this.unreadCount = 0; - document.title = (this as any).os.instanceName; - }, - - onVisibilitychange() { - if (!document.hidden) { - this.clearNotification(); - } - }, - onScroll() { if (this.isScrollTop()) { this.releaseQueue(); - this.clearNotification(); } if (this.$store.state.settings.fetchOnScroll !== false) { @@ -226,6 +215,20 @@ export default Vue.extend({ > * transition transform .3s ease, opacity .3s ease + > .error + max-width 300px + margin 0 auto + padding 32px + text-align center + color var(--text) + + > p + margin 0 0 8px 0 + + > .placeholder + padding 32px + opacity 0.3 + > .notes > .date display block diff --git a/src/client/app/desktop/views/components/notifications.vue b/src/client/app/desktop/views/components/notifications.vue index 95b8e1355a..e1a6c4c9ad 100644 --- a/src/client/app/desktop/views/components/notifications.vue +++ b/src/client/app/desktop/views/components/notifications.vue @@ -1,5 +1,11 @@ <template> <div class="mk-notifications"> + <div class="placeholder" v-if="fetching"> + <template v-for="i in 10"> + <mk-note-skeleton :key="i"/> + </template> + </div> + <div class="notifications" v-if="notifications.length != 0"> <!-- トランジションを有効にするとなぜかメモリリークする --> <component :is="!$store.state.device.reduceMotion ? 'transition-group' : 'div'" name="mk-notifications" class="transition" tag="div"> @@ -102,7 +108,6 @@ <template v-if="fetchingMoreNotifications">%fa:spinner .pulse .fw%</template>{{ fetchingMoreNotifications ? '%i18n:common.loading%' : '%i18n:@more%' }} </button> <p class="empty" v-if="notifications.length == 0 && !fetching">%i18n:@empty%</p> - <p class="loading" v-if="fetching">%fa:spinner .pulse .fw%%i18n:common.loading%<mk-ellipsis/></p> </div> </template> @@ -181,8 +186,7 @@ export default Vue.extend({ onNotification(notification) { // TODO: ユーザーが画面を見てないと思われるとき(ブラウザやタブがアクティブじゃないなど)は送信しない - this.connection.send({ - type: 'readNotification', + (this as any).os.stream.send('readNotification', { id: notification.id }); @@ -203,6 +207,10 @@ export default Vue.extend({ > * transition transform .3s ease, opacity .3s ease + > .placeholder + padding 16px + opacity 0.3 + > .notifications > div > .notification @@ -299,7 +307,7 @@ export default Vue.extend({ display block width 100% padding 16px - color #555 + color var(--text) border-top solid 1px rgba(#000, 0.05) &:hover @@ -318,15 +326,6 @@ export default Vue.extend({ margin 0 padding 16px text-align center - color #aaa - - > .loading - margin 0 - padding 16px - text-align center - color #aaa - - > [data-fa] - margin-right 4px + color var(--text) </style> diff --git a/src/client/app/desktop/views/components/post-form.vue b/src/client/app/desktop/views/components/post-form.vue index e25cc33579..a703382f38 100644 --- a/src/client/app/desktop/views/components/post-form.vue +++ b/src/client/app/desktop/views/components/post-form.vue @@ -12,7 +12,7 @@ </div> <div class="hashtags" v-if="recentHashtags.length > 0 && $store.state.settings.suggestRecentHashtags"> <b>%i18n:@recent-tags%:</b> - <a v-for="tag in recentHashtags.slice(0, 5)" @click="addTag(tag)" title="%@click-to-tagging%">#{{ tag }}</a> + <a v-for="tag in recentHashtags.slice(0, 5)" @click="addTag(tag)" title="%i18n:@click-to-tagging%">#{{ tag }}</a> </div> <input v-show="useCw" v-model="cw" placeholder="%i18n:@annotations%"> <textarea :class="{ with: (files.length != 0 || poll) }" @@ -45,7 +45,7 @@ <span v-if="visibility === 'specified'">%fa:envelope%</span> <span v-if="visibility === 'private'">%fa:lock%</span> </button> - <p class="text-count" :class="{ over: this.trimmedLength(text) > 1000 }">{{ 1000 - this.trimmedLength(text) }}</p> + <p class="text-count" :class="{ over: this.trimmedLength(text) > this.maxNoteTextLength }">{{ this.maxNoteTextLength - this.trimmedLength(text) }}</p> <button :class="{ posting }" class="submit" :disabled="!canPost" @click="post"> {{ posting ? '%i18n:@posting%' : submitText }}<mk-ellipsis v-if="posting"/> </button> @@ -65,6 +65,7 @@ import { host } from '../../../config'; import { erase, unique } from '../../../../../prelude/array'; import { length } from 'stringz'; import parseAcct from '../../../../../misc/acct/parse'; +import { toASCII } from 'punycode'; export default Vue.extend({ components: { @@ -106,10 +107,17 @@ export default Vue.extend({ visibleUsers: [], autocomplete: null, draghover: false, - recentHashtags: JSON.parse(localStorage.getItem('hashtags') || '[]') + recentHashtags: JSON.parse(localStorage.getItem('hashtags') || '[]'), + maxNoteTextLength: 1000 }; }, + created() { + (this as any).os.getMeta().then(meta => { + this.maxNoteTextLength = meta.maxNoteTextLength; + }); + }, + computed: { draftId(): string { return this.renote @@ -148,7 +156,7 @@ export default Vue.extend({ canPost(): boolean { return !this.posting && (1 <= this.text.length || 1 <= this.files.length || this.poll || this.renote) && - (length(this.text.trim()) <= 1000); + (length(this.text.trim()) <= this.maxNoteTextLength); } }, @@ -158,14 +166,14 @@ export default Vue.extend({ } if (this.reply && this.reply.user.host != null) { - this.text = `@${this.reply.user.username}@${this.reply.user.host} `; + this.text = `@${this.reply.user.username}@${toASCII(this.reply.user.host)} `; } if (this.reply && this.reply.text != null) { const ast = parse(this.reply.text); ast.filter(t => t.type == 'mention').forEach(x => { - const mention = x.host ? `@${x.username}@${x.host}` : `@${x.username}`; + const mention = x.host ? `@${x.username}@${toASCII(x.host)}` : `@${x.username}`; // 自分は除外 if (this.$store.state.i.username == x.username && x.host == null) return; diff --git a/src/client/app/desktop/views/components/settings.2fa.vue b/src/client/app/desktop/views/components/settings.2fa.vue index 3e8c860eba..201bce60d3 100644 --- a/src/client/app/desktop/views/components/settings.2fa.vue +++ b/src/client/app/desktop/views/components/settings.2fa.vue @@ -1,11 +1,11 @@ <template> <div class="2fa"> <p>%i18n:@intro%<a href="%i18n:@url%" target="_blank">%i18n:@detail%</a></p> - <div class="ui info warn"><p>%fa:exclamation-triangle%%i18n:@caution%</p></div> - <p v-if="!data && !$store.state.i.twoFactorEnabled"><button @click="register" class="ui primary">%i18n:@register%</button></p> + <ui-info warn>%i18n:@caution%</ui-info> + <p v-if="!data && !$store.state.i.twoFactorEnabled"><ui-button @click="register">%i18n:@register%</ui-button></p> <template v-if="$store.state.i.twoFactorEnabled"> <p>%i18n:@already-registered%</p> - <button @click="unregister" class="ui">%i18n:@unregister%</button> + <ui-button @click="unregister">%i18n:@unregister%</ui-button> </template> <div v-if="data"> <ol> @@ -13,7 +13,7 @@ <li>%i18n:@scan%<br><img :src="data.qr"></li> <li>%i18n:@done%<br> <input type="number" v-model="token" class="ui"> - <button @click="submit" class="ui primary">%i18n:@submit%</button> + <ui-button primary @click="submit">%i18n:@submit%</ui-button> </li> </ol> <div class="ui info"><p>%fa:info-circle%%i18n:@info%</p></div> diff --git a/src/client/app/desktop/views/components/settings.api.vue b/src/client/app/desktop/views/components/settings.api.vue deleted file mode 100644 index 113764c3e1..0000000000 --- a/src/client/app/desktop/views/components/settings.api.vue +++ /dev/null @@ -1,38 +0,0 @@ -<template> -<div class="root api"> - <p>%i18n:@token% <code>{{ $store.state.i.token }}</code></p> - <p>%i18n:@intro%</p> - <div class="ui info warn"><p>%fa:exclamation-triangle%%i18n:@caution%</p></div> - <p>%i18n:@regeneration-of-token%</p> - <button class="ui" @click="regenerateToken">%i18n:@regenerate-token%</button> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; - -export default Vue.extend({ - methods: { - regenerateToken() { - (this as any).apis.input({ - title: '%i18n:@enter-password%', - type: 'password' - }).then(password => { - (this as any).api('i/regenerate_token', { - password: password - }); - }); - } - } -}); -</script> - -<style lang="stylus" scoped> -.root.api - code - display inline-block - padding 4px 6px - color #555 - background #eee - border-radius 2px -</style> diff --git a/src/client/app/desktop/views/components/settings.drive.vue b/src/client/app/desktop/views/components/settings.drive.vue deleted file mode 100644 index d254b27110..0000000000 --- a/src/client/app/desktop/views/components/settings.drive.vue +++ /dev/null @@ -1,34 +0,0 @@ -<template> -<div class="root"> - <template v-if="!fetching"> - <p><b>{{ capacity | bytes }}</b>%i18n:max%<b>{{ usage | bytes }}</b>%i18n:in-use%</p> - </template> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -export default Vue.extend({ - data() { - return { - fetching: true, - usage: null, - capacity: null - }; - }, - mounted() { - (this as any).api('drive').then(info => { - this.capacity = info.capacity; - this.usage = info.usage; - this.fetching = false; - }); - } -}); -</script> - -<style lang="stylus" scoped> -.root - > p - > b - margin 0 8px -</style> diff --git a/src/client/app/desktop/views/components/settings.password.vue b/src/client/app/desktop/views/components/settings.password.vue index 39896daf67..82b163f1fa 100644 --- a/src/client/app/desktop/views/components/settings.password.vue +++ b/src/client/app/desktop/views/components/settings.password.vue @@ -1,6 +1,6 @@ <template> <div> - <button @click="reset" class="ui primary">%i18n:@reset%</button> + <ui-button @click="reset">%i18n:@reset%</ui-button> </div> </template> diff --git a/src/client/app/desktop/views/components/settings.profile.vue b/src/client/app/desktop/views/components/settings.profile.vue deleted file mode 100644 index 5f465a52bb..0000000000 --- a/src/client/app/desktop/views/components/settings.profile.vue +++ /dev/null @@ -1,106 +0,0 @@ -<template> -<div class="profile"> - <label class="avatar ui from group"> - <p>%i18n:@avatar%</p> - <img class="avatar" :src="$store.state.i.avatarUrl" alt="avatar"/> - <button class="ui" @click="updateAvatar">%i18n:@choice-avatar%</button> - </label> - <label class="ui from group"> - <ui-input v-model="name" type="text">%i18n:@name%</ui-input> - </label> - <label class="ui from group"> - <ui-input v-model="location" type="text">%i18n:@location%</ui-input> - </label> - <label class="ui from group"> - <ui-textarea v-model="description">%i18n:@description%</ui-textarea> - </label> - <label class="ui from group"> - <p>%i18n:@birthday%</p> - <input type="date" v-model="birthday"/> - </label> - <ui-button primary @click="save">%i18n:@save%</ui-button> - <section> - <h2>%i18n:@locked-account%</h2> - <ui-switch v-model="$store.state.i.isLocked" @change="onChangeIsLocked">%i18n:@is-locked%</ui-switch> - </section> - <section> - <h2>%i18n:@other%</h2> - <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> - -<script lang="ts"> -import Vue from 'vue'; - -export default Vue.extend({ - data() { - return { - name: null, - location: null, - description: null, - 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; - this.description = this.$store.state.i.description; - this.birthday = this.$store.state.i.profile.birthday; - }, - methods: { - updateAvatar() { - (this as any).apis.updateAvatar(); - }, - save() { - (this as any).api('i/update', { - name: this.name || null, - location: this.location || null, - description: this.description || null, - birthday: this.birthday || null - }).then(() => { - (this as any).apis.notify('%i18n:@profile-updated%'); - }); - }, - onChangeIsLocked() { - (this as any).api('i/update', { - isLocked: this.$store.state.i.isLocked - }); - }, - onChangeIsBot() { - (this as any).api('i/update', { - isBot: this.$store.state.i.isBot - }); - }, - onChangeIsCat() { - (this as any).api('i/update', { - isCat: this.$store.state.i.isCat - }); - } - } -}); -</script> - -<style lang="stylus" scoped> -.profile - > .avatar - > img - display inline-block - vertical-align top - width 64px - height 64px - border-radius 4px - - > button - margin-left 8px - -</style> - diff --git a/src/client/app/desktop/views/components/settings.vue b/src/client/app/desktop/views/components/settings.vue index 1cb8d4d4c8..b5c02e486e 100644 --- a/src/client/app/desktop/views/components/settings.vue +++ b/src/client/app/desktop/views/components/settings.vue @@ -2,38 +2,66 @@ <div class="mk-settings"> <div class="nav"> <p :class="{ active: page == 'profile' }" @mousedown="page = 'profile'">%fa:user .fw%%i18n:@profile%</p> + <p :class="{ active: page == 'theme' }" @mousedown="page = 'theme'">%fa:palette .fw%%i18n:@theme%</p> <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 == 'drive' }" @mousedown="page = 'drive'">%fa:cloud .fw%%i18n:common.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> <p :class="{ active: page == 'security' }" @mousedown="page = 'security'">%fa:unlock-alt .fw%%i18n:@security%</p> <p :class="{ active: page == 'api' }" @mousedown="page = 'api'">%fa:key .fw%API</p> <p :class="{ active: page == 'other' }" @mousedown="page = 'other'">%fa:cogs .fw%%i18n:@other%</p> </div> <div class="pages"> - <section class="profile" v-show="page == 'profile'"> - <h1>%i18n:@profile%</h1> - <x-profile/> - </section> + <div class="profile" v-show="page == 'profile'"> + <mk-profile-editor/> - <section class="web" v-show="page == 'web'"> - <h1>%i18n:@theme%</h1> - <mk-theme/> - </section> + <ui-card> + <div slot="title">%fa:B twitter% %i18n:@twitter%</div> + <section> + <mk-twitter-setting/> + </section> + </ui-card> + </div> + + <ui-card class="theme" v-show="page == 'theme'"> + <div slot="title">%fa:palette% %i18n:@theme%</div> + + <section> + <mk-theme/> + </section> + </ui-card> + + <ui-card class="web" v-show="page == 'web'"> + <div slot="title">%fa:sliders-h% %i18n:@behaviour%</div> + + <section> + <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> + <ui-switch v-model="deckNav">%i18n:@deck-nav%<span slot="desc">%i18n:@deck-nav-desc%</span></ui-switch> + + <details> + <summary>%i18n:@advanced%</summary> + <ui-switch v-model="apiViaStream"> + %i18n:@api-via-stream% + <span slot="desc">%i18n:@api-via-stream-desc%</span> + </ui-switch> + </details> + </section> - <section class="web" v-show="page == 'web'"> - <h1>%i18n:@behaviour%</h1> - <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:@timeline%</header> + <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> + </section> <section> <header>%i18n:@note-visibility%</header> @@ -49,24 +77,30 @@ </ui-select> </section> </section> + </ui-card> - <details> - <summary>%i18n:@advanced%</summary> - <ui-switch v-model="apiViaStream"> - %i18n:@api-via-stream% - <span slot="desc">%i18n:@api-via-stream-desc%</span> - </ui-switch> - </details> - </section> + <ui-card class="web" v-show="page == 'web'"> + <div slot="title">%fa:desktop% %i18n:@display%</div> - <section class="web" v-show="page == 'web'"> - <h1>%i18n:@display%</h1> - <div class="div"> - <button class="ui button" @click="customizeHome" style="margin-bottom: 16px">%i18n:@customize%</button> - </div> - <div class="div"> - <button class="ui" @click="updateWallpaper">%i18n:@choose-wallpaper%</button> - <button class="ui" @click="deleteWallpaper">%i18n:@delete-wallpaper%</button> + <section> + <ui-switch v-model="showPostFormOnTopOfTl">%i18n:@post-form-on-timeline%</ui-switch> + <ui-button @click="customizeHome">%i18n:@customize%</ui-button> + </section> + <section> + <header>%i18n:@wallpaper%</header> + <ui-button @click="updateWallpaper">%i18n:@choose-wallpaper%</ui-button> + <ui-button @click="deleteWallpaper">%i18n:@delete-wallpaper%</ui-button> + </section> + <section> + <header>%i18n:@navbar-position%</header> + <ui-radio v-model="navbar" value="top">%i18n:@navbar-position-top%</ui-radio> + <ui-radio v-model="navbar" value="left">%i18n:@navbar-position-left%</ui-radio> + <ui-radio v-model="navbar" value="right">%i18n:@navbar-position-right%</ui-radio> + </section> + <section> + <ui-switch v-model="deckDefault">%i18n:@deck-default%</ui-switch> + </section> + <section> <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> @@ -75,185 +109,202 @@ <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> - <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> + <section> + <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="showMaps">%i18n:@show-maps%</ui-switch> + <ui-switch v-model="disableAnimatedMfm">%i18n:common.disable-animated-mfm%</ui-switch> + </section> + <section> + <header>%i18n:@deck-column-align%</header> + <ui-radio v-model="deckColumnAlign" value="center">%i18n:@deck-column-align-center%</ui-radio> + <ui-radio v-model="deckColumnAlign" value="left">%i18n:@deck-column-align-left%</ui-radio> + </section> + <section> + <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> + </ui-card> - <section class="web" v-show="page == 'web'"> - <h1>%i18n:@sound%</h1> - <ui-switch v-model="enableSounds"> - %i18n:@enable-sounds% - <span slot="desc">%i18n:@enable-sounds-desc%</span> - </ui-switch> - <label>%i18n:@volume%</label> - <input type="range" - v-model="soundVolume" - :disabled="!enableSounds" - max="1" - step="0.1" - /> - <button class="ui button" @click="soundTest">%fa:volume-up% %i18n:@test%</button> - </section> + <ui-card class="web" v-show="page == 'web'"> + <div slot="title">%fa:volume-up% %i18n:@sound%</div> - <section class="web" v-show="page == 'web'"> - <h1>%i18n:@mobile%</h1> - <ui-switch v-model="disableViaMobile">%i18n:@disable-via-mobile%</ui-switch> - </section> + <section> + <ui-switch v-model="enableSounds"> + %i18n:@enable-sounds% + <span slot="desc">%i18n:@enable-sounds-desc%</span> + </ui-switch> + <label>%i18n:@volume%</label> + <input type="range" + v-model="soundVolume" + :disabled="!enableSounds" + max="1" + step="0.1" + /> + <ui-button @click="soundTest">%fa:volume-up% %i18n:@test%</ui-button> + </section> + </ui-card> - <section class="web" v-show="page == 'web'"> - <h1>%i18n:@language%</h1> - <select v-model="lang" placeholder="%i18n:@pick-language%"> - <optgroup label="%i18n:@recommended%"> - <option value="">%i18n:@auto%</option> - </optgroup> + <ui-card class="web" v-show="page == 'web'"> + <div slot="title">%fa:language% %i18n:@language%</div> + <section class="fit-top"> + <ui-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> - </section> + <optgroup label="%i18n:@specify-language%"> + <option v-for="x in langs" :value="x[0]" :key="x[0]">{{ x[1] }}</option> + </optgroup> + </ui-select> + <div class="none ui info"> + <p>%fa:info-circle%%i18n:@language-desc%</p> + </div> + </section> + </ui-card> - <section class="web" v-show="page == 'web'"> - <h1>%i18n:@cache%</h1> - <button class="ui button" @click="clean">%i18n:@clean-cache%</button> - <div class="none ui info warn"> - <p>%fa:exclamation-triangle%%i18n:@cache-warn%</p> - </div> - </section> + <ui-card class="web" v-show="page == 'web'"> + <div slot="title">%fa:trash-alt R% %i18n:@cache%</div> + <section> + <ui-button @click="clean">%i18n:@clean-cache%</ui-button> + <div class="none ui info warn"> + <p>%fa:exclamation-triangle%%i18n:@cache-warn%</p> + </div> + </section> + </ui-card> - <section class="notification" v-show="page == 'notification'"> - <h1>%i18n:@notification%</h1> - <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> + <ui-card class="notification" v-show="page == 'notification'"> + <div slot="title">%fa:bell R% %i18n:@notification%</div> + <section> + <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> + <ui-button @click="readAllUnreadNotes">%i18n:@mark-as-read-all-unread-notes%</ui-button> + </section> + </section> + </ui-card> - <section class="drive" v-show="page == 'drive'"> - <h1>%i18n:@drive%</h1> - <x-drive/> - </section> + <div class="drive" v-if="page == 'drive'"> + <mk-drive-settings/> + </div> - <section class="hashtags" v-show="page == 'hashtags'"> - <h1>%i18n:@tags%</h1> - <x-tags/> - </section> + <ui-card class="hashtags" v-show="page == 'hashtags'"> + <div slot="title">%fa:hashtag% %i18n:@tags%</div> + <section> + <x-tags/> + </section> + </ui-card> - <section class="mute" v-show="page == 'mute'"> - <h1>%i18n:@mute%</h1> - <x-mute/> - </section> + <ui-card class="mute" v-show="page == 'mute'"> + <div slot="title">%fa:ban% %i18n:@mute%</div> + <section> + <x-mute/> + </section> + </ui-card> - <section class="apps" v-show="page == 'apps'"> - <h1>%i18n:@apps%</h1> - <x-apps/> - </section> + <ui-card class="apps" v-show="page == 'apps'"> + <div slot="title">%fa:puzzle-piece% %i18n:@apps%</div> + <section> + <x-apps/> + </section> + </ui-card> - <section class="twitter" v-show="page == 'twitter'"> - <h1>Twitter</h1> - <mk-twitter-setting/> - </section> + <ui-card class="password" v-show="page == 'security'"> + <div slot="title">%fa:unlock-alt% %i18n:@password%</div> + <section> + <x-password/> + </section> + </ui-card> - <section class="password" v-show="page == 'security'"> - <h1>%i18n:@password%</h1> - <x-password/> - </section> + <ui-card class="2fa" v-show="page == 'security'"> + <div slot="title">%fa:mobile-alt% %i18n:@2fa%</div> + <section> + <x-2fa/> + </section> + </ui-card> - <section class="2fa" v-show="page == 'security'"> - <h1>%i18n:@2fa%</h1> - <x-2fa/> - </section> + <ui-card class="signin" v-show="page == 'security'"> + <div slot="title">%fa:sign-in-alt% %i18n:@signin%</div> + <section> + <x-signins/> + </section> + </ui-card> - <section class="signin" v-show="page == 'security'"> - <h1>%i18n:@signin%</h1> - <x-signins/> - </section> + <div class="api" v-show="page == 'api'"> + <mk-api-settings/> + </div> - <section class="api" v-show="page == 'api'"> - <h1>API</h1> - <x-api/> - </section> + <ui-card class="other" v-show="page == 'other'"> + <div slot="title">%fa:info-circle% %i18n:@about%</div> + <section> + <p v-if="meta">%i18n:@operator%: <i><a :href="meta.maintainer.url" target="_blank">{{ meta.maintainer.name }}</a></i></p> + </section> + </ui-card> - <section class="other" v-show="page == 'other'"> - <h1>%i18n:@about%</h1> - <p v-if="meta">%i18n:@operator%: <i><a :href="meta.maintainer.url" target="_blank">{{ meta.maintainer.name }}</a></i></p> - </section> + <ui-card class="other" v-show="page == 'other'"> + <div slot="title">%fa:sync-alt% %i18n:@update%</div> + <section> + <p> + <span>%i18n:@version% <i>{{ version }}</i></span> + <template v-if="latestVersion !== undefined"> + <br> + <span>%i18n:@latest-version% <i>{{ latestVersion ? latestVersion : version }}</i></span> + </template> + </p> + <button class="ui button block" @click="checkForUpdate" :disabled="checkingForUpdate"> + <template v-if="checkingForUpdate">%i18n:@update-checking%<mk-ellipsis/></template> + <template v-else>%i18n:@do-update%</template> + </button> + <details> + <summary>%i18n:@update-settings%</summary> + <ui-switch v-model="preventUpdate"> + %i18n:@prevent-update% + <span slot="desc">%i18n:@prevent-update-desc%</span> + </ui-switch> + </details> + </section> + </ui-card> - <section class="other" v-show="page == 'other'"> - <h1>%i18n:@update%</h1> - <p> - <span>%i18n:@version% <i>{{ version }}</i></span> - <template v-if="latestVersion !== undefined"> - <br> - <span>%i18n:@latest-version% <i>{{ latestVersion ? latestVersion : version }}</i></span> - </template> - </p> - <button class="ui button block" @click="checkForUpdate" :disabled="checkingForUpdate"> - <template v-if="checkingForUpdate">%i18n:@update-checking%<mk-ellipsis/></template> - <template v-else>%i18n:@do-update%</template> - </button> - <details> - <summary>%i18n:@update-settings%</summary> - <ui-switch v-model="preventUpdate"> - %i18n:@prevent-update% - <span slot="desc">%i18n:@prevent-update-desc%</span> + <ui-card class="other" v-show="page == 'other'"> + <div slot="title">%fa:cogs% %i18n:@advanced-settings%</div> + <section> + <ui-switch v-model="debug"> + %i18n:@debug-mode% + <span slot="desc">%i18n:@debug-mode-desc%</span> </ui-switch> - </details> - </section> - - <section class="other" v-show="page == 'other'"> - <h1>%i18n:@advanced-settings%</h1> - <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> + <ui-switch v-model="enableExperimentalFeatures"> + %i18n:@experimental% + <span slot="desc">%i18n:@experimental-desc%</span> + </ui-switch> + </section> + </ui-card> </div> </div> </template> <script lang="ts"> import Vue from 'vue'; -import XProfile from './settings.profile.vue'; import XMute from './settings.mute.vue'; import XPassword from './settings.password.vue'; import X2fa from './settings.2fa.vue'; -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'; export default Vue.extend({ components: { - XProfile, XMute, XPassword, X2fa, - XApi, XApps, XSignins, - XDrive, XTags }, props: { @@ -288,11 +339,31 @@ export default Vue.extend({ set(value) { this.$store.commit('device/set', { key: 'autoPopout', value }); } }, + deckNav: { + get() { return this.$store.state.settings.deckNav; }, + set(value) { this.$store.commit('settings/set', { key: 'deckNav', value }); } + }, + darkmode: { get() { return this.$store.state.device.darkmode; }, set(value) { this.$store.commit('device/set', { key: 'darkmode', value }); } }, + navbar: { + get() { return this.$store.state.device.navbar; }, + set(value) { this.$store.commit('device/set', { key: 'navbar', value }); } + }, + + deckColumnAlign: { + get() { return this.$store.state.device.deckColumnAlign; }, + set(value) { this.$store.commit('device/set', { key: 'deckColumnAlign', value }); } + }, + + deckDefault: { + get() { return this.$store.state.device.deckDefault; }, + set(value) { this.$store.commit('device/set', { key: 'deckDefault', value }); } + }, + enableSounds: { get() { return this.$store.state.device.enableSounds; }, set(value) { this.$store.commit('device/set', { key: 'enableSounds', value }); } @@ -426,11 +497,6 @@ export default Vue.extend({ 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() { @@ -439,6 +505,9 @@ export default Vue.extend({ }); }, methods: { + readAllUnreadNotes() { + (this as any).api('i/read_all_unread_notes'); + }, customizeHome() { this.$router.push('/i/customize-home'); this.$emit('done'); @@ -508,7 +577,8 @@ export default Vue.extend({ height 100% padding 16px 0 0 0 overflow auto - border-right solid 1px var(--faceDivider) + box-shadow var(--shadowRight) + z-index 1 > p display block @@ -534,34 +604,10 @@ export default Vue.extend({ height 100% flex auto overflow auto + background var(--bg) > section margin 32px color var(--text) - > h1 - margin 0 0 1em 0 - padding 0 0 8px 0 - font-size 1em - border-bottom solid 1px var(--faceDivider) - - &, >>> * - .ui.button.block - margin 16px 0 - - > section - margin 32px 0 - - > h2 - margin 0 0 1em 0 - padding 0 0 8px 0 - font-size 1em - color var(--text) - border-bottom solid 1px var(--faceDivider) - - > .web - > .div - border-bottom solid 1px var(--faceDivider) - margin 16px 0 - </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 fd8e658056..d36d1c6745 100644 --- a/src/client/app/desktop/views/components/sub-note-content.vue +++ b/src/client/app/desktop/views/components/sub-note-content.vue @@ -5,7 +5,7 @@ <span v-if="note.deletedAt" style="opacity: 0.5">%i18n:@deleted%</span> <a class="reply" v-if="note.replyId">%fa:reply%</a> <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> + <a class="rp" v-if="note.renoteId" :href="`/notes/${note.renoteId}`">RN: ...</a> </div> <details v-if="note.files.length > 0"> <summary>({{ '%i18n:@media-count%'.replace('{}', note.files.length) }})</summary> diff --git a/src/client/app/desktop/views/components/timeline.core.vue b/src/client/app/desktop/views/components/timeline.core.vue index 2c17e936eb..f1af7116b2 100644 --- a/src/client/app/desktop/views/components/timeline.core.vue +++ b/src/client/app/desktop/views/components/timeline.core.vue @@ -1,9 +1,6 @@ <template> <div class="mk-timeline-core"> <mk-friends-maker v-if="src == 'home' && alone"/> - <div class="fetching" v-if="fetching"> - <mk-ellipsis-icon/> - </div> <mk-notes ref="timeline" :more="existMore ? more : null"> <p :class="$style.empty" slot="empty"> @@ -170,15 +167,10 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> - - .mk-timeline-core > .mk-friends-maker border-bottom solid 1px #eee - > .fetching - padding 64px 0 - </style> <style lang="stylus" module> 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 a541dea121..56a3ebdde0 100644 --- a/src/client/app/desktop/views/components/ui.header.account.vue +++ b/src/client/app/desktop/views/components/ui.header.account.vue @@ -11,7 +11,7 @@ <router-link :to="`/@${ $store.state.i.username }`">%fa:user%<span>%i18n:@profile%</span>%fa:angle-right%</router-link> </li> <li @click="drive"> - <p>%fa:cloud%<span>%i18n:@drive%</span>%fa:angle-right%</p> + <p>%fa:cloud%<span>%i18n:common.drive%</span>%fa:angle-right%</p> </li> <li> <router-link to="/i/favorites">%fa:star%<span>%i18n:@favorites%</span>%fa:angle-right%</router-link> @@ -19,7 +19,7 @@ <li @click="list"> <p>%fa:list%<span>%i18n:@lists%</span>%fa:angle-right%</p> </li> - <li @click="followRequests" v-if="$store.state.i.isLocked"> + <li @click="followRequests" v-if="($store.state.i.isLocked || $store.state.i.carefulBot)"> <p>%fa:envelope R%<span>%i18n:@follow-requests%<i v-if="$store.state.i.pendingReceivedFollowRequestsCount">{{ $store.state.i.pendingReceivedFollowRequestsCount }}</i></span>%fa:angle-right%</p> </li> </ul> @@ -157,6 +157,9 @@ export default Vue.extend({ font-family Meiryo, sans-serif text-decoration none + @media (max-width 1100px) + display none + [data-fa] margin-left 8px @@ -171,6 +174,9 @@ export default Vue.extend({ border-radius 4px transition filter 100ms ease + @media (max-width 1100px) + margin-left 8px + > .menu $bgcolor = var(--face) display block 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 122570a696..3acc25c0dd 100644 --- a/src/client/app/desktop/views/components/ui.header.nav.vue +++ b/src/client/app/desktop/views/components/ui.header.nav.vue @@ -2,18 +2,22 @@ <div class="nav"> <ul> <template v-if="$store.getters.isSignedIn"> - <li class="home" :class="{ active: $route.name == 'index' }" @click="goToTop"> - <router-link to="/"> - %fa:home% - <p>%i18n:@home%</p> - </router-link> - </li> - <li class="deck" :class="{ active: $route.name == 'deck' }" @click="goToTop"> - <router-link to="/deck"> - %fa:columns% - <p>%i18n:@deck%</p> - </router-link> - </li> + <template v-if="$store.state.device.deckDefault"> + <li class="deck" :class="{ active: $route.name == 'deck' || $route.name == 'index' }" @click="goToTop"> + <router-link to="/">%fa:columns%<p>%i18n:@deck%</p></router-link> + </li> + <li class="home" :class="{ active: $route.name == 'home' }" @click="goToTop"> + <router-link to="/home">%fa:home%<p>%i18n:@home%</p></router-link> + </li> + </template> + <template v-else> + <li class="home" :class="{ active: $route.name == 'home' || $route.name == 'index' }" @click="goToTop"> + <router-link to="/">%fa:home%<p>%i18n:@home%</p></router-link> + </li> + <li class="deck" :class="{ active: $route.name == 'deck' }" @click="goToTop"> + <router-link to="/deck">%fa:columns%<p>%i18n:@deck%</p></router-link> + </li> + </template> <li class="messaging"> <a @click="messaging"> %fa:comments% 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 9527792a34..a0d8cbdf83 100644 --- a/src/client/app/desktop/views/components/ui.header.post.vue +++ b/src/client/app/desktop/views/components/ui.header.post.vue @@ -17,8 +17,6 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> - - .note display inline-block padding 8px 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 d22efbf84f..0880f4b722 100644 --- a/src/client/app/desktop/views/components/ui.header.search.vue +++ b/src/client/app/desktop/views/components/ui.header.search.vue @@ -29,6 +29,9 @@ export default Vue.extend({ <style lang="stylus" scoped> .search + @media (max-width 800px) + display none !important + > [data-fa] display block position absolute @@ -58,6 +61,9 @@ export default Vue.extend({ transition color 0.5s ease, border 0.5s ease color var(--desktopHeaderSearchFg) + @media (max-width 1000px) + width 10em + &::placeholder color var(--desktopHeaderFg) diff --git a/src/client/app/desktop/views/components/ui.sidebar.vue b/src/client/app/desktop/views/components/ui.sidebar.vue new file mode 100644 index 0000000000..36b5b3958b --- /dev/null +++ b/src/client/app/desktop/views/components/ui.sidebar.vue @@ -0,0 +1,378 @@ +<template> +<div class="header" :class="navbar"> + <div class="body"> + <div class="post"> + <button @click="post" title="%i18n:@post%">%fa:pencil-alt%</button> + </div> + + <div class="nav" v-if="$store.getters.isSignedIn"> + <template v-if="$store.state.device.deckDefault"> + <div class="deck" :class="{ active: $route.name == 'deck' || $route.name == 'index' }" @click="goToTop"> + <router-link to="/">%fa:columns%</router-link> + </div> + <div class="home" :class="{ active: $route.name == 'home' }" @click="goToTop"> + <router-link to="/home">%fa:home%</router-link> + </div> + </template> + <template v-else> + <div class="home" :class="{ active: $route.name == 'home' || $route.name == 'index' }" @click="goToTop"> + <router-link to="/">%fa:home%</router-link> + </div> + <div class="deck" :class="{ active: $route.name == 'deck' }" @click="goToTop"> + <router-link to="/deck">%fa:columns%</router-link> + </div> + </template> + <div class="messaging"> + <a @click="messaging">%fa:comments%<template v-if="hasUnreadMessagingMessage">%fa:circle%</template></a> + </div> + <div class="game"> + <a @click="game">%fa:gamepad%<template v-if="hasGameInvitations">%fa:circle%</template></a> + </div> + </div> + + <div class="nav bottom" v-if="$store.getters.isSignedIn"> + <div> + <a @click="drive">%fa:cloud%</a> + </div> + <div ref="notificationsButton" :class="{ active: showNotifications }"> + <a @click="notifications">%fa:R bell%</a> + </div> + <div> + <a @click="settings">%fa:cog%</a> + </div> + </div> + + <div class="account"> + <router-link :to="`/@${ $store.state.i.username }`"> + <mk-avatar class="avatar" :user="$store.state.i"/> + </router-link> + + <div class="nav menu"> + <div class="signout"> + <a @click="signout">%fa:power-off%</a> + </div> + <div> + <router-link to="/i/favorites">%fa:star%</router-link> + </div> + <div v-if="($store.state.i.isLocked || $store.state.i.carefulBot)"> + <a @click="followRequests">%fa:envelope R%<i v-if="$store.state.i.pendingReceivedFollowRequestsCount">{{ $store.state.i.pendingReceivedFollowRequestsCount }}</i></a> + </div> + </div> + </div> + + <div class="nav dark"> + <div> + <a @click="dark"><template v-if="$store.state.device.darkmode">%fa:moon%</template><template v-else>%fa:R moon%</template></a> + </div> + </div> + </div> + + <transition :name="`slide-${navbar}`"> + <div class="notifications" v-if="showNotifications" ref="notifications" :class="navbar"> + <mk-notifications/> + </div> + </transition> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import MkUserListsWindow from './user-lists-window.vue'; +import MkFollowRequestsWindow from './received-follow-requests-window.vue'; +import MkSettingsWindow from './settings-window.vue'; +import MkDriveWindow from './drive-window.vue'; +import MkMessagingWindow from './messaging-window.vue'; +import MkGameWindow from './game-window.vue'; +import contains from '../../../common/scripts/contains'; + +export default Vue.extend({ + data() { + return { + hasGameInvitations: false, + connection: null, + showNotifications: false + }; + }, + + computed: { + hasUnreadMessagingMessage(): boolean { + return this.$store.getters.isSignedIn && this.$store.state.i.hasUnreadMessagingMessage; + }, + + navbar(): string { + return this.$store.state.device.navbar; + }, + }, + + mounted() { + if (this.$store.getters.isSignedIn) { + this.connection = (this as any).os.stream.useSharedConnection('main'); + + this.connection.on('reversiInvited', this.onReversiInvited); + this.connection.on('reversi_no_invites', this.onReversiNoInvites); + } + }, + + beforeDestroy() { + if (this.$store.getters.isSignedIn) { + this.connection.dispose(); + } + }, + + methods: { + onReversiInvited() { + this.hasGameInvitations = true; + }, + + onReversiNoInvites() { + this.hasGameInvitations = false; + }, + + messaging() { + (this as any).os.new(MkMessagingWindow); + }, + + game() { + (this as any).os.new(MkGameWindow); + }, + + post() { + (this as any).apis.post(); + }, + + drive() { + (this as any).os.new(MkDriveWindow); + }, + + list() { + const w = (this as any).os.new(MkUserListsWindow); + w.$once('choosen', list => { + this.$router.push(`i/lists/${ list.id }`); + }); + }, + + followRequests() { + (this as any).os.new(MkFollowRequestsWindow); + }, + + settings() { + (this as any).os.new(MkSettingsWindow); + }, + + signout() { + (this as any).os.signout(); + }, + + notifications() { + this.showNotifications ? this.closeNotifications() : this.openNotifications(); + }, + + openNotifications() { + this.showNotifications = true; + Array.from(document.querySelectorAll('body *')).forEach(el => { + el.addEventListener('mousedown', this.onMousedown); + }); + }, + + closeNotifications() { + this.showNotifications = false; + Array.from(document.querySelectorAll('body *')).forEach(el => { + el.removeEventListener('mousedown', this.onMousedown); + }); + }, + + onMousedown(e) { + e.preventDefault(); + if ( + !contains(this.$refs.notifications, e.target) && + this.$refs.notifications != e.target && + !contains(this.$refs.notificationsButton, e.target) && + this.$refs.notificationsButton != e.target + ) { + this.closeNotifications(); + } + return false; + }, + + dark() { + this.$store.commit('device/set', { + key: 'darkmode', + value: !this.$store.state.device.darkmode + }); + }, + + goToTop() { + window.scrollTo({ + top: 0, + behavior: 'smooth' + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +.header + $width = 68px + + position fixed + top 0 + z-index 1000 + width $width + height 100% + + &.left + left 0 + box-shadow var(--shadowRight) + + &.right + right 0 + box-shadow var(--shadowLeft) + + > .body + position fixed + top 0 + z-index 1 + width $width + height 100% + background var(--desktopHeaderBg) + + > .post + width $width + height $width + padding 12px + + > button + display inline-block + margin 0 + padding 0 + height 100% + width 100% + font-size 1.2em + font-weight normal + text-decoration none + color var(--primaryForeground) + background var(--primary) !important + outline none + border none + border-radius 100% + transition background 0.1s ease + cursor pointer + + * + pointer-events none + + &:hover + background var(--primaryLighten10) !important + + &:active + background var(--primaryDarken10) !important + transition background 0s ease + + > .nav.bottom + position absolute + bottom 128px + left 0 + + > .account + position absolute + bottom 64px + left 0 + width $width + height $width + padding 14px + + > .menu + display none + position absolute + bottom 64px + left 0 + background var(--desktopHeaderBg) + + &:hover + > .menu + display block + + > *:not(.menu) + display block + width 100% + height 100% + + > .avatar + pointer-events none + width 100% + height 100% + + > .dark + position absolute + bottom 0 + left 0 + width $width + height $width + + > .notifications + position fixed + top 0 + width 350px + height 100% + overflow auto + background var(--face) + + &.left + left $width + box-shadow var(--shadowRight) + + &.right + right $width + box-shadow var(--shadowLeft) + + .nav + > * + > * + display block + width $width + line-height 52px + text-align center + font-size 18px + color var(--desktopHeaderFg) + + &:hover + background rgba(0, 0, 0, 0.05) + color var(--desktopHeaderHoverFg) + text-decoration none + + &:active + background rgba(0, 0, 0, 0.1) + + &.left + .nav + > * + &.active + box-shadow -4px 0 var(--primary) inset + + &.right + .nav + > * + &.active + box-shadow 4px 0 var(--primary) inset + +.slide-left-enter-active, +.slide-left-leave-active { + transition: all 0.2s ease; +} + +.slide-left-enter, .slide-left-leave-to { + transform: translateX(-16px); + opacity: 0; +} + +.slide-right-enter-active, +.slide-right-leave-active { + transition: all 0.2s ease; +} + +.slide-right-enter, .slide-right-leave-to { + transform: translateX(16px); + opacity: 0; +} +</style> diff --git a/src/client/app/desktop/views/components/ui.vue b/src/client/app/desktop/views/components/ui.vue index 2d1e98447b..18465922f2 100644 --- a/src/client/app/desktop/views/components/ui.vue +++ b/src/client/app/desktop/views/components/ui.vue @@ -1,8 +1,9 @@ <template> <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"> + <x-header class="header" v-if="navbar == 'top'" v-show="!zenMode" ref="header"/> + <x-sidebar class="sidebar" v-if="navbar != 'top'" v-show="!zenMode" ref="sidebar"/> + <div class="content" :class="[{ sidebar: navbar != 'top', zen: zenMode }, navbar]"> <slot></slot> </div> <mk-stream-indicator v-if="$store.getters.isSignedIn"/> @@ -12,10 +13,12 @@ <script lang="ts"> import Vue from 'vue'; import XHeader from './ui.header.vue'; +import XSidebar from './ui.sidebar.vue'; export default Vue.extend({ components: { - XHeader + XHeader, + XSidebar }, data() { @@ -25,6 +28,10 @@ export default Vue.extend({ }, computed: { + navbar(): string { + return this.$store.state.device.navbar; + }, + style(): any { if (!this.$store.getters.isSignedIn || this.$store.state.i.wallpaperUrl == null) return {}; return { @@ -45,6 +52,12 @@ export default Vue.extend({ watch: { '$store.state.uiHeaderHeight'() { this.$el.style.paddingTop = this.$store.state.uiHeaderHeight + 'px'; + }, + + navbar() { + if (this.navbar != 'top') { + this.$store.commit('setUiHeaderHeight', 0); + } } }, @@ -60,7 +73,9 @@ export default Vue.extend({ toggleZenMode() { this.zenMode = !this.zenMode; this.$nextTick(() => { - this.$store.commit('setUiHeaderHeight', this.$refs.header.$el.offsetHeight); + if (this.$refs.header) { + this.$store.commit('setUiHeaderHeight', this.$refs.header.$el.offsetHeight); + } }); } } @@ -83,8 +98,13 @@ export default Vue.extend({ background-attachment fixed opacity 0.3 - > .header - @media (max-width 1000px) - display none + > .content.sidebar.left + padding-left 68px + + > .content.sidebar.right + padding-right 68px + + > .content.zen + padding 0 !important </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 3407851fc5..d370754fef 100644 --- a/src/client/app/desktop/views/components/user-list-timeline.vue +++ b/src/client/app/desktop/views/components/user-list-timeline.vue @@ -26,12 +26,14 @@ export default Vue.extend({ this.init(); }, beforeDestroy() { - this.connection.close(); + this.connection.dispose(); }, methods: { init() { - if (this.connection) this.connection.close(); - this.connection = new UserListStream((this as any).os, this.$store.state.i, this.list.id); + if (this.connection) this.connection.dispose(); + this.connection = (this as any).os.stream.connectToChannel('userList', { + listId: this.list.id + }); this.connection.on('note', this.onNote); this.connection.on('userAdded', this.onUserAdded); this.connection.on('userRemoved', this.onUserRemoved); 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 63b24cea47..5d03b30ef4 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 @@ -77,9 +77,8 @@ export default Vue.extend({ mounted() { this.connection.on('stats', this.onStats); this.connection.on('statsLog', this.onStatsLog); - this.connection.send({ - type: 'requestLog', - id: Math.random().toString(), + this.connection.send('requestLog', { + id: Math.random().toString().substr(2, 8), length: 200 }); }, diff --git a/src/client/app/desktop/views/pages/admin/admin.vue b/src/client/app/desktop/views/pages/admin/admin.vue index ad417e5121..c1f5a7f0e0 100644 --- a/src/client/app/desktop/views/pages/admin/admin.vue +++ b/src/client/app/desktop/views/pages/admin/admin.vue @@ -13,7 +13,7 @@ <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('drive')" :class="{ active: page == 'drive' }">%fa:cloud .fw%%i18n:common.drive%</li> --> <!-- <li @click="nav('update')" :class="{ active: page == 'update' }">%i18n:@update%</li> --> </ul> </nav> 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 e1490cb0e4..974c58235d 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 @@ -1,14 +1,14 @@ <template> -<x-widgets-column v-if="column.type == 'widgets'" :column="column" :is-stacked="isStacked"/> -<x-notifications-column v-else-if="column.type == 'notifications'" :column="column" :is-stacked="isStacked"/> -<x-tl-column v-else-if="column.type == 'home'" :column="column" :is-stacked="isStacked"/> -<x-tl-column v-else-if="column.type == 'local'" :column="column" :is-stacked="isStacked"/> -<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"/> +<x-widgets-column v-if="column.type == 'widgets'" :column="column" :is-stacked="isStacked" v-on="$listeners"/> +<x-notifications-column v-else-if="column.type == 'notifications'" :column="column" :is-stacked="isStacked" v-on="$listeners"/> +<x-tl-column v-else-if="column.type == 'home'" :column="column" :is-stacked="isStacked" v-on="$listeners"/> +<x-tl-column v-else-if="column.type == 'local'" :column="column" :is-stacked="isStacked" v-on="$listeners"/> +<x-tl-column v-else-if="column.type == 'hybrid'" :column="column" :is-stacked="isStacked" v-on="$listeners"/> +<x-tl-column v-else-if="column.type == 'global'" :column="column" :is-stacked="isStacked" v-on="$listeners"/> +<x-tl-column v-else-if="column.type == 'list'" :column="column" :is-stacked="isStacked" v-on="$listeners"/> +<x-tl-column v-else-if="column.type == 'hashtag'" :column="column" :is-stacked="isStacked" v-on="$listeners"/> +<x-mentions-column v-else-if="column.type == 'mentions'" :column="column" :is-stacked="isStacked" v-on="$listeners"/> +<x-direct-column v-else-if="column.type == 'direct'" :column="column" :is-stacked="isStacked" v-on="$listeners"/> </template> <script lang="ts"> @@ -38,6 +38,12 @@ export default Vue.extend({ required: false, default: false } + }, + + methods: { + focus() { + this.$children[0].focus(); + } } }); </script> 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 c372ef490e..9b812cce65 100644 --- a/src/client/app/desktop/views/pages/deck/deck.column.vue +++ b/src/client/app/desktop/views/pages/deck/deck.column.vue @@ -1,9 +1,9 @@ <template> <div class="dnpfarvgbnfmyzbdquhhzyxcmstpdqzs" :class="{ naked, narrow, active, isStacked, draghover, dragging, dropready }" @dragover.prevent.stop="onDragover" - @dragenter.prevent="onDragenter" @dragleave="onDragleave" - @drop.prevent.stop="onDrop"> + @drop.prevent.stop="onDrop" + v-hotkey="keymap"> <header :class="{ indicate: count > 0 }" draggable="true" @click="goTop" @@ -16,7 +16,8 @@ </button> <slot name="header"></slot> <span class="count" v-if="count > 0">({{ count }})</span> - <button class="menu" ref="menu" @click.stop="showMenu">%fa:caret-down%</button> + <button v-if="!isTemporaryColumn" class="menu" ref="menu" @click.stop="showMenu">%fa:caret-down%</button> + <button v-else class="close" @click.stop="close">%fa:times%</button> </header> <div ref="body" v-show="active"> <slot></slot> @@ -34,11 +35,13 @@ export default Vue.extend({ props: { column: { type: Object, - required: true + required: false, + default: null }, isStacked: { type: Boolean, - required: true + required: false, + default: false }, name: { type: String, @@ -61,6 +64,21 @@ export default Vue.extend({ } }, + computed: { + isTemporaryColumn(): boolean { + return this.column == null; + }, + + keymap(): any { + return { + 'shift+up': () => this.$parent.$emit('parentFocus', 'up'), + 'shift+down': () => this.$parent.$emit('parentFocus', 'down'), + 'shift+left': () => this.$parent.$emit('parentFocus', 'left'), + 'shift+right': () => this.$parent.$emit('parentFocus', 'right'), + }; + } + }, + inject: { getColumnVm: { from: 'getColumnVm' } }, @@ -96,14 +114,20 @@ export default Vue.extend({ mounted() { this.$refs.body.addEventListener('scroll', this.onScroll, { passive: true }); - this.$root.$on('deck.column.dragStart', this.onOtherDragStart); - this.$root.$on('deck.column.dragEnd', this.onOtherDragEnd); + + if (!this.isTemporaryColumn) { + this.$root.$on('deck.column.dragStart', this.onOtherDragStart); + this.$root.$on('deck.column.dragEnd', this.onOtherDragEnd); + } }, beforeDestroy() { this.$refs.body.removeEventListener('scroll', this.onScroll); - this.$root.$off('deck.column.dragStart', this.onOtherDragStart); - this.$root.$off('deck.column.dragEnd', this.onOtherDragEnd); + + if (!this.isTemporaryColumn) { + this.$root.$off('deck.column.dragStart', this.onOtherDragStart); + this.$root.$off('deck.column.dragEnd', this.onOtherDragEnd); + } }, methods: { @@ -203,6 +227,7 @@ export default Vue.extend({ }, onContextmenu(e) { + if (this.isTemporaryColumn) return; contextmenu((this as any).os)(e, this.getMenu()); }, @@ -214,6 +239,13 @@ export default Vue.extend({ }); }, + close() { + this.$store.commit('device/set', { + key: 'deckTemporaryColumn', + value: null + }); + }, + goTop() { this.$refs.body.scrollTo({ top: 0, @@ -222,6 +254,12 @@ export default Vue.extend({ }, onDragstart(e) { + // テンポラリカラムはドラッグさせない + if (this.isTemporaryColumn) { + e.preventDefault(); + return; + } + e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('mk-deck-column', this.column.id); this.dragging = true; @@ -232,6 +270,12 @@ export default Vue.extend({ }, onDragover(e) { + // テンポラリカラムにはドロップさせない + if (this.isTemporaryColumn) { + e.dataTransfer.dropEffect = 'none'; + return; + } + // 自分自身がドラッグされている場合 if (this.dragging) { // 自分自身にはドロップさせない @@ -242,9 +286,7 @@ export default Vue.extend({ const isDeckColumn = e.dataTransfer.types[0] == 'mk-deck-column'; e.dataTransfer.dropEffect = isDeckColumn ? 'move' : 'none'; - }, - onDragenter() { if (!this.dragging) this.draghover = true; }, @@ -276,13 +318,24 @@ export default Vue.extend({ min-width 330px height 100% background var(--face) - border-radius 6px - //box-shadow 0 2px 16px rgba(#000, 0.1) + border-radius var(--round) + box-shadow var(--shadow) overflow hidden &.draghover box-shadow 0 0 0 2px var(--primaryAlpha08) + &:after + content "" + display block + position absolute + z-index 1000 + top 0 + left 0 + width 100% + height 100% + background var(--primaryAlpha02) + &.dragging box-shadow 0 0 0 2px var(--primaryAlpha04) @@ -310,7 +363,7 @@ export default Vue.extend({ > header display flex - z-index 1 + z-index 2 line-height $header-height padding 0 16px font-size 14px @@ -338,6 +391,8 @@ export default Vue.extend({ > .toggleActive > .menu + > .close + padding 0 width $header-height line-height $header-height font-size 16px @@ -353,6 +408,7 @@ export default Vue.extend({ margin-left -16px > .menu + > .close margin-left auto margin-right -16px 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 index d5093761f4..7744a755e6 100644 --- a/src/client/app/desktop/views/pages/deck/deck.direct-column.vue +++ b/src/client/app/desktop/views/pages/deck/deck.direct-column.vue @@ -34,5 +34,11 @@ export default Vue.extend({ return '%i18n:common.deck.direct%'; } }, + + methods: { + focus() { + this.$refs.tl.focus(); + } + } }); </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 index c771e58a6e..47fb15370b 100644 --- a/src/client/app/desktop/views/pages/deck/deck.direct.vue +++ b/src/client/app/desktop/views/pages/deck/deck.direct.vue @@ -58,6 +58,7 @@ export default Vue.extend({ }, rej); })); }, + more() { this.moreFetching = true; @@ -82,11 +83,16 @@ export default Vue.extend({ return promise; }, + onNote(note) { // Prepend a note if (note.visibility == 'specified') { (this.$refs.timeline as any).prepend(note); } + }, + + focus() { + this.$refs.timeline.focus(); } } }); diff --git a/src/client/app/desktop/views/pages/deck/deck.hashtag-column.vue b/src/client/app/desktop/views/pages/deck/deck.hashtag-column.vue new file mode 100644 index 0000000000..2b5bf14b27 --- /dev/null +++ b/src/client/app/desktop/views/pages/deck/deck.hashtag-column.vue @@ -0,0 +1,112 @@ +<template> +<x-column> + <span slot="header"> + %fa:hashtag%<span>{{ tag }}</span> + </span> + + <div class="xroyrflcmhhtmlwmyiwpfqiirqokfueb"> + <div ref="chart" class="chart"></div> + <x-hashtag-tl :tag-tl="tagTl" class="tl"/> + </div> +</x-column> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import XColumn from './deck.column.vue'; +import XHashtagTl from './deck.hashtag-tl.vue'; +import * as ApexCharts from 'apexcharts'; + +export default Vue.extend({ + components: { + XColumn, + XHashtagTl + }, + + props: { + tag: { + type: String, + required: true + } + }, + + computed: { + tagTl(): any { + return { + query: [[this.tag]] + }; + } + }, + + mounted() { + (this as any).api('charts/hashtag', { + tag: this.tag, + span: 'hour', + limit: 24 + }).then(stats => { + const local = []; + const remote = []; + + const now = new Date(); + const y = now.getFullYear(); + const m = now.getMonth(); + const d = now.getDate(); + const h = now.getHours(); + + for (let i = 0; i < 24; i++) { + const x = new Date(y, m, d, h - i); + local.push([x, stats.local.count[i]]); + remote.push([x, stats.remote.count[i]]); + } + + const chart = new ApexCharts(this.$refs.chart, { + chart: { + type: 'area', + height: 70, + sparkline: { + enabled: true + }, + }, + grid: { + clipMarkers: false, + padding: { + top: 16, + right: 16, + bottom: 16, + left: 16 + } + }, + stroke: { + curve: 'straight', + width: 2 + }, + series: [{ + name: 'Local', + data: local + }, { + name: 'Remote', + data: remote + }], + xaxis: { + type: 'datetime', + } + }); + + chart.render(); + }); + } +}); +</script> + +<style lang="stylus" scoped> +.xroyrflcmhhtmlwmyiwpfqiirqokfueb + background var(--deckColumnBg) + + > .chart + margin-bottom 16px + background var(--face) + + > .tl + background var(--face) + +</style> 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 index 02d99d3883..a4fdc2ce72 100644 --- a/src/client/app/desktop/views/pages/deck/deck.hashtag-tl.vue +++ b/src/client/app/desktop/views/pages/deck/deck.hashtag-tl.vue @@ -1,5 +1,5 @@ <template> - <x-notes ref="timeline" :more="existMore ? more : null" :media-view="mediaView"/> +<x-notes ref="timeline" :more="existMore ? more : null" :media-view="mediaView"/> </template> <script lang="ts"> @@ -47,14 +47,16 @@ export default Vue.extend({ mounted() { if (this.connection) this.connection.close(); - this.connection = (this as any).os.stream.connectToChannel('hashtag', this.tagTl.query); + this.connection = (this as any).os.stream.connectToChannel('hashtag', { + q: this.tagTl.query + }); this.connection.on('note', this.onNote); this.fetch(); }, beforeDestroy() { - this.connection.close(); + this.connection.dispose(); }, methods: { @@ -80,6 +82,7 @@ export default Vue.extend({ }, rej); })); }, + more() { this.moreFetching = true; @@ -105,11 +108,16 @@ export default Vue.extend({ return promise; }, + onNote(note) { if (this.mediaOnly && note.files.length == 0) return; // Prepend a note (this.$refs.timeline as any).prepend(note); + }, + + focus() { + this.$refs.timeline.focus(); } } }); 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 e543130310..714c267668 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 @@ -1,5 +1,5 @@ <template> - <x-notes ref="timeline" :more="existMore ? more : null" :media-view="mediaView"/> +<x-notes ref="timeline" :more="existMore ? more : null" :media-view="mediaView"/> </template> <script lang="ts"> @@ -46,8 +46,10 @@ export default Vue.extend({ }, mounted() { - if (this.connection) this.connection.close(); - this.connection = new UserListStream((this as any).os, this.$store.state.i, this.list.id); + if (this.connection) this.connection.dispose(); + this.connection = (this as any).os.stream.connectToChannel('userList', { + listId: this.list.id + }); this.connection.on('note', this.onNote); this.connection.on('userAdded', this.onUserAdded); this.connection.on('userRemoved', this.onUserRemoved); @@ -56,7 +58,7 @@ export default Vue.extend({ }, beforeDestroy() { - this.connection.close(); + this.connection.dispose(); }, methods: { @@ -82,6 +84,7 @@ export default Vue.extend({ }, rej); })); }, + more() { this.moreFetching = true; @@ -107,17 +110,24 @@ export default Vue.extend({ return promise; }, + onNote(note) { if (this.mediaOnly && note.files.length == 0) return; // Prepend a note (this.$refs.timeline as any).prepend(note); }, + onUserAdded() { this.fetch(); }, + onUserRemoved() { this.fetch(); + }, + + focus() { + this.$refs.timeline.focus(); } } }); 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 index 8ec10164f2..6598832bab 100644 --- a/src/client/app/desktop/views/pages/deck/deck.mentions-column.vue +++ b/src/client/app/desktop/views/pages/deck/deck.mentions-column.vue @@ -2,7 +2,7 @@ <x-column :name="name" :column="column" :is-stacked="isStacked"> <span slot="header">%fa:at%{{ name }}</span> - <x-mentions/> + <x-mentions ref="tl"/> </x-column> </template> @@ -34,5 +34,11 @@ export default Vue.extend({ return '%i18n:common.deck.mentions%'; } }, + + methods: { + focus() { + this.$refs.tl.focus(); + } + } }); </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 index 17b572f146..7890e68409 100644 --- a/src/client/app/desktop/views/pages/deck/deck.mentions.vue +++ b/src/client/app/desktop/views/pages/deck/deck.mentions.vue @@ -57,6 +57,7 @@ export default Vue.extend({ }, rej); })); }, + more() { this.moreFetching = true; @@ -80,9 +81,14 @@ export default Vue.extend({ return promise; }, + onNote(note) { // Prepend a note (this.$refs.timeline as any).prepend(note); + }, + + focus() { + this.$refs.timeline.focus(); } } }); diff --git a/src/client/app/desktop/views/pages/deck/deck.note-column.vue b/src/client/app/desktop/views/pages/deck/deck.note-column.vue new file mode 100644 index 0000000000..8335c37bf2 --- /dev/null +++ b/src/client/app/desktop/views/pages/deck/deck.note-column.vue @@ -0,0 +1,74 @@ +<template> +<x-column> + <span slot="header"> + %fa:comment-alt R%<span>{{ title }}</span> + </span> + + <div class="rvtscbadixhhbsczoorqoaygovdeecsx" v-if="note"> + <div class="is-remote" v-if="note.user.host != null"> + <details> + <summary>%fa:exclamation-triangle% %i18n:common.is-remote-post%</summary> + <a :href="note.url || note.uri" target="_blank">%i18n:common.view-on-remote%</a> + </details> + </div> + <x-note :note="note" :detail="true" :mini="true"/> + </div> +</x-column> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import XColumn from './deck.column.vue'; +import XNotes from './deck.notes.vue'; +import XNote from '../../components/note.vue'; + +export default Vue.extend({ + components: { + XColumn, + XNotes, + XNote + }, + + props: { + noteId: { + type: String, + required: true + } + }, + + data() { + return { + note: null, + fetching: true + }; + }, + + computed: { + title(): string { + return this.note ? Vue.filter('userName')(this.note.user) : ''; + } + }, + + created() { + (this as any).api('notes/show', { noteId: this.noteId }).then(note => { + this.note = note; + this.fetching = false; + }); + } +}); +</script> + +<style lang="stylus" scoped> +.rvtscbadixhhbsczoorqoaygovdeecsx + > .is-remote + padding 8px 16px + font-size 12px + + &.is-remote + color var(--remoteInfoFg) + background var(--remoteInfoBg) + + > a + font-weight bold + +</style> 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 deleted file mode 100644 index 445bf7e365..0000000000 --- a/src/client/app/desktop/views/pages/deck/deck.note.sub.vue +++ /dev/null @@ -1,71 +0,0 @@ -<template> -<div class="fnlfosztlhtptnongximhlbykxblytcq"> - <mk-avatar class="avatar" :user="note.user"/> - <div class="main"> - <mk-note-header class="header" :note="note" :mini="true"/> - <div class="body"> - <mk-sub-note-content class="text" :note="note"/> - </div> - </div> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; - -export default Vue.extend({ - props: { - note: { - type: Object, - required: true - }, - // TODO - truncate: { - type: Boolean, - default: true - } - } -}); -</script> - -<style lang="stylus" scoped> -.fnlfosztlhtptnongximhlbykxblytcq - display flex - padding 16px - font-size 10px - background var(--subNoteBg) - - &.smart - > .main - width 100% - - > header - align-items center - - > .avatar - flex-shrink 0 - display block - margin 0 8px 0 0 - width 38px - height 38px - border-radius 8px - - > .main - flex 1 - min-width 0 - - > .header - margin-bottom 2px - - > .body - - > .text - margin 0 - padding 0 - color var(--subNoteText) - - pre - max-height 120px - font-size 80% - -</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 deleted file mode 100644 index e843ac54fe..0000000000 --- a/src/client/app/desktop/views/pages/deck/deck.note.vue +++ /dev/null @@ -1,360 +0,0 @@ -<template> -<div v-if="!mediaView" class="zyjjkidcqjnlegkqebitfviomuqmseqk" :class="{ renote: isRenote }"> - <div class="reply-to" v-if="p.reply && (!$store.getters.isSignedIn || $store.state.settings.showReplyTarget)"> - <x-sub :note="p.reply"/> - </div> - <div class="renote" v-if="isRenote"> - <mk-avatar class="avatar" :user="note.user"/> - %fa:retweet% - <span>{{ '%i18n:@reposted-by%'.substr(0, '%i18n:@reposted-by%'.indexOf('{')) }}</span> - <router-link class="name" :to="note.user | userPage">{{ note.user | userName }}</router-link> - <span>{{ '%i18n:@reposted-by%'.substr('%i18n:@reposted-by%'.indexOf('}') + 1) }}</span> - <mk-time :time="note.createdAt"/> - </div> - <article> - <mk-avatar class="avatar" :user="p.user"/> - <div class="main"> - <mk-note-header class="header" :note="p" :mini="true"/> - <div class="body"> - <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> - <a class="reply" v-if="p.reply">%fa:reply%</a> - <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="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> - <footer> - <mk-reactions-viewer :note="p" ref="reactionsViewer"/> - <button @click="reply"> - <template v-if="p.reply">%fa:reply-all%</template> - <template v-else>%fa:reply%</template> - </button> - <button @click="renote" title="Renote">%fa:retweet%</button> - <button :class="{ reacted: p.myReaction != null }" @click="react" ref="reactButton">%fa:plus%</button> - <button class="menu" @click="menu" ref="menuButton">%fa:ellipsis-h%</button> - </footer> - </div> - </article> -</div> -<div v-else class="srwrkujossgfuhrbnvqkybtzxpblgchi"> - <div v-if="note.files.length > 0"> - <mk-media-list :media-list="note.files"/> - </div> - <div v-if="note.renote && note.renote.files.length > 0"> - <mk-media-list :media-list="note.renote.files"/> - </div> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -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, - required: true - }, - mediaView: { - type: Boolean, - required: false, - default: false - } - }, - - data() { - return { - showContent: false - }; - }, - - computed: { - isRenote(): boolean { - return (this.note.renote && - this.note.text == null && - this.note.fileIds.length == 0 && - this.note.poll == null); - }, - - p(): any { - return this.isRenote ? this.note.renote : this.note; - }, - - urls(): string[] { - if (this.p.text) { - const ast = parse(this.p.text); - return ast - .filter(t => (t.type == 'url' || t.type == 'link') && !t.silent) - .map(t => t.url); - } else { - return null; - } - } - }, - - methods: { - 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, - note: this.p, - compact: true - }); - }, - - menu() { - (this as any).os.new(MkNoteMenu, { - source: this.$refs.menuButton, - note: this.p, - compact: true - }); - } - } -}); -</script> - -<style lang="stylus" scoped> -.srwrkujossgfuhrbnvqkybtzxpblgchi - font-size 13px - margin 4px 12px - - &:first-child - margin-top 12px - - &:last-child - margin-bottom 12px - -.zyjjkidcqjnlegkqebitfviomuqmseqk - font-size 13px - border-bottom solid 1px var(--faceDivider) - - &:last-of-type - border-bottom none - - &.smart - > article - > .main - > header - align-items center - margin-bottom 4px - - > .renote - display flex - align-items center - padding 8px 16px 0 16px - line-height 28px - white-space pre - color var(--renoteText) - background linear-gradient(to bottom, var(--renoteGradient) 0%, var(--face) 100%) - - .avatar - flex-shrink 0 - display inline-block - width 20px - height 20px - margin 0 8px 0 0 - border-radius 6px - - [data-fa] - margin-right 4px - - > span - flex-shrink 0 - - &:last-of-type - margin-right 8px - - .name - overflow hidden - flex-shrink 1 - text-overflow ellipsis - white-space nowrap - font-weight bold - - > .mk-time - display block - margin-left auto - flex-shrink 0 - font-size 0.9em - - & + article - padding-top 8px - - > article - display flex - padding 16px 16px 4px - - > .avatar - flex-shrink 0 - display block - margin 0 10px 8px 0 - width 42px - height 42px - border-radius 6px - //position -webkit-sticky - //position sticky - //top 62px - - > .main - flex 1 - min-width 0 - - > .body - - > .cw - cursor default - display block - margin 0 - padding 0 - overflow-wrap break-word - color var(--noteText) - - > .text - margin-right 8px - - > .content - - > .text - display block - margin 0 - padding 0 - overflow-wrap break-word - color var(--noteText) - - >>> .title - display block - margin-bottom 4px - padding 4px - font-size 90% - text-align center - background var(--mfmTitleBg) - border-radius 4px - - >>> .code - margin 8px 0 - - >>> .quote - margin 8px - padding 6px 12px - color var(--mfmQuote) - border-left solid 3px var(--mfmQuoteLine) - - > .reply - margin-right 8px - color var(--noteText) - - > .rp - margin-left 4px - font-style oblique - color var(--renoteText) - - [data-is-me]:after - content "you" - padding 0 4px - margin-left 4px - font-size 80% - color var(--primaryForeground) - background var(--primary) - border-radius 4px - - .mk-url-preview - margin-top 8px - - > .files - > img - display block - max-width 100% - - > .location - margin 4px 0 - font-size 12px - color #ccc - - > .map - width 100% - height 200px - - &:empty - display none - - > .mk-poll - font-size 80% - - > .renote - margin 8px 0 - - > * - padding 16px - border dashed 1px var(--quoteBorder) - border-radius 8px - - > .app - font-size 12px - color #ccc - - > footer - > button - margin 0 - padding 4px 8px 8px 8px - background transparent - border none - box-shadow none - font-size 1em - color var(--noteActions) - cursor pointer - - &:not(:last-child) - margin-right 28px - - &:hover - color var(--noteActionsHover) - - > .count - display inline - margin 0 0 0 8px - color #999 - - &.reacted - 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 884be3a841..3231bd7226 100644 --- a/src/client/app/desktop/views/pages/deck/deck.notes.vue +++ b/src/client/app/desktop/views/pages/deck/deck.notes.vue @@ -2,16 +2,27 @@ <div class="eamppglmnmimdhrlzhplwpvyeaqmmhxu"> <slot name="empty" v-if="notes.length == 0 && !fetching && requestInitPromise == null"></slot> - <div v-if="!fetching && requestInitPromise != null"> - <p>%i18n:@error%</p> - <button @click="resolveInitPromise">%i18n:@retry%</button> + <div class="placeholder" v-if="fetching"> + <template v-for="i in 10"> + <mk-note-skeleton :key="i"/> + </template> + </div> + + <div v-if="!fetching && requestInitPromise != null" class="error"> + <p>%fa:exclamation-triangle% %i18n:common.error.title%</p> + <ui-button @click="resolveInitPromise">%i18n:common.error.retry%</ui-button> </div> <!-- トランジションを有効にするとなぜかメモリリークする --> - <!--<transition-group name="mk-notes" class="transition">--> - <div class="notes"> + <!--<transition-group name="mk-notes" class="transition" ref="notes">--> + <div class="notes" ref="notes"> <template v-for="(note, i) in _notes"> - <x-note :note="note" :key="note.id" @update:note="onNoteUpdated(i, $event)" :media-view="mediaView"/> + <x-note + :note="note" + :key="note.id" + @update:note="onNoteUpdated(i, $event)" + :media-view="mediaView" + :mini="true"/> <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> @@ -32,7 +43,7 @@ <script lang="ts"> import Vue from 'vue'; -import XNote from './deck.note.vue'; +import XNote from '../../components/note.vue'; const displayLimit = 20; @@ -96,7 +107,7 @@ export default Vue.extend({ methods: { 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) { @@ -148,6 +159,11 @@ export default Vue.extend({ } //#endregion + // タブが非表示ならタイトルで通知 + if (document.hidden) { + this.$store.commit('pushBehindNote', note); + } + if (this.isScrollTop()) { // Prepend the note this.notes.unshift(note); @@ -205,12 +221,23 @@ export default Vue.extend({ > * transition transform .3s ease, opacity .3s ease + > .error + max-width 300px + margin 0 auto + padding 16px + text-align center + color var(--text) + + > .placeholder + padding 16px + opacity 0.3 + > .notes > .date display block margin 0 - line-height 32px - font-size 14px + line-height 28px + font-size 12px text-align center color var(--dateDividerFg) background var(--dateDividerBg) 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 149bd10293..fa8f99a2ba 100644 --- a/src/client/app/desktop/views/pages/deck/deck.notification.vue +++ b/src/client/app/desktop/views/pages/deck/deck.notification.vue @@ -66,15 +66,15 @@ </div> <template v-if="notification.type == 'quote'"> - <x-note :note="notification.note" @update:note="onNoteUpdated"/> + <x-note :note="notification.note" @update:note="onNoteUpdated" :mini="true"/> </template> <template v-if="notification.type == 'reply'"> - <x-note :note="notification.note" @update:note="onNoteUpdated"/> + <x-note :note="notification.note" @update:note="onNoteUpdated" :mini="true"/> </template> <template v-if="notification.type == 'mention'"> - <x-note :note="notification.note" @update:note="onNoteUpdated"/> + <x-note :note="notification.note" @update:note="onNoteUpdated" :mini="true"/> </template> </div> </template> @@ -82,7 +82,7 @@ <script lang="ts"> import Vue from 'vue'; import getNoteSummary from '../../../../../../misc/get-note-summary'; -import XNote from './deck.note.vue'; +import XNote from '../../components/note.vue'; export default Vue.extend({ components: { 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 29de691fe2..59d361b0bd 100644 --- a/src/client/app/desktop/views/pages/deck/deck.notifications.vue +++ b/src/client/app/desktop/views/pages/deck/deck.notifications.vue @@ -1,5 +1,11 @@ <template> <div class="oxynyeqmfvracxnglgulyqfgqxnxmehl"> + <div class="placeholder" v-if="fetching"> + <template v-for="i in 10"> + <mk-note-skeleton :key="i"/> + </template> + </div> + <!-- トランジションを有効にするとなぜかメモリリークする --> <component :is="!$store.state.device.reduceMotion ? 'transition-group' : 'div'" name="mk-notifications" class="transition notifications"> <template v-for="(notification, i) in _notifications"> @@ -14,7 +20,6 @@ <template v-if="fetchingMoreNotifications">%fa:spinner .pulse .fw%</template>{{ fetchingMoreNotifications ? '%i18n:common.loading%' : '%i18n:@more%' }} </button> <p class="empty" v-if="notifications.length == 0 && !fetching">%i18n:@empty%</p> - <p class="loading" v-if="fetching">%fa:spinner .pulse .fw%%i18n:common.loading%<mk-ellipsis/></p> </div> </template> @@ -113,8 +118,7 @@ export default Vue.extend({ onNotification(notification) { // TODO: ユーザーが画面を見てないと思われるとき(ブラウザやタブがアクティブじゃないなど)は送信しない - this.connection.send({ - type: 'readNotification', + (this as any).os.stream.send('readNotification', { id: notification.id }); @@ -162,6 +166,10 @@ export default Vue.extend({ > * transition transform .3s ease, opacity .3s ease + > .placeholder + padding 16px + opacity 0.3 + > .notifications > .notification:not(:last-child) @@ -170,9 +178,9 @@ export default Vue.extend({ > .date display block margin 0 - line-height 32px + line-height 28px text-align center - font-size 0.8em + font-size 12px color var(--dateDividerFg) background var(--dateDividerBg) border-bottom solid 1px var(--faceDivider) @@ -208,13 +216,4 @@ export default Vue.extend({ text-align center color #aaa - > .loading - margin 0 - padding 16px - text-align center - color #aaa - - > [data-fa] - margin-right 4px - </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 d245e3ecf5..6faef36439 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 @@ -14,9 +14,25 @@ <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-list-tl v-if="column.type == 'list'" + :list="column.list" + :media-only="column.isMediaOnly" + :media-view="column.isMediaView" + ref="tl" + /> + <x-hashtag-tl v-else-if="column.type == 'hashtag'" + :tag-tl="$store.state.settings.tagTimelines.find(x => x.id == column.tagTlId)" + :media-only="column.isMediaOnly" + :media-view="column.isMediaView" + ref="tl" + /> + <x-tl v-else + :src="column.type" + :media-only="column.isMediaOnly" + :media-view="column.isMediaView" + ref="tl" + /> </x-column> </template> @@ -77,6 +93,10 @@ export default Vue.extend({ methods: { onChangeSettings(v) { this.$store.dispatch('settings/saveDeck'); + }, + + focus() { + this.$refs.tl.focus(); } } }); 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 8aed80fa1b..e9507cdf29 100644 --- a/src/client/app/desktop/views/pages/deck/deck.tl.vue +++ b/src/client/app/desktop/views/pages/deck/deck.tl.vue @@ -1,5 +1,5 @@ <template> - <x-notes ref="timeline" :more="existMore ? more : null" :media-view="mediaView"/> +<x-notes ref="timeline" :more="existMore ? more : null" :media-view="mediaView"/> </template> <script lang="ts"> diff --git a/src/client/app/desktop/views/pages/deck/deck.user-column.vue b/src/client/app/desktop/views/pages/deck/deck.user-column.vue new file mode 100644 index 0000000000..7a84f6605c --- /dev/null +++ b/src/client/app/desktop/views/pages/deck/deck.user-column.vue @@ -0,0 +1,472 @@ +<template> +<x-column> + <span slot="header"> + %fa:user%<span>{{ title }}</span> + </span> + + <div class="zubukjlciycdsyynicqrnlsmdwmymzqu" v-if="user"> + <div class="is-remote" v-if="user.host != null"> + <details> + <summary>%fa:exclamation-triangle% %i18n:common.is-remote-user%</summary> + <a :href="user.url || user.uri" target="_blank">%i18n:common.view-on-remote%</a> + </details> + </div> + <header :style="bannerStyle"> + <div> + <button class="menu" @click="menu" ref="menu">%fa:ellipsis-h%</button> + <mk-follow-button v-if="$store.getters.isSignedIn && user.id != $store.state.i.id" :user="user" class="follow"/> + <mk-avatar class="avatar" :user="user" :disable-preview="true"/> + <span class="name">{{ user | userName }}</span> + <span class="acct">@{{ user | acct }}</span> + </div> + </header> + <div class="info"> + <div class="description"> + <misskey-flavored-markdown v-if="user.description" :text="user.description" :i="$store.state.i"/> + </div> + <div class="counts"> + <div> + <b>{{ user.notesCount | number }}</b> + <span>%i18n:@posts%</span> + </div> + <div> + <b>{{ user.followingCount | number }}</b> + <span>%i18n:@following%</span> + </div> + <div> + <b>{{ user.followersCount | number }}</b> + <span>%i18n:@followers%</span> + </div> + </div> + </div> + <div class="pinned" v-if="user.pinnedNotes && user.pinnedNotes.length > 0"> + <p class="caption" @click="toggleShowPinned">%fa:thumbtack% %i18n:@pinned-notes%</p> + <span class="angle" v-if="showPinned">%fa:angle-up%</span> + <span class="angle" v-else>%fa:angle-down%</span> + <div class="notes" v-show="showPinned"> + <x-note v-for="n in user.pinnedNotes" :key="n.id" :note="n" :mini="true"/> + </div> + </div> + <div class="images" v-if="images.length > 0"> + <p class="caption" @click="toggleShowImages">%fa:images R% %i18n:@images%</p> + <span class="angle" v-if="showImages">%fa:angle-up%</span> + <span class="angle" v-else>%fa:angle-down%</span> + <div v-show="showImages"> + <router-link v-for="image in images" + :style="`background-image: url(${image.thumbnailUrl})`" + :key="`${image.id}:${image._note.id}`" + :to="image._note | notePage" + :title="`${image.name}\n${(new Date(image.createdAt)).toLocaleString()}`" + ></router-link> + </div> + </div> + <div class="activity"> + <p class="caption" @click="toggleShowActivity">%fa:chart-bar R% %i18n:@activity%</p> + <span class="angle" v-if="showActivity">%fa:angle-up%</span> + <span class="angle" v-else>%fa:angle-down%</span> + <div v-show="showActivity"> + <div ref="chart"></div> + </div> + </div> + <div class="tl"> + <p class="caption">%fa:comment-alt R% %i18n:@timeline%</p> + <div> + <x-notes ref="timeline" :more="existMore ? fetchMoreNotes : null"/> + </div> + </div> + </div> +</x-column> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import parseAcct from '../../../../../../misc/acct/parse'; +import XColumn from './deck.column.vue'; +import XNotes from './deck.notes.vue'; +import XNote from '../../components/note.vue'; +import Menu from '../../../../common/views/components/menu.vue'; +import MkUserListsWindow from '../../components/user-lists-window.vue'; +import Ok from '../../../../common/views/components/ok.vue'; +import { concat } from '../../../../../../prelude/array'; +import * as ApexCharts from 'apexcharts'; + +const fetchLimit = 10; + +export default Vue.extend({ + components: { + XColumn, + XNotes, + XNote + }, + + props: { + acct: { + type: String, + required: true + } + }, + + data() { + return { + user: null, + fetching: true, + existMore: false, + moreFetching: false, + withFiles: false, + images: [], + showPinned: true, + showImages: true, + showActivity: true + }; + }, + + computed: { + title(): string { + return this.user ? Vue.filter('userName')(this.user) : ''; + }, + + bannerStyle(): any { + if (this.user == null) return {}; + 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 })` + }; + }, + }, + + created() { + (this as any).api('users/show', parseAcct(this.acct)).then(user => { + this.user = user; + this.fetching = false; + + this.$nextTick(() => { + (this.$refs.timeline as any).init(() => this.initTl()); + }); + + const image = [ + 'image/jpeg', + 'image/png', + 'image/gif' + ]; + + (this as any).api('users/notes', { + userId: this.user.id, + fileType: image, + limit: 9 + }).then(notes => { + notes.forEach(note => { + note.files.forEach(file => { + file._note = note; + }); + }); + const files = concat(notes.map((n: any): any[] => n.files)); + this.images = files.filter(f => image.includes(f.type)).slice(0, 9); + }); + + (this as any).api('charts/user/notes', { + userId: this.user.id, + span: 'day', + limit: 21 + }).then(stats => { + const normal = []; + const reply = []; + const renote = []; + + const now = new Date(); + const y = now.getFullYear(); + const m = now.getMonth(); + const d = now.getDate(); + + for (let i = 0; i < 21; i++) { + const x = new Date(y, m, d - i); + normal.push([ + x, + stats.diffs.normal[i] + ]); + reply.push([ + x, + stats.diffs.reply[i] + ]); + renote.push([ + x, + stats.diffs.renote[i] + ]); + } + + const chart = new ApexCharts(this.$refs.chart, { + chart: { + type: 'bar', + stacked: true, + height: 100, + sparkline: { + enabled: true + }, + }, + plotOptions: { + bar: { + columnWidth: '90%', + endingShape: 'rounded' + } + }, + grid: { + clipMarkers: false, + padding: { + top: 16, + right: 16, + bottom: 16, + left: 16 + } + }, + tooltip: { + shared: true, + intersect: false + }, + series: [{ + name: 'Normal', + data: normal + }, { + name: 'Reply', + data: reply + }, { + name: 'Renote', + data: renote + }], + xaxis: { + type: 'datetime', + crosshairs: { + width: 1, + opacity: 1 + } + } + }); + + chart.render(); + }); + }); + }, + + methods: { + initTl() { + return new Promise((res, rej) => { + (this as any).api('users/notes', { + userId: this.user.id, + limit: fetchLimit + 1, + withFiles: this.withFiles, + 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); + }, rej); + }); + }, + + fetchMoreNotes() { + this.moreFetching = true; + + const promise = (this as any).api('users/notes', { + userId: this.user.id, + limit: fetchLimit + 1, + untilId: (this.$refs.timeline as any).tail().id, + withFiles: this.withFiles, + 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; + }, + + menu() { + let menu = [{ + icon: '%fa:list%', + text: '%i18n:@push-to-a-list%', + action: () => { + const w = (this as any).os.new(MkUserListsWindow); + w.$once('choosen', async list => { + w.close(); + await (this as any).api('users/lists/push', { + listId: list.id, + userId: this.user.id + }); + (this as any).os.new(Ok); + }); + } + }]; + + this.os.new(Menu, { + source: this.$refs.menu, + compact: false, + items: menu + }); + }, + + toggleShowPinned() { + this.showPinned = !this.showPinned; + }, + + toggleShowImages() { + this.showImages = !this.showImages; + }, + + toggleShowActivity() { + this.showActivity = !this.showActivity; + } + } +}); +</script> + +<style lang="stylus" scoped> +.zubukjlciycdsyynicqrnlsmdwmymzqu + background var(--deckColumnBg) + + > .is-remote + padding 8px 16px + font-size 12px + + &.is-remote + color var(--remoteInfoFg) + background var(--remoteInfoBg) + + > a + font-weight bold + + > header + overflow hidden + background-size cover + background-position center + + > div + padding 32px + background rgba(#000, 0.5) + color #fff + text-align center + + > .menu + position absolute + top 8px + left 8px + padding 8px + font-size 16px + text-shadow 0 0 8px #000 + + > .follow + position absolute + top 16px + right 16px + + > .avatar + display block + width 64px + height 64px + margin 0 auto + + > .name + display block + margin-top 8px + font-weight bold + text-shadow 0 0 8px #000 + + > .acct + font-size 14px + opacity 0.7 + text-shadow 0 0 8px #000 + + > .info + padding 16px + font-size 12px + color var(--text) + text-align center + background var(--face) + + &:before + content "" + display blcok + position absolute + top -32px + left 0 + right 0 + width 0px + margin 0 auto + border-top solid 16px transparent + border-left solid 16px transparent + border-right solid 16px transparent + border-bottom solid 16px var(--face) + + > .counts + display grid + grid-template-columns 1fr 1fr 1fr + margin-top 8px + border-top solid 1px var(--faceDivider) + + > div + padding 8px 8px 0 8px + text-align center + + > b + display block + font-size 110% + + > span + display block + font-size 80% + opacity 0.7 + + > * + > p.caption + margin 0 + padding 8px 16px + font-size 12px + color var(--text) + + & + .angle + position absolute + top 0 + right 8px + padding 6px + font-size 14px + color var(--text) + + > .pinned + > .notes + background var(--face) + + > .images + > div + display grid + grid-template-columns 1fr 1fr 1fr + gap 8px + padding 16px + background var(--face) + + > * + height 70px + background-position center center + background-size cover + background-clip content-box + border-radius 4px + + > .activity + > div + background var(--face) + + > .tl + > div + background var(--face) + +</style> diff --git a/src/client/app/desktop/views/pages/deck/deck.vue b/src/client/app/desktop/views/pages/deck/deck.vue index 22b4c50bb4..3b3102bd72 100644 --- a/src/client/app/desktop/views/pages/deck/deck.vue +++ b/src/client/app/desktop/views/pages/deck/deck.vue @@ -1,13 +1,18 @@ <template> <mk-ui :class="$style.root"> - <div class="qlvquzbjribqcaozciifydkngcwtyzje" :style="style"> + <div class="qlvquzbjribqcaozciifydkngcwtyzje" ref="body" :style="style" :class="{ center: $store.state.device.deckColumnAlign == 'center' }" v-hotkey.global="keymap"> <template v-for="ids in layout"> <div v-if="ids.length > 1" class="folder"> <template v-for="id, i in ids"> - <x-column-core :ref="id" :key="id" :column="columns.find(c => c.id == id)" :is-stacked="true"/> + <x-column-core :ref="id" :key="id" :column="columns.find(c => c.id == id)" :is-stacked="true" @parentFocus="moveFocus(id, $event)"/> </template> </div> - <x-column-core v-else :ref="ids[0]" :key="ids[0]" :column="columns.find(c => c.id == ids[0])"/> + <x-column-core v-else :ref="ids[0]" :key="ids[0]" :column="columns.find(c => c.id == ids[0])" @parentFocus="moveFocus(ids[0], $event)"/> + </template> + <template v-if="temporaryColumn"> + <x-user-column v-if="temporaryColumn.type == 'user'" :acct="temporaryColumn.acct" :key="temporaryColumn.acct"/> + <x-note-column v-else-if="temporaryColumn.type == 'note'" :note-id="temporaryColumn.noteId" :key="temporaryColumn.noteId"/> + <x-hashtag-column v-else-if="temporaryColumn.type == 'tag'" :tag="temporaryColumn.tag" :key="temporaryColumn.tag"/> </template> <button ref="add" @click="add" title="%i18n:common.deck.add-column%">%fa:plus%</button> </div> @@ -19,11 +24,18 @@ import Vue from 'vue'; import XColumnCore from './deck.column-core.vue'; import Menu from '../../../../common/views/components/menu.vue'; import MkUserListsWindow from '../../components/user-lists-window.vue'; +import XUserColumn from './deck.user-column.vue'; +import XNoteColumn from './deck.note-column.vue'; +import XHashtagColumn from './deck.hashtag-column.vue'; + import * as uuid from 'uuid'; export default Vue.extend({ components: { - XColumnCore + XColumnCore, + XUserColumn, + XNoteColumn, + XHashtagColumn }, computed: { @@ -31,15 +43,40 @@ export default Vue.extend({ if (this.$store.state.settings.deck == null) return []; return this.$store.state.settings.deck.columns; }, + layout(): any[] { 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)` }; + }, + + temporaryColumn(): any { + return this.$store.state.device.deckTemporaryColumn; + }, + + keymap(): any { + return { + 't': this.focus + }; + } + }, + + watch: { + temporaryColumn() { + if (this.temporaryColumn != null) { + this.$nextTick(() => { + this.$refs.body.scrollTo({ + left: this.$refs.body.scrollWidth - this.$refs.body.clientWidth, + behavior: 'smooth' + }); + }); + } } }, @@ -50,6 +87,8 @@ export default Vue.extend({ }, created() { + this.$store.commit('navHook', this.onNav); + if (this.$store.state.settings.deck == null) { const deck = { columns: [/*{ @@ -95,6 +134,8 @@ export default Vue.extend({ }, beforeDestroy() { + this.$store.commit('navHook', null); + document.documentElement.style.overflow = 'auto'; }, @@ -103,6 +144,39 @@ export default Vue.extend({ return this.$refs[id][0]; }, + onNav(to) { + if (!this.$store.state.settings.deckNav) return false; + + if (to.name == 'user') { + this.$store.commit('device/set', { + key: 'deckTemporaryColumn', + value: { + type: 'user', + acct: to.params.user + } + }); + return true; + } else if (to.name == 'note') { + this.$store.commit('device/set', { + key: 'deckTemporaryColumn', + value: { + type: 'note', + noteId: to.params.note + } + }); + return true; + } else if (to.name == 'tag') { + this.$store.commit('device/set', { + key: 'deckTemporaryColumn', + value: { + type: 'tag', + tag: to.params.tag + } + }); + return true; + } + }, + add() { this.os.new(Menu, { source: this.$refs.add, @@ -210,6 +284,71 @@ export default Vue.extend({ } }] }); + }, + + focus() { + // Flatten array of arrays + const ids = [].concat.apply([], this.layout); + const firstTl = ids.find(id => this.isTlColumn(id)); + + if (firstTl) { + this.$refs[firstTl][0].focus(); + } + }, + + moveFocus(id, direction) { + let targetColumn; + + if (direction == 'right') { + const currentColumnIndex = this.layout.findIndex(ids => ids.includes(id)); + this.layout.some((ids, i) => { + if (i <= currentColumnIndex) return false; + const tl = ids.find(id => this.isTlColumn(id)); + if (tl) { + targetColumn = tl; + return true; + } + }); + } else if (direction == 'left') { + const currentColumnIndex = [...this.layout].reverse().findIndex(ids => ids.includes(id)); + [...this.layout].reverse().some((ids, i) => { + if (i <= currentColumnIndex) return false; + const tl = ids.find(id => this.isTlColumn(id)); + if (tl) { + targetColumn = tl; + return true; + } + }); + } else if (direction == 'down') { + const currentColumn = this.layout.find(ids => ids.includes(id)); + const currentIndex = currentColumn.indexOf(id); + currentColumn.some((_id, i) => { + if (i <= currentIndex) return false; + if (this.isTlColumn(_id)) { + targetColumn = _id; + return true; + } + }); + } else if (direction == 'up') { + const currentColumn = [...this.layout.find(ids => ids.includes(id))].reverse(); + const currentIndex = currentColumn.indexOf(id); + currentColumn.some((_id, i) => { + if (i <= currentIndex) return false; + if (this.isTlColumn(_id)) { + targetColumn = _id; + return true; + } + }); + } + + if (targetColumn) { + this.$refs[targetColumn][0].focus(); + } + }, + + isTlColumn(id) { + const column = this.columns.find(c => c.id === id); + return ['home', 'local', 'hybrid', 'global', 'list', 'hashtag', 'mentions', 'direct'].includes(column.type); } } }); @@ -240,12 +379,13 @@ export default Vue.extend({ > *:not(:last-child) margin-bottom 8px - > * - &:first-child - margin-left auto + &.center + > * + &:first-child + margin-left auto - &:last-child - margin-right auto + &:last-child + margin-right auto > button padding 0 16px diff --git a/src/client/app/desktop/views/pages/index.vue b/src/client/app/desktop/views/pages/index.vue index 5d11fc5423..c531e00e4f 100644 --- a/src/client/app/desktop/views/pages/index.vue +++ b/src/client/app/desktop/views/pages/index.vue @@ -1,16 +1,25 @@ <template> -<component :is="$store.getters.isSignedIn ? 'home' : 'welcome'"></component> +<component :is="page"></component> </template> <script lang="ts"> import Vue from 'vue'; import Home from './home.vue'; import Welcome from './welcome.vue'; +import Deck from './deck/deck.vue'; export default Vue.extend({ components: { Home, + Deck, Welcome + }, + + computed: { + page(): string { + if (!this.$store.getters.isSignedIn) return 'welcome'; + return this.$store.state.device.deckDefault ? 'deck' : 'home'; + } } }); </script> diff --git a/src/client/app/desktop/views/pages/search.vue b/src/client/app/desktop/views/pages/search.vue index 0b6c9a032a..f088ba114d 100644 --- a/src/client/app/desktop/views/pages/search.vue +++ b/src/client/app/desktop/views/pages/search.vue @@ -3,9 +3,6 @@ <header :class="$style.header"> <h1>{{ q }}</h1> </header> - <div :class="$style.loading" v-if="fetching"> - <mk-ellipsis-icon/> - </div> <p :class="$style.notAvailable" v-if="!fetching && notAvailable">%i18n:@not-available%</p> <p :class="$style.empty" v-if="!fetching && empty">%fa:search% {{ '%i18n:not-found%'.split('{}')[0] }}{{ q }}{{ '%i18n:not-found%'.split('{}')[1] }}</p> <mk-notes ref="timeline" :class="$style.notes" :more="existMore ? more : null"/> @@ -119,9 +116,6 @@ export default Vue.extend({ border-radius 6px overflow hidden -.loading - padding 64px 0 - .empty display block margin 0 auto diff --git a/src/client/app/desktop/views/pages/tag.vue b/src/client/app/desktop/views/pages/tag.vue index 04b377e0ab..5305b4ac13 100644 --- a/src/client/app/desktop/views/pages/tag.vue +++ b/src/client/app/desktop/views/pages/tag.vue @@ -3,9 +3,6 @@ <header :class="$style.header"> <h1>#{{ $route.params.tag }}</h1> </header> - <div :class="$style.loading" v-if="fetching"> - <mk-ellipsis-icon/> - </div> <p :class="$style.empty" v-if="!fetching && empty">%fa:search% {{ '%i18n:no-posts-found%'.split('{}')[0] }}{{ q }}{{ '%i18n:no-posts-found%'.split('{}')[1] }}</p> <mk-notes ref="timeline" :class="$style.notes" :more="existMore ? more : null"/> </mk-ui> @@ -108,9 +105,6 @@ export default Vue.extend({ border-radius 6px overflow hidden -.loading - padding 64px 0 - .empty display block margin 0 auto 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 76eb8f9e1c..81398b9862 100644 --- a/src/client/app/desktop/views/pages/user/user.header.vue +++ b/src/client/app/desktop/views/pages/user/user.header.vue @@ -8,8 +8,6 @@ <div> <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> </div> </div> </div> @@ -18,6 +16,10 @@ <div class="description"> <misskey-flavored-markdown v-if="user.description" :text="user.description" :i="$store.state.i"/> </div> + <div class="info"> + <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('-', '%i18n:@year%').replace('-', '%i18n:@month%') + '%i18n:@day%' }} ({{ age }}%i18n:@years-old%)</span> + </div> <div class="status"> <span class="notes-count"><b>{{ user.notesCount | number }}</b>%i18n:@posts%</span> <span class="following clickable" @click="showFollowing"><b>{{ user.followingCount | number }}</b>%i18n:@following%</span> @@ -182,6 +184,14 @@ export default Vue.extend({ padding 16px 16px 16px 154px color var(--text) + > .info + margin-top 16px + padding-top 16px + border-top solid 1px var(--faceDivider) + + > * + margin-right 16px + > .status margin-top 16px padding-top 16px 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 628d5b6d95..2f525b003d 100644 --- a/src/client/app/desktop/views/pages/user/user.photos.vue +++ b/src/client/app/desktop/views/pages/user/user.photos.vue @@ -60,9 +60,6 @@ export default Vue.extend({ margin-right 4px > .stream - display -webkit-flex - display -moz-flex - display -ms-flex display flex justify-content center flex-wrap wrap 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 fe10b54378..fe864f0d7b 100644 --- a/src/client/app/desktop/views/pages/user/user.profile.vue +++ b/src/client/app/desktop/views/pages/user/user.profile.vue @@ -9,11 +9,11 @@ </p> </div> <div class="action-form"> - <button class="mute ui" @click="user.isMuted ? unmute() : mute()" v-if="$store.state.i.id != user.id"> + <ui-button @click="user.isMuted ? unmute() : mute()" v-if="$store.state.i.id != user.id"> <span v-if="user.isMuted">%fa:eye% %i18n:@unmute%</span> <span v-if="!user.isMuted">%fa:eye-slash% %i18n:@mute%</span> - </button> - <button class="mute ui" @click="list">%fa:list% %i18n:@push-to-a-list%</button> + </ui-button> + <ui-button @click="list">%fa:list% %i18n:@push-to-a-list%</ui-button> </div> </div> </template> 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 608c12b7e2..a8d49c8d64 100644 --- a/src/client/app/desktop/views/pages/user/user.timeline.vue +++ b/src/client/app/desktop/views/pages/user/user.timeline.vue @@ -5,9 +5,6 @@ <span :data-active="mode == 'with-replies'" @click="mode = 'with-replies'">%fa:comments% %i18n:@with-replies%</span> <span :data-active="mode == 'with-media'" @click="mode = 'with-media'">%fa:images% %i18n:@with-media%</span> </header> - <div class="loading" v-if="fetching"> - <mk-ellipsis-icon/> - </div> <mk-notes ref="timeline" :more="existMore ? more : null"> <p class="empty" slot="empty">%fa:R comments%%i18n:@empty%</p> </mk-notes> @@ -152,9 +149,6 @@ export default Vue.extend({ &:hover color var(--desktopTimelineSrcHover) - > .loading - padding 64px 0 - > .empty display block margin 0 auto diff --git a/src/client/app/desktop/views/pages/user/user.vue b/src/client/app/desktop/views/pages/user/user.vue index a8da890936..0f58763f03 100644 --- a/src/client/app/desktop/views/pages/user/user.vue +++ b/src/client/app/desktop/views/pages/user/user.vue @@ -2,7 +2,7 @@ <mk-ui> <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> + <div class="is-remote" v-if="user.host != null">%fa:exclamation-triangle% %i18n:common.is-remote-user%<a :href="user.url || user.uri" target="_blank">%i18n:common.view-on-remote%</a></div> <main> <div class="main"> <x-header :user="user"/> diff --git a/src/client/app/desktop/views/widgets/users.vue b/src/client/app/desktop/views/widgets/users.vue index 28c6372b6f..7501e96d97 100644 --- a/src/client/app/desktop/views/widgets/users.vue +++ b/src/client/app/desktop/views/widgets/users.vue @@ -13,7 +13,6 @@ <router-link class="name" :to="_user | userPage" v-user-preview="_user.id">{{ _user | userName }}</router-link> <p class="username">@{{ _user | acct }}</p> </div> - <mk-follow-button :user="_user"/> </div> </template> <p class="empty" v-else>%i18n:@no-one%</p> diff --git a/src/client/app/dev/views/apps.vue b/src/client/app/dev/views/apps.vue index 7e0b107a30..d68191aa6c 100644 --- a/src/client/app/dev/views/apps.vue +++ b/src/client/app/dev/views/apps.vue @@ -1,12 +1,12 @@ <template> <mk-ui> - <b-card header="アプリを管理"> - <b-button to="/app/new" variant="primary">アプリ作成</b-button> + <b-card header="%i18n:@manage-apps%"> + <b-button to="/app/new" variant="primary">%i18n:@create-app%</b-button> <hr> <div class="apps"> - <p v-if="fetching">読み込み中</p> + <p v-if="fetching">%i18n:common.loading%</p> <template v-if="!fetching"> - <b-alert v-if="apps.length == 0">アプリなし</b-alert> + <b-alert v-if="apps.length == 0">%i18n:@app-missing%</b-alert> <b-list-group v-else> <b-list-group-item v-for="app in apps" :key="app.id" :to="`/app/${app.id}`"> {{ app.name }} diff --git a/src/client/app/dev/views/new-app.vue b/src/client/app/dev/views/new-app.vue index 7321df00c0..ce42b91604 100644 --- a/src/client/app/dev/views/new-app.vue +++ b/src/client/app/dev/views/new-app.vue @@ -1,34 +1,34 @@ <template> <mk-ui> - <b-card header="アプリケーションの作成"> + <b-card header="%i18n:@create-app%"> <b-form @submit.prevent="onSubmit" autocomplete="off"> - <b-form-group label="アプリケーション名" description="あなたのアプリの名称。"> - <b-form-input v-model="name" type="text" placeholder="ex) Misskey for iOS" autocomplete="off" required/> + <b-form-group label="%i18n:@app-name%" description="%i18n:@app-name-desc%"> + <b-form-input v-model="name" type="text" placeholder="%i18n:@app-name-ex%" autocomplete="off" required/> </b-form-group> - <b-form-group label="アプリの概要" description="あなたのアプリの簡単な説明や紹介。"> - <b-textarea v-model="description" placeholder="ex) Misskey iOSクライアント。" autocomplete="off" required></b-textarea> + <b-form-group label="%i18n:@app-overview%" description="%i18n:@app-desc%"> + <b-textarea v-model="description" placeholder="%i18n:@app-desc-ex%" autocomplete="off" required></b-textarea> </b-form-group> - <b-form-group label="コールバックURL (オプション)" description="ユーザーが認証フォームで認証した際にリダイレクトするURLを設定できます。"> + <b-form-group label="%i18n:@callback-url%" description="%i18n:@callback-url-desc%"> <b-input v-model="cb" type="url" placeholder="ex) https://your.app.example.com/callback.php" autocomplete="off"/> </b-form-group> - <b-card header="権限"> - <b-form-group description="ここで要求した機能だけがAPIからアクセスできます。"> - <b-alert show variant="warning">%fa:exclamation-triangle%アプリ作成後も変更できますが、新たな権限を付与する場合、その時点で関連付けられているユーザーキーはすべて無効になります。</b-alert> + <b-card header="%i18n:@authority%"> + <b-form-group description="%i18n:@authority-desc%"> + <b-alert show variant="warning">%fa:exclamation-triangle% %i18n:@authority-warning%</b-alert> <b-form-checkbox-group v-model="permission" stacked> - <b-form-checkbox value="account-read">アカウントの情報を見る。</b-form-checkbox> - <b-form-checkbox value="account-write">アカウントの情報を操作する。</b-form-checkbox> - <b-form-checkbox value="note-write">投稿する。</b-form-checkbox> - <b-form-checkbox value="reaction-write">リアクションしたりリアクションをキャンセルする。</b-form-checkbox> - <b-form-checkbox value="following-write">フォローしたりフォロー解除する。</b-form-checkbox> - <b-form-checkbox value="drive-read">ドライブを見る。</b-form-checkbox> - <b-form-checkbox value="drive-write">ドライブを操作する。</b-form-checkbox> - <b-form-checkbox value="notification-read">通知を見る。</b-form-checkbox> - <b-form-checkbox value="notification-write">通知を操作する。</b-form-checkbox> + <b-form-checkbox value="account-read">%i18n:@account-read%</b-form-checkbox> + <b-form-checkbox value="account-write">%i18n:@account-write%</b-form-checkbox> + <b-form-checkbox value="note-write">%i18n:@note-write%</b-form-checkbox> + <b-form-checkbox value="reaction-write">%i18n:@reaction-write%</b-form-checkbox> + <b-form-checkbox value="following-write">%i18n:@following-write%</b-form-checkbox> + <b-form-checkbox value="drive-read">%i18n:@drive-read%</b-form-checkbox> + <b-form-checkbox value="drive-write">%i18n:@drive-write%</b-form-checkbox> + <b-form-checkbox value="notification-read">%i18n:@notification-read%</b-form-checkbox> + <b-form-checkbox value="notification-write">%i18n:@notification-write%</b-form-checkbox> </b-form-checkbox-group> </b-form-group> </b-card> <hr> - <b-button type="submit" variant="primary">アプリ作成</b-button> + <b-button type="submit" variant="primary">%i18n:@create-app%</b-button> </b-form> </b-card> </mk-ui> @@ -56,7 +56,7 @@ export default Vue.extend({ }).then(() => { location.href = '/dev/apps'; }).catch(() => { - alert('アプリの作成に失敗しました。再度お試しください。'); + alert('%i18n:common.dev.failed-to-create%'); }); } } diff --git a/src/client/app/init.ts b/src/client/app/init.ts index c2381067da..fc09c3eeaf 100644 --- a/src/client/app/init.ts +++ b/src/client/app/init.ts @@ -8,6 +8,7 @@ import VueRouter from 'vue-router'; import * as TreeView from 'vue-json-tree-view'; import VAnimateCss from 'v-animate-css'; import VModal from 'vue-js-modal'; +import VueSweetalert2 from 'vue-sweetalert2'; import VueHotkey from './common/hotkey'; import App from './app.vue'; @@ -26,6 +27,7 @@ Vue.use(TreeView); Vue.use(VAnimateCss); Vue.use(VModal); Vue.use(VueHotkey); +Vue.use(VueSweetalert2); // Register global directives require('./common/views/directives'); @@ -122,11 +124,17 @@ export default (callback: (launch: (router: VueRouter, api?: (os: MiOS) => API) //#region shadow const shadow = '0 3px 8px rgba(0, 0, 0, 0.2)'; + const shadowRight = '4px 0 4px rgba(0, 0, 0, 0.1)'; + const shadowLeft = '-4px 0 4px rgba(0, 0, 0, 0.1)'; if (os.store.state.settings.useShadow) document.documentElement.style.setProperty('--shadow', shadow); + if (os.store.state.settings.useShadow) document.documentElement.style.setProperty('--shadowRight', shadowRight); + if (os.store.state.settings.useShadow) document.documentElement.style.setProperty('--shadowLeft', shadowLeft); os.store.watch(s => { return s.settings.useShadow; }, v => { document.documentElement.style.setProperty('--shadow', v ? shadow : 'none'); + document.documentElement.style.setProperty('--shadowRight', v ? shadowRight : 'none'); + document.documentElement.style.setProperty('--shadowLeft', v ? shadowLeft : 'none'); }); //#endregion @@ -140,6 +148,31 @@ export default (callback: (launch: (router: VueRouter, api?: (os: MiOS) => API) }); //#endregion + // Navigation hook + router.beforeEach((to, from, next) => { + if (os.store.state.navHook) { + if (os.store.state.navHook(to)) { + next(false); + } else { + next(); + } + } else { + next(); + } + }); + + document.addEventListener('visibilitychange', () => { + if (!document.hidden) { + os.store.commit('clearBehindNotes'); + } + }, false); + + window.addEventListener('scroll', () => { + if (window.scrollY <= 8) { + os.store.commit('clearBehindNotes'); + } + }, { passive: true }); + Vue.mixin({ data() { return { @@ -189,15 +222,15 @@ function panic(e) { document.documentElement.style.background = '#1269e2'; document.body.innerHTML = '<div id="error">' - + '<h1>:( 致命的な問題が発生しました。</h1>' - + '<p>お使いのブラウザ(またはOS)のバージョンを更新すると解決する可能性があります。</p>' + + '<h1>%i18n.common.BSoD.fatal-error%</h1>' + + '<p>%i18n.common.BSoD.update-browser-os%</p>' + '<hr>' - + `<p>エラーコード: ${e.toString()}</p>` - + `<p>ブラウザ バージョン: ${navigator.userAgent}</p>` - + `<p>クライアント バージョン: ${version}</p>` + + `<p>%i18n.common.BSoD.error-code%: ${e.toString()}</p>` + + `<p>%i18n.common.BSoD.browser-version%: ${navigator.userAgent}</p>` + + `<p>%i18n.common.BSoD.client-version%: ${version}</p>` + '<hr>' - + '<p>問題が解決しない場合は、上記の情報をお書き添えの上 syuilotan@yahoo.co.jp までご連絡ください。</p>' - + '<p>Thank you for using Misskey.</p>' + + '<p>%i18n.common.BSoD.email-support%</p>' + + '<p>%i18n.common.BSoD.thanks%</p>' + '</div>'; // TODO: Report the bug diff --git a/src/client/app/mios.ts b/src/client/app/mios.ts index 42171e71fa..d50c4e15b2 100644 --- a/src/client/app/mios.ts +++ b/src/client/app/mios.ts @@ -212,7 +212,7 @@ export default class MiOS extends EventEmitter { const fetched = () => { this.emit('signedin'); - this.stream = new Stream(this); + this.initStream(); // Finish init callback(); @@ -244,15 +244,106 @@ export default class MiOS extends EventEmitter { this.store.dispatch('login', me); fetched(); } else { + this.initStream(); + // Finish init callback(); - - this.stream = new Stream(this); } }); } } + @autobind + private initStream() { + this.stream = new Stream(this); + + if (this.store.getters.isSignedIn) { + const main = this.stream.useSharedConnection('main'); + + // 自分の情報が更新されたとき + main.on('meUpdated', i => { + this.store.dispatch('mergeMe', i); + }); + + main.on('readAllNotifications', () => { + this.store.dispatch('mergeMe', { + hasUnreadNotification: false + }); + }); + + main.on('unreadNotification', () => { + this.store.dispatch('mergeMe', { + hasUnreadNotification: true + }); + }); + + main.on('readAllMessagingMessages', () => { + this.store.dispatch('mergeMe', { + hasUnreadMessagingMessage: false + }); + }); + + main.on('unreadMessagingMessage', () => { + this.store.dispatch('mergeMe', { + hasUnreadMessagingMessage: true + }); + }); + + main.on('unreadMention', () => { + this.store.dispatch('mergeMe', { + hasUnreadMentions: true + }); + }); + + main.on('readAllUnreadMentions', () => { + this.store.dispatch('mergeMe', { + hasUnreadMentions: false + }); + }); + + main.on('unreadSpecifiedNote', () => { + this.store.dispatch('mergeMe', { + hasUnreadSpecifiedNotes: true + }); + }); + + main.on('readAllUnreadSpecifiedNotes', () => { + this.store.dispatch('mergeMe', { + hasUnreadSpecifiedNotes: false + }); + }); + + main.on('clientSettingUpdated', x => { + this.store.commit('settings/set', { + key: x.key, + value: x.value + }); + }); + + main.on('homeUpdated', x => { + this.store.commit('settings/setHome', x); + }); + + main.on('mobileHomeUpdated', x => { + this.store.commit('settings/setMobileHome', x); + }); + + main.on('widgetUpdated', x => { + this.store.commit('settings/setWidget', { + id: x.id, + data: x.data + }); + }); + + // トークンが再生成されたとき + // このままではMisskeyが利用できないので強制的にサインアウトさせる + main.on('myTokenRegenerated', () => { + alert('%i18n:common.my-token-regenerated%'); + this.signout(); + }); + } + } + /** * Register service worker */ @@ -352,10 +443,10 @@ export default class MiOS extends EventEmitter { }; const promise = new Promise((resolve, reject) => { - const viaStream = this.stream && this.store.state.device.apiViaStream && !forceFetch; + const viaStream = this.stream && this.stream.state == 'connected' && this.store.state.device.apiViaStream && !forceFetch; if (viaStream) { - const id = Math.random().toString(); + const id = Math.random().toString().substr(2, 8); this.stream.once(`api:${id}`, res => { if (res == null || Object.keys(res).length == 0) { diff --git a/src/client/app/mobile/api/post.ts b/src/client/app/mobile/api/post.ts index 5c0f0af852..a64ed1c43e 100644 --- a/src/client/app/mobile/api/post.ts +++ b/src/client/app/mobile/api/post.ts @@ -18,6 +18,7 @@ export default (os) => (opts) => { }).$mount(); vm.$once('cancel', recover); vm.$once('posted', recover); + if (o.cb) vm.$once('closed', o.cb); document.body.appendChild(vm.$el); (vm as any).focus(); }; diff --git a/src/client/app/mobile/views/components/dialog.vue b/src/client/app/mobile/views/components/dialog.vue index fff44a28c3..4f935cf03c 100644 --- a/src/client/app/mobile/views/components/dialog.vue +++ b/src/client/app/mobile/views/components/dialog.vue @@ -91,8 +91,6 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> - - .mk-dialog > .bg display block 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 7425afe1e2..c80cb61fa9 100644 --- a/src/client/app/mobile/views/components/drive.file-detail.vue +++ b/src/client/app/mobile/views/components/drive.file-detail.vue @@ -5,7 +5,6 @@ :src="file.url" :alt="file.name" :title="file.name" - @load="onImageLoaded" :style="style"> <template v-if="kind != 'image'">%fa:file%</template> <footer v-if="kind == 'image' && file.properties && file.properties.width && file.properties.height"> @@ -41,17 +40,11 @@ <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="toggleSensitive" v-if="file.isSensitive">%fa:R eye% %i18n:@unmark-as-sensitive%</ui-button> + <ui-button @click="toggleSensitive" v-else>%fa:R eye-slash% %i18n:@mark-as-sensitive%</ui-button> <ui-button @click="del">%fa:trash-alt R% %i18n:@delete%</ui-button> </div> </div> - <div class="exif" v-show="exif"> - <div> - <p> - %fa:camera%%i18n:@exif% - </p> - <pre ref="exif" class="json">{{ exif ? JSON.stringify(exif, null, 2) : '' }}</pre> - </div> - </div> <div class="hash"> <div> <p> @@ -65,31 +58,34 @@ <script lang="ts"> import Vue from 'vue'; -import * as EXIF from 'exif-js'; -import * as hljs from 'highlight.js'; import { gcd } from '../../../../../prelude/math'; export default Vue.extend({ props: ['file'], + data() { return { gcd, exif: null }; }, + computed: { browser(): any { return this.$parent; }, + kind(): string { return this.file.type.split('/')[0]; }, + style(): any { return this.file.properties.avgColor && this.file.properties.avgColor.length == 3 ? { 'background-color': `rgb(${ this.file.properties.avgColor.join(',') })` } : {}; } }, + methods: { rename() { const name = window.prompt('%i18n:@rename%', this.file.name); @@ -101,6 +97,7 @@ export default Vue.extend({ this.browser.cf(this.file, true); }); }, + move() { (this as any).apis.chooseDriveFolder().then(folder => { (this as any).api('drive/files/update', { @@ -111,6 +108,7 @@ export default Vue.extend({ }); }); }, + del() { (this as any).api('drive/files/delete', { fileId: this.file.id @@ -118,16 +116,18 @@ export default Vue.extend({ this.browser.cd(this.file.folderId, true); }); }, + + toggleSensitive() { + (this as any).api('drive/files/update', { + fileId: this.file.id, + isSensitive: !this.file.isSensitive + }); + + this.file.isSensitive = !this.file.isSensitive; + }, + showCreatedAt() { alert(new Date(this.file.createdAt).toLocaleString()); - }, - onImageLoaded() { - const self = this; - EXIF.getData(this.$refs.img, function(this: any) { - const allMetaData = EXIF.getAllTags(this); - self.exif = allMetaData; - hljs.highlightBlock(self.$refs.exif); - }); } } }); @@ -236,34 +236,4 @@ export default Vue.extend({ border-radius 2px background #f5f5f5 - > .exif - padding 14px - border-top solid 1px var(--faceDivider) - - > div - max-width 500px - margin 0 auto - - > p - display block - margin 0 - padding 0 - color var(--text) - font-size 0.9em - - > [data-fa] - margin-right 4px - - > pre - display block - width 100% - margin 6px 0 0 0 - padding 8px - height 128px - overflow auto - font-size 0.9em - border solid 1px #dfdfdf - border-radius 2px - background #f5f5f5 - </style> diff --git a/src/client/app/mobile/views/components/drive.vue b/src/client/app/mobile/views/components/drive.vue index 469f6da240..603b2ba061 100644 --- a/src/client/app/mobile/views/components/drive.vue +++ b/src/client/app/mobile/views/components/drive.vue @@ -1,7 +1,7 @@ <template> <div class="kmmwchoexgckptowjmjgfsygeltxfeqs"> <nav ref="nav"> - <a @click.prevent="goRoot()" href="/i/drive">%fa:cloud%%i18n:@drive%</a> + <a @click.prevent="goRoot()" href="/i/drive">%fa:cloud%%i18n:common.drive%</a> <template v-for="folder in hierarchyFolders"> <span :key="folder.id + '>'">%fa:angle-right%</span> <a :key="folder.id" @click.prevent="cd(folder)" :href="`/i/drive/folder/${folder.id}`">{{ folder.name }}</a> @@ -103,11 +103,11 @@ export default Vue.extend({ mounted() { this.connection = (this as any).os.stream.useSharedConnection('drive'); - this.connection.on('file_created', this.onStreamDriveFileCreated); - this.connection.on('file_updated', this.onStreamDriveFileUpdated); - this.connection.on('file_deleted', this.onStreamDriveFileDeleted); - this.connection.on('folder_created', this.onStreamDriveFolderCreated); - this.connection.on('folder_updated', this.onStreamDriveFolderUpdated); + this.connection.on('fileCreated', this.onStreamDriveFileCreated); + this.connection.on('fileUpdated', this.onStreamDriveFileUpdated); + this.connection.on('fileDeleted', this.onStreamDriveFileDeleted); + this.connection.on('folderCreated', this.onStreamDriveFolderCreated); + this.connection.on('folderUpdated', this.onStreamDriveFolderUpdated); if (this.initFolder) { this.cd(this.initFolder, true); diff --git a/src/client/app/mobile/views/components/note.vue b/src/client/app/mobile/views/components/note.vue index f370fbf874..cbac5b6450 100644 --- a/src/client/app/mobile/views/components/note.vue +++ b/src/client/app/mobile/views/components/note.vue @@ -1,7 +1,13 @@ <template> -<div class="note" :class="{ renote: isRenote, smart: $store.state.device.postStyle == 'smart' }"> - <div class="reply-to" v-if="p.reply && (!$store.getters.isSignedIn || $store.state.settings.showReplyTarget)"> - <x-sub :note="p.reply"/> +<div + class="note" + v-show="appearNote.deletedAt == null" + :tabindex="appearNote.deletedAt == null ? '-1' : null" + :class="{ renote: isRenote, smart: $store.state.device.postStyle == 'smart' }" + v-hotkey="keymap" +> + <div class="reply-to" v-if="appearNote.reply && (!$store.getters.isSignedIn || $store.state.settings.showReplyTarget)"> + <x-sub :note="appearNote.reply"/> </div> <div class="renote" v-if="isRenote"> <mk-avatar class="avatar" :user="note.user"/> @@ -12,47 +18,45 @@ <mk-time :time="note.createdAt"/> </div> <article> - <mk-avatar class="avatar" :user="p.user" v-if="$store.state.device.postStyle != 'smart'"/> + <mk-avatar class="avatar" :user="appearNote.user" v-if="$store.state.device.postStyle != 'smart'"/> <div class="main"> - <mk-note-header class="header" :note="p" :mini="true"/> + <mk-note-header class="header" :note="appearNote" :mini="true"/> <div class="body"> - <p v-if="p.cw != null" class="cw"> - <span class="text" v-if="p.cw != ''">{{ p.cw }}</span> + <p v-if="appearNote.cw != null" class="cw"> + <span class="text" v-if="appearNote.cw != ''">{{ appearNote.cw }}</span> <mk-cw-button v-model="showContent"/> </p> - <div class="content" v-show="p.cw == null || showContent"> + <div class="content" v-show="appearNote.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> - <a class="reply" v-if="p.reply">%fa:reply%</a> - <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> + <span v-if="appearNote.isHidden" style="opacity: 0.5">(%i18n:@private%)</span> + <a class="reply" v-if="appearNote.reply">%fa:reply%</a> + <misskey-flavored-markdown v-if="appearNote.text" :text="appearNote.text" :i="$store.state.i" :class="$style.text"/> + <a class="rp" v-if="appearNote.renote != null">RN:</a> </div> - <div class="files" v-if="p.files.length > 0"> - <mk-media-list :media-list="p.files"/> + <div class="files" v-if="appearNote.files.length > 0"> + <mk-media-list :media-list="appearNote.files"/> </div> - <mk-poll v-if="p.poll" :note="p" ref="pollViewer"/> + <mk-poll v-if="appearNote.poll" :note="appearNote" 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> + <a class="location" v-if="appearNote.geo" :href="`https://maps.google.com/maps?q=${appearNote.geo.coordinates[1]},${appearNote.geo.coordinates[0]}`" target="_blank">%fa:map-marker-alt% %i18n:@location%</a> + <div class="renote" v-if="appearNote.renote"><mk-note-preview :note="appearNote.renote"/></div> </div> - <span class="app" v-if="p.app">via <b>{{ p.app.name }}</b></span> + <span class="app" v-if="appearNote.app">via <b>{{ appearNote.app.name }}</b></span> </div> - <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> + <footer> + <mk-reactions-viewer :note="appearNote" ref="reactionsViewer"/> + <button @click="reply()"> + <template v-if="appearNote.reply">%fa:reply-all%</template> <template v-else>%fa:reply%</template> - <p class="count" v-if="p.repliesCount > 0">{{ p.repliesCount }}</p> + <p class="count" v-if="appearNote.repliesCount > 0">{{ appearNote.repliesCount }}</p> </button> - <button @click="renote" title="Renote"> - %fa:retweet%<p class="count" v-if="p.renoteCount > 0">{{ p.renoteCount }}</p> + <button @click="renote()" title="Renote"> + %fa:retweet%<p class="count" v-if="appearNote.renoteCount > 0">{{ appearNote.renoteCount }}</p> </button> - <button :class="{ reacted: p.myReaction != null }" @click="react" ref="reactButton"> - %fa:plus%<p class="count" v-if="p.reactions_count > 0">{{ p.reactions_count }}</p> + <button :class="{ reacted: appearNote.myReaction != null }" @click="react()" ref="reactButton"> + %fa:plus%<p class="count" v-if="appearNote.reactions_count > 0">{{ appearNote.reactions_count }}</p> </button> - <button class="menu" @click="menu" ref="menuButton"> + <button class="menu" @click="menu()" ref="menuButton"> %fa:ellipsis-h% </button> </footer> @@ -63,12 +67,9 @@ <script lang="ts"> import Vue from 'vue'; -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 noteMixin from '../../../common/scripts/note-mixin'; import noteSubscriber from '../../../common/scripts/note-subscriber'; export default Vue.extend({ @@ -76,74 +77,17 @@ export default Vue.extend({ XSub }, - mixins: [noteSubscriber('note')], + mixins: [ + noteMixin({ + mobile: true + }), + noteSubscriber('note') + ], - props: ['note'], - - data() { - return { - showContent: false - }; - }, - - computed: { - isRenote(): boolean { - return (this.note.renote && - this.note.text == null && - 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 - ? sum(Object.values(this.p.reactionCounts)) - : 0; - }, - - urls(): string[] { - if (this.p.text) { - const ast = parse(this.p.text); - return ast - .filter(t => (t.type == 'url' || t.type == 'link') && !t.silent) - .map(t => t.url); - } else { - return null; - } - } - }, - - methods: { - 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, - note: this.p, - compact: true, - big: true - }); - }, - - menu() { - (this as any).os.new(MkNoteMenu, { - source: this.$refs.menuButton, - note: this.p, - compact: true - }); + props: { + note: { + type: Object, + required: true } } }); @@ -154,6 +98,20 @@ export default Vue.extend({ font-size 12px border-bottom solid 1px var(--faceDivider) + &:focus + z-index 1 + + &:after + content "" + pointer-events none + position absolute + top 2px + right 2px + bottom 2px + left 2px + border 2px solid var(--primaryAlpha03) + border-radius 4px + &:last-of-type border-bottom none diff --git a/src/client/app/mobile/views/components/notes.vue b/src/client/app/mobile/views/components/notes.vue index 8f0a1ef196..53ffe49c5b 100644 --- a/src/client/app/mobile/views/components/notes.vue +++ b/src/client/app/mobile/views/components/notes.vue @@ -4,8 +4,10 @@ <slot name="empty" v-if="notes.length == 0 && !fetching && requestInitPromise == null"></slot> - <div class="init" v-if="fetching"> - %fa:spinner .pulse%%i18n:common.loading% + <div class="placeholder" v-if="fetching"> + <template v-for="i in 10"> + <mk-note-skeleton :key="i"/> + </template> </div> <div v-if="!fetching && requestInitPromise != null"> @@ -35,7 +37,6 @@ <script lang="ts"> import Vue from 'vue'; -import getNoteSummary from '../../../../../misc/get-note-summary'; const displayLimit = 30; @@ -52,7 +53,6 @@ export default Vue.extend({ requestInitPromise: null as () => Promise<any[]>, notes: [], queue: [], - unreadCount: 0, fetching: true, moreFetching: false }; @@ -81,12 +81,10 @@ export default Vue.extend({ }, mounted() { - document.addEventListener('visibilitychange', this.onVisibilitychange, false); window.addEventListener('scroll', this.onScroll, { passive: true }); }, beforeDestroy() { - document.removeEventListener('visibilitychange', this.onVisibilitychange); window.removeEventListener('scroll', this.onScroll); }, @@ -144,10 +142,9 @@ export default Vue.extend({ } //#endregion - // 投稿が自分のものではないかつ、タブが非表示またはスクロール位置が最上部ではないならタイトルで通知 - if ((document.hidden || !this.isScrollTop()) && note.userId !== this.$store.state.i.id) { - this.unreadCount++; - document.title = `(${this.unreadCount}) ${getNoteSummary(note)}`; + // タブが非表示またはスクロール位置が最上部ではないならタイトルで通知 + if (document.hidden || !this.isScrollTop()) { + this.$store.commit('pushBehindNote', note); } if (this.isScrollTop()) { @@ -185,21 +182,9 @@ export default Vue.extend({ this.moreFetching = false; }, - clearNotification() { - this.unreadCount = 0; - document.title = (this as any).os.instanceName; - }, - - onVisibilitychange() { - if (!document.hidden) { - this.clearNotification(); - } - }, - onScroll() { if (this.isScrollTop()) { this.releaseQueue(); - this.clearNotification(); } if (this.$store.state.settings.fetchOnScroll !== false) { @@ -251,13 +236,12 @@ export default Vue.extend({ [data-fa] margin-right 8px - > .init - padding 64px 0 - text-align center - color #999 + > .placeholder + padding 16px + opacity 0.3 - > [data-fa] - margin-right 4px + @media (min-width 500px) + padding 32px > .empty margin 0 auto diff --git a/src/client/app/mobile/views/components/notifications.vue b/src/client/app/mobile/views/components/notifications.vue index e1a2967071..f33808a3c1 100644 --- a/src/client/app/mobile/views/components/notifications.vue +++ b/src/client/app/mobile/views/components/notifications.vue @@ -1,5 +1,11 @@ <template> <div class="mk-notifications"> + <div class="placeholder" v-if="fetching"> + <template v-for="i in 10"> + <mk-note-skeleton :key="i"/> + </template> + </div> + <!-- トランジションを有効にするとなぜかメモリリークする --> <component :is="!$store.state.device.reduceMotion ? 'transition-group' : 'div'" name="mk-notifications" class="transition notifications"> <template v-for="(notification, i) in _notifications"> @@ -17,7 +23,6 @@ </button> <p class="empty" v-if="notifications.length == 0 && !fetching">%i18n:@empty%</p> - <p class="fetching" v-if="fetching">%fa:spinner .pulse .fw%%i18n:common.loading%<mk-ellipsis/></p> </div> </template> @@ -48,6 +53,8 @@ export default Vue.extend({ }, mounted() { + window.addEventListener('scroll', this.onScroll, { passive: true }); + this.connection = (this as any).os.stream.useSharedConnection('main'); this.connection.on('notification', this.onNotification); @@ -69,11 +76,14 @@ export default Vue.extend({ }, beforeDestroy() { + window.removeEventListener('scroll', this.onScroll); this.connection.dispose(); }, methods: { fetchMoreNotifications() { + if (this.fetchingMoreNotifications) return; + this.fetchingMoreNotifications = true; const max = 30; @@ -95,12 +105,23 @@ export default Vue.extend({ onNotification(notification) { // TODO: ユーザーが画面を見てないと思われるとき(ブラウザやタブがアクティブじゃないなど)は送信しない - this.connection.send({ - type: 'readNotification', + (this as any).os.stream.send('readNotification', { id: notification.id }); this.notifications.unshift(notification); + }, + + onScroll() { + if (this.$store.state.settings.fetchOnScroll !== false) { + // 親要素が display none だったら弾く + // https://github.com/syuilo/misskey/issues/1569 + // http://d.hatena.ne.jp/favril/20091105/1257403319 + if (this.$el.offsetHeight == 0) return; + + const current = window.scrollY + window.innerHeight; + if (current > document.body.offsetHeight - 8) this.fetchMoreNotifications(); + } } } }); @@ -151,7 +172,7 @@ export default Vue.extend({ display block width 100% padding 16px - color #555 + color var(--text) border-top solid 1px rgba(#000, 0.05) > [data-fa] @@ -163,13 +184,11 @@ export default Vue.extend({ text-align center color #aaa - > .fetching - margin 0 + > .placeholder padding 16px - text-align center - color #aaa + opacity 0.3 - > [data-fa] - margin-right 4px + @media (min-width 500px) + padding 32px </style> diff --git a/src/client/app/mobile/views/components/post-form.vue b/src/client/app/mobile/views/components/post-form.vue index 3de920cf22..0c783fded3 100644 --- a/src/client/app/mobile/views/components/post-form.vue +++ b/src/client/app/mobile/views/components/post-form.vue @@ -4,7 +4,7 @@ <header> <button class="cancel" @click="cancel">%fa:times%</button> <div> - <span class="text-count" :class="{ over: trimmedLength(text) > 1000 }">{{ 1000 - trimmedLength(text) }}</span> + <span class="text-count" :class="{ over: trimmedLength(text) > this.maxNoteTextLength }">{{ this.maxNoteTextLength - trimmedLength(text) }}</span> <span class="geo" v-if="geo">%fa:map-marker-alt%</span> <button class="submit" :disabled="!canPost" @click="post">{{ submitText }}</button> </div> @@ -62,6 +62,7 @@ import { host } from '../../../config'; import { erase, unique } from '../../../../../prelude/array'; import { length } from 'stringz'; import parseAcct from '../../../../../misc/acct/parse'; +import { toASCII } from 'punycode'; export default Vue.extend({ components: { @@ -101,10 +102,17 @@ export default Vue.extend({ visibleUsers: [], useCw: false, cw: null, - recentHashtags: JSON.parse(localStorage.getItem('hashtags') || '[]') + recentHashtags: JSON.parse(localStorage.getItem('hashtags') || '[]'), + maxNoteTextLength: 1000 }; }, + created() { + (this as any).os.getMeta().then(meta => { + this.maxNoteTextLength = meta.maxNoteTextLength; + }); + }, + computed: { draftId(): string { return this.renote @@ -143,7 +151,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); + (this.text.trim().length <= this.maxNoteTextLength); } }, @@ -153,14 +161,14 @@ export default Vue.extend({ } if (this.reply && this.reply.user.host != null) { - this.text = `@${this.reply.user.username}@${this.reply.user.host} `; + this.text = `@${this.reply.user.username}@${toASCII(this.reply.user.host)} `; } if (this.reply && this.reply.text != null) { const ast = parse(this.reply.text); ast.filter(t => t.type == 'mention').forEach(x => { - const mention = x.host ? `@${x.username}@${x.host}` : `@${x.username}`; + const mention = x.host ? `@${x.username}@${toASCII(x.host)}` : `@${x.username}`; // 自分は除外 if (this.$store.state.i.username == x.username && x.host == null) return; 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 2238edf278..6a90d5bc1a 100644 --- a/src/client/app/mobile/views/components/sub-note-content.vue +++ b/src/client/app/mobile/views/components/sub-note-content.vue @@ -5,7 +5,7 @@ <span v-if="note.deletedAt" style="opacity: 0.5">(%i18n:@deleted%)</span> <a class="reply" v-if="note.replyId">%fa:reply%</a> <misskey-flavored-markdown v-if="note.text" :text="note.text" :i="$store.state.i"/> - <a class="rp" v-if="note.renoteId">RP: ...</a> + <a class="rp" v-if="note.renoteId">RN: ...</a> </div> <details v-if="note.files.length > 0"> <summary>({{ '%i18n:@media-count%'.replace('{}', note.files.length) }})</summary> diff --git a/src/client/app/mobile/views/components/ui.nav.vue b/src/client/app/mobile/views/components/ui.nav.vue index c9c0c082b2..86a426f80a 100644 --- a/src/client/app/mobile/views/components/ui.nav.vue +++ b/src/client/app/mobile/views/components/ui.nav.vue @@ -18,14 +18,14 @@ <li><router-link to="/" :data-active="$route.name == 'index'">%fa:home%%i18n:@timeline%%fa:angle-right%</router-link></li> <li><router-link to="/i/notifications" :data-active="$route.name == 'notifications'">%fa:R bell%%i18n:@notifications%<template v-if="hasUnreadNotification">%fa:circle%</template>%fa:angle-right%</router-link></li> <li><router-link to="/i/messaging" :data-active="$route.name == 'messaging'">%fa:R comments%%i18n:@messaging%<template v-if="hasUnreadMessagingMessage">%fa:circle%</template>%fa:angle-right%</router-link></li> - <li v-if="$store.getters.isSignedIn && $store.state.i.isLocked"><router-link to="/i/received-follow-requests" :data-active="$route.name == 'received-follow-requests'">%fa:R envelope%%i18n:@follow-requests%<template v-if="$store.getters.isSignedIn && $store.state.i.pendingReceivedFollowRequestsCount">%fa:circle%</template>%fa:angle-right%</router-link></li> + <li v-if="$store.getters.isSignedIn && ($store.state.i.isLocked || $store.state.i.carefulBot)"><router-link to="/i/received-follow-requests" :data-active="$route.name == 'received-follow-requests'">%fa:R envelope%%i18n:@follow-requests%<template v-if="$store.getters.isSignedIn && $store.state.i.pendingReceivedFollowRequestsCount">%fa:circle%</template>%fa:angle-right%</router-link></li> <li><router-link to="/reversi" :data-active="$route.name == 'reversi'">%fa:gamepad%%i18n:@game%<template v-if="hasGameInvitation">%fa:circle%</template>%fa:angle-right%</router-link></li> </ul> <ul> <li><router-link to="/i/widgets" :data-active="$route.name == 'widgets'">%fa:R calendar-alt%%i18n:@widgets%%fa:angle-right%</router-link></li> <li><router-link to="/i/favorites" :data-active="$route.name == 'favorites'">%fa:star%%i18n:@favorites%%fa:angle-right%</router-link></li> <li><router-link to="/i/lists" :data-active="$route.name == 'user-lists'">%fa:list%%i18n:@user-lists%%fa:angle-right%</router-link></li> - <li><router-link to="/i/drive" :data-active="$route.name == 'drive'">%fa:cloud%%i18n:@drive%%fa:angle-right%</router-link></li> + <li><router-link to="/i/drive" :data-active="$route.name == 'drive'">%fa:cloud%%i18n:common.drive%%fa:angle-right%</router-link></li> </ul> <ul> <li><a @click="search">%fa:search%%i18n:@search%%fa:angle-right%</a></li> diff --git a/src/client/app/mobile/views/components/ui.vue b/src/client/app/mobile/views/components/ui.vue index b16c246b10..6f77f44454 100644 --- a/src/client/app/mobile/views/components/ui.vue +++ b/src/client/app/mobile/views/components/ui.vue @@ -58,8 +58,7 @@ export default Vue.extend({ methods: { onNotification(notification) { // TODO: ユーザーが画面を見てないと思われるとき(ブラウザやタブがアクティブじゃないなど)は送信しない - this.connection.send({ - type: 'readNotification', + (this as any).os.stream.send('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 97200eb5b3..f0137d5df4 100644 --- a/src/client/app/mobile/views/components/user-list-timeline.vue +++ b/src/client/app/mobile/views/components/user-list-timeline.vue @@ -36,13 +36,15 @@ export default Vue.extend({ }, beforeDestroy() { - this.connection.close(); + this.connection.dispose(); }, methods: { init() { - if (this.connection) this.connection.close(); - this.connection = new UserListStream((this as any).os, this.$store.state.i, this.list.id); + if (this.connection) this.connection.dispose(); + this.connection = (this as any).os.stream.connectToChannel('userList', { + listId: this.list.id + }); this.connection.on('note', this.onNote); this.connection.on('userAdded', this.onUserAdded); this.connection.on('userRemoved', this.onUserRemoved); diff --git a/src/client/app/mobile/views/pages/drive.vue b/src/client/app/mobile/views/pages/drive.vue index bf02adca9d..03f0686f7c 100644 --- a/src/client/app/mobile/views/pages/drive.vue +++ b/src/client/app/mobile/views/pages/drive.vue @@ -3,7 +3,7 @@ <span slot="header"> <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> + <template v-if="!folder && !file"><span style="margin-right:4px;">%fa:cloud%</span>%i18n:common.drive%</template> </span> <template slot="func"><button @click="fn">%fa:ellipsis-h%</button></template> <mk-drive diff --git a/src/client/app/mobile/views/pages/settings.vue b/src/client/app/mobile/views/pages/settings.vue index 94fa38cec9..8ddfbb2fca 100644 --- a/src/client/app/mobile/views/pages/settings.vue +++ b/src/client/app/mobile/views/pages/settings.vue @@ -5,10 +5,17 @@ <div class="signin-as" v-html="'%i18n:@signed-in-as%'.replace('{}', `<b>${name}</b>`)"></div> <div> - <x-profile/> + <mk-profile-editor/> <ui-card> - <div slot="title">%fa:palette% %i18n:@design%</div> + <div slot="title">%fa:palette% %i18n:@theme%</div> + <section> + <mk-theme/> + </section> + </ui-card> + + <ui-card> + <div slot="title">%fa:poll-h% %i18n:@design%</div> <section> <ui-switch v-model="darkmode">%i18n:@dark-mode%</ui-switch> @@ -24,13 +31,6 @@ </section> <section> - <header>%i18n:@theme%</header> - <div> - <mk-theme/> - </div> - </section> - - <section> <header>%i18n:@timeline%</header> <div> <ui-switch v-model="showReplyTarget">%i18n:@show-reply-target%</ui-switch> @@ -54,7 +54,7 @@ </ui-card> <ui-card> - <div slot="title">%fa:cog% %i18n:@behavior%</div> + <div slot="title">%fa:sliders-h% %i18n:@behavior%</div> <section> <ui-switch v-model="fetchOnScroll">%i18n:@fetch-on-scroll%</ui-switch> @@ -80,6 +80,8 @@ </section> </ui-card> + <mk-drive-settings/> + <ui-card> <div slot="title">%fa:volume-up% %i18n:@sound%</div> @@ -118,6 +120,8 @@ </section> </ui-card> + <mk-api-settings /> + <ui-card> <div slot="title">%fa:sync-alt% %i18n:@update%</div> @@ -148,13 +152,7 @@ import Vue from 'vue'; import { apiUrl, version, codename, langs } from '../../../config'; import checkForUpdate from '../../../common/scripts/check-for-update'; -import XProfile from './settings/settings.profile.vue'; - export default Vue.extend({ - components: { - XProfile - }, - data() { return { apiUrl, diff --git a/src/client/app/mobile/views/pages/user.vue b/src/client/app/mobile/views/pages/user.vue index a2a6bd7a83..95ef387d96 100644 --- a/src/client/app/mobile/views/pages/user.vue +++ b/src/client/app/mobile/views/pages/user.vue @@ -3,7 +3,7 @@ <template slot="header" v-if="!fetching"><img :src="user.avatarUrl" alt="">{{ user | userName }}</template> <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> + <div class="is-remote" v-if="user.host != null"><p>%fa:exclamation-triangle% %i18n:common.is-remote-user%<a :href="user.url || user.uri" target="_blank">%i18n:common.view-on-remote%</a></p></div> <header> <div class="banner" :style="style"></div> <div class="body"> diff --git a/src/client/app/store.ts b/src/client/app/store.ts index 545261225a..7c3a403c87 100644 --- a/src/client/app/store.ts +++ b/src/client/app/store.ts @@ -5,11 +5,13 @@ import * as nestedProperty from 'nested-property'; import MiOS from './mios'; import { hostname } from './config'; import { erase } from '../../prelude/array'; +import getNoteSummary from '../../misc/get-note-summary'; const defaultSettings = { home: null, mobileHome: [], deck: null, + deckNav: true, tagTimelines: [], fetchOnScroll: true, showMaps: true, @@ -56,7 +58,11 @@ const defaultDeviceSettings = { loadRawImages: false, alwaysShowNsfw: false, postStyle: 'standard', - mobileNotificationPosition: 'bottom' + navbar: 'top', + deckColumnAlign: 'center', + mobileNotificationPosition: 'bottom', + deckTemporaryColumn: null, + deckDefault: false }; export default (os: MiOS) => new Vuex.Store({ @@ -67,7 +73,9 @@ export default (os: MiOS) => new Vuex.Store({ state: { i: null, indicate: false, - uiHeaderHeight: 0 + uiHeaderHeight: 0, + navHook: null, + behindNotes: [] }, getters: { @@ -89,6 +97,22 @@ export default (os: MiOS) => new Vuex.Store({ setUiHeaderHeight(state, height) { state.uiHeaderHeight = height; + }, + + navHook(state, callback) { + state.navHook = callback; + }, + + pushBehindNote(state, note) { + if (note.userId === state.i.id) return; + if (state.behindNotes.some(n => n.id === note.id)) return; + state.behindNotes.push(note); + document.title = `(${state.behindNotes.length}) ${getNoteSummary(note)}`; + }, + + clearBehindNotes(state) { + state.behindNotes = []; + document.title = os.instanceName; } }, diff --git a/src/client/theme/dark.json5 b/src/client/theme/dark.json5 index 4fa38a3ae0..b4bd45744e 100644 --- a/src/client/theme/dark.json5 +++ b/src/client/theme/dark.json5 @@ -26,7 +26,7 @@ face: '$secondary', faceText: '#fff', faceHeader: ':lighten<5<$secondary', - faceHeaderText: '#e3e5e8', + faceHeaderText: '$text', faceDivider: 'rgba(0, 0, 0, 0.3)', faceTextButton: '$text', faceTextButtonHover: ':lighten<10<$text', @@ -34,7 +34,7 @@ faceClearButtonHover: 'rgba(0, 0, 0, 0.1)', faceClearButtonActive: 'rgba(0, 0, 0, 0.2)', popupBg: ':lighten<5<$secondary', - popupFg: '#d6dce2', + popupFg: '$text', subNoteBg: 'rgba(0, 0, 0, 0.18)', subNoteText: ':alpha<0.7<$text', @@ -84,7 +84,8 @@ reactionPickerButtonHoverBg: 'rgba(255, 255, 255, 0.18)', - reactionViewerBorder: 'rgba(255, 255, 255, 0.1)', + reactionViewerButtonBg: 'rgba(255, 255, 255, 0.1)', + reactionViewerButtonHoverBg: 'rgba(255, 255, 255, 0.2)', pollEditorInputBg: 'rgba(0, 0, 0, 0.25)', @@ -121,12 +122,18 @@ mfmTitleBg: 'rgba(0, 0, 0, 0.2)', mfmQuote: ':alpha<0.7<$text', mfmQuoteLine: ':alpha<0.6<$text', + mfmLink: '$primary', + mfmMention: '$primary', + mfmHashtag: '$primary', suspendedInfoBg: '#611d1d', suspendedInfoFg: '#ffb4b4', remoteInfoBg: '#42321c', remoteInfoFg: '#ffbd3e', + infoWarnBg: '#42321c', + infoWarnFg: '#ffbd3e', + messagingRoomBg: '@bg', messagingRoomInfo: '#fff', messagingRoomDateDividerLine: 'rgba(255, 255, 255, 0.1)', @@ -170,6 +177,7 @@ desktopSettingsNavItemHover: ':lighten<10<$text', deckAcrylicColumnBg: 'rgba(0, 0, 0, 0.25)', + deckColumnBg: ':darken<3<@face', mobileHeaderBg: ':lighten<5<$secondary', mobileHeaderFg: '$text', diff --git a/src/client/theme/light.json5 b/src/client/theme/light.json5 index 9f17a63dda..ea1de39afc 100644 --- a/src/client/theme/light.json5 +++ b/src/client/theme/light.json5 @@ -34,7 +34,7 @@ faceClearButtonHover: 'rgba(0, 0, 0, 0.025)', faceClearButtonActive: 'rgba(0, 0, 0, 0.05)', popupBg: ':lighten<5<$secondary', - popupFg: '#586069', + popupFg: '$text', subNoteBg: 'rgba(0, 0, 0, 0.01)', subNoteText: ':alpha<0.7<$text', @@ -84,7 +84,8 @@ reactionPickerButtonHoverBg: '#eee', - reactionViewerBorder: 'rgba(0, 0, 0, 0.1)', + reactionViewerButtonBg: 'rgba(0, 0, 0, 0.05)', + reactionViewerButtonHoverBg: 'rgba(0, 0, 0, 0.1)', pollEditorInputBg: '#fff', @@ -121,12 +122,18 @@ mfmTitleBg: 'rgba(0, 0, 0, 0.07)', mfmQuote: ':alpha<0.6<$text', mfmQuoteLine: ':alpha<0.5<$text', + mfmLink: '$primary', + mfmMention: '$primary', + mfmHashtag: '$primary', suspendedInfoBg: '#ffdbdb', suspendedInfoFg: '#570808', remoteInfoBg: '#fff0db', remoteInfoFg: '#573c08', + infoWarnBg: '#fff0db', + infoWarnFg: '#573c08', + messagingRoomBg: '#fff', messagingRoomInfo: '#000', messagingRoomDateDividerLine: 'rgba(0, 0, 0, 0.1)', @@ -170,6 +177,7 @@ desktopSettingsNavItemHover: ':darken<10<$text', deckAcrylicColumnBg: 'rgba(0, 0, 0, 0.1)', + deckColumnBg: ':darken<4<@face', mobileHeaderBg: ':lighten<5<$secondary', mobileHeaderFg: '$text', diff --git a/src/config/load.ts b/src/config/load.ts index 3a1bac3201..9cdd742c6d 100644 --- a/src/config/load.ts +++ b/src/config/load.ts @@ -49,6 +49,8 @@ export default function load() { if (config.localDriveCapacityMb == null) config.localDriveCapacityMb = 256; if (config.remoteDriveCapacityMb == null) config.remoteDriveCapacityMb = 8; + if (config.maxNoteTextLength == null) config.maxNoteTextLength = 1000; + if (config.name == null) config.name = 'Misskey'; return Object.assign(config, mixin); diff --git a/src/config/types.ts b/src/config/types.ts index 003185accd..fc3a3afe5f 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -23,6 +23,7 @@ export type Source = { url: string; port: number; https?: { [x: string]: string }; + disableHsts?: boolean; mongodb: { host: string; port: number; @@ -62,6 +63,8 @@ export type Source = { */ ghost?: string; + proxy?: string; + summalyProxy?: string; accesslog?: string; @@ -93,9 +96,15 @@ export type Source = { private_key: string; }; - google_maps_api_key: string; - clusterLimit?: number; + + user_recommendation?: { + external: boolean; + engine: string; + timeout: number; + }; + + maxNoteTextLength?: number; }; /** diff --git a/src/db/redis.ts b/src/db/redis.ts index f8d66ebda0..48e3f4e43e 100644 --- a/src/db/redis.ts +++ b/src/db/redis.ts @@ -1,10 +1,10 @@ import * as redis from 'redis'; import config from '../config'; -export default redis.createClient( +export default config.redis ? redis.createClient( config.redis.port, config.redis.host, { auth_pass: config.redis.pass } -); +) : null; diff --git a/src/docs/api/endpoints/style.styl b/src/docs/api/endpoints/style.styl index e7e32b3395..56ad291627 100644 --- a/src/docs/api/endpoints/style.styl +++ b/src/docs/api/endpoints/style.styl @@ -21,3 +21,20 @@ > .host opacity 0.7 + +#stability + padding 8px 12px + color #fff + border-radius 4px + + &.deprecated + background #f42443 + + &.experimental + background #f2781a + + &.stable + background #3dcc90 + + > b + margin-left 4px diff --git a/src/docs/api/endpoints/view.pug b/src/docs/api/endpoints/view.pug index be7e84faa1..696ec4050f 100644 --- a/src/docs/api/endpoints/view.pug +++ b/src/docs/api/endpoints/view.pug @@ -14,6 +14,11 @@ block main | / span.path= endpointUrl.path + - var stability = endpoint.stability || 'experimental'; + p#stability(class=stability) + | Stability: + b= stability + if endpoint.desc p#desc= endpoint.desc[lang] || endpoint.desc['ja-JP'] diff --git a/src/docs/keyboard-shortcut.ja-JP.md b/src/docs/keyboard-shortcut.ja-JP.md index 264387242c..40ecfc27da 100644 --- a/src/docs/keyboard-shortcut.ja-JP.md +++ b/src/docs/keyboard-shortcut.ja-JP.md @@ -25,11 +25,13 @@ <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">R</kbd></td><td>返信フォームを開く</td><td><b>R</b>eply</td></tr> + <tr><td><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">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">F</kbd>, <kbd class="key">B</kbd></td><td>お気に入りに登録</td><td><b>F</b>avorite, <b>B</b>ookmark</td></tr> + <tr><td><kbd class="key">Del</kbd>, <kbd class="group"><kbd class="key">Ctrl</kbd> + <kbd class="key">D</kbd></kbd></td><td>投稿を削除</td><td><b>D</b>elete</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> @@ -84,6 +86,19 @@ </tbody> </table> +## デッキ +<table> + <thead> + <tr><th>ショートカット</th><th>効果</th><th>由来</th></tr> + </thead> + <tbody> + <tr><td>投稿にフォーカスした状態で<kbd class="group"><kbd class="key">Shift</kbd> + <kbd class="key">↑</kbd></kbd></td><td>上のカラムにフォーカス</td><td>-</td></tr> + <tr><td>投稿にフォーカスした状態で<kbd class="group"><kbd class="key">Shift</kbd> + <kbd class="key">↓</kbd></kbd></td><td>下のカラムにフォーカス</td><td>-</td></tr> + <tr><td>投稿にフォーカスした状態で<kbd class="group"><kbd class="key">Shift</kbd> + <kbd class="key">→</kbd></kbd></td><td>右のカラムにフォーカス</td><td>-</td></tr> + <tr><td>投稿にフォーカスした状態で<kbd class="group"><kbd class="key">Shift</kbd> + <kbd class="key">←</kbd></kbd></td><td>左のカラムにフォーカス</td><td>-</td></tr> + </tbody> +</table> + # 例 <table> <thead> diff --git a/src/docs/stream.ja-JP.md b/src/docs/stream.ja-JP.md index a8b0eb0cdc..59d6cdaf10 100644 --- a/src/docs/stream.ja-JP.md +++ b/src/docs/stream.ja-JP.md @@ -1,19 +1,14 @@ # ストリーミングAPI -ストリーミングAPIを使うと、リアルタイムで様々な情報(例えばタイムラインに新しい投稿が流れてきた、メッセージが届いた、フォローされた、など)を受け取ったり、HTTPリクエストを発生させることなくAPIにアクセスしたりすることができます。 - -ストリーミングAPIは複数の種類がありますが、ここではメインとなる「ホームストリーム」について説明します。 +ストリーミングAPIを使うと、リアルタイムで様々な情報(例えばタイムラインに新しい投稿が流れてきた、メッセージが届いた、フォローされた、など)を受け取ったり、様々な操作を行ったりすることができます。 ## ストリームに接続する -以下のURLに**websocket**接続します。 -``` -%URL% -``` +ストリーミングAPIを利用するには、まずMisskeyサーバーに**websocket**接続する必要があります。 -接続する際は、`i`というパラメータ名で認証情報を含めます。例: +以下のURLに、`i`というパラメータ名で認証情報を含めて、websocket接続してください。例: ``` -%URL%/?i=xxxxxxxxxxxxxxx +%URL%/streaming?i=xxxxxxxxxxxxxxx ``` 認証情報は、自分のAPIキーや、アプリケーションからストリームに接続する際はユーザーのアクセストークンのことを指します。 @@ -22,12 +17,116 @@ <p><i class="fas fa-info-circle"></i> 認証情報の取得については、<a href="./api">こちらのドキュメント</a>をご確認ください。</p> </div> +--- + +認証情報は省略することもできますが、その場合非ログインでの利用ということになり、受信できる情報や可能な操作は限られます。例: + +``` +%URL%/streaming +``` + +--- + +ストリームに接続すると、後述するAPI操作や、投稿の購読を行ったりすることができます。 +しかしまだこの段階では、例えばタイムラインへの新しい投稿を受信したりすることはできません。 +それを行うには、ストリーム上で、後述する**チャンネル**に接続する必要があります。 + +**ストリームでのやり取りはすべてJSONです。** + +## チャンネル +MisskeyのストリーミングAPIにはチャンネルという概念があります。これは、送受信する情報を分離するための仕組みです。 +Misskeyのストリームに接続しただけでは、まだリアルタイムでタイムラインの投稿を受信したりはできません。 +ストリーム上でチャンネルに接続することで、様々な情報を受け取ったり情報を送信したりすることができるようになります。 + +### チャンネルに接続する +チャンネルに接続するには、次のようなデータをJSONでストリームに送信します: + +```json +{ + type: 'connect', + body: { + channel: 'xxxxxxxx', + id: 'foobar', + params: { + ... + } + } +} +``` + +ここで、 +* `channel`には接続したいチャンネル名を設定します。チャンネルの種類については後述します。 +* `id`にはそのチャンネルとやり取りするための任意のIDを設定します。ストリームでは様々なメッセージが流れるので、そのメッセージがどのチャンネルからのものなのか識別する必要があるからです。このIDは、UUIDや、乱数のようなもので構いません。 +* `params`はチャンネルに接続する際のパラメータです。チャンネルによって接続時に必要とされるパラメータは異なります。パラメータ不要のチャンネルに接続する際は、このプロパティは省略可能です。 + +<div class="ui info"> + <p><i class="fas fa-info-circle"></i> IDはチャンネルごとではなく「チャンネルの接続ごと」です。なぜなら、同じチャンネルに異なるパラメータで複数接続するケースもあるからです。</p> +</div> + +### チャンネルからのメッセージを受け取る +例えばタイムラインのチャンネルなら、新しい投稿があった時にメッセージを発します。そのメッセージを受け取ることで、タイムラインに新しい投稿がされたことをリアルタイムで知ることができます。 + +チャンネルがメッセージを発すると、次のようなデータがJSONでストリームに流れてきます: +```json +{ + type: 'channel', + body: { + id: 'foobar', + type: 'something', + body: { + some: 'thing' + } + } +} +``` + +ここで、 +* `id`には前述したそのチャンネルに接続する際に設定したIDが設定されています。これで、このメッセージがどのチャンネルからのものなのか知ることができます。 +* `type`にはメッセージの種類が設定されます。チャンネルによって、どのような種類のメッセージが流れてくるかは異なります。 +* `body`にはメッセージの内容が設定されます。チャンネルによって、どのような内容のメッセージが流れてくるかは異なります。 + +### チャンネルに向けてメッセージを送信する +チャンネルによっては、メッセージを受け取るだけでなく、こちらから何かメッセージを送信し、何らかの操作を行える場合があります。 + +チャンネルにメッセージを送信するには、次のようなデータをJSONでストリームに送信します: +```json +{ + type: 'channel', + body: { + id: 'foobar', + type: 'something', + body: { + some: 'thing' + } + } +} +``` + +ここで、 +* `id`には前述したそのチャンネルに接続する際に設定したIDを設定します。これで、このメッセージがどのチャンネルに向けたものなのか識別させることができます。 +* `type`にはメッセージの種類を設定します。チャンネルによって、どのような種類のメッセージを受け付けるかは異なります。 +* `body`にはメッセージの内容を設定します。チャンネルによって、どのような内容のメッセージを受け付けるかは異なります。 + +### チャンネルから切断する +チャンネルから切断するには、次のようなデータをJSONでストリームに送信します: + +```json +{ + type: 'disconnect', + body: { + id: 'foobar' + } +} +``` + +ここで、 +* `id`には前述したそのチャンネルに接続する際に設定したIDを設定します。 ## ストリームを経由してAPIリクエストする ストリームを経由してAPIリクエストすると、HTTPリクエストを発生させずにAPIを利用できます。そのため、コードを簡潔にできたり、パフォーマンスの向上を見込めるかもしれません。 -ストリームを経由してAPIリクエストするには、次のようなメッセージをストリームに送信します: +ストリームを経由してAPIリクエストするには、次のようなデータをJSONでストリームに送信します: ```json { type: 'api', @@ -39,11 +138,10 @@ } ``` -`id`には、APIのレスポンスを識別するための、APIリクエストごとの一意なIDを設定する必要があります。UUIDや、簡単な乱数のようなもので構いません。 - -`endpoint`には、あなたがリクエストしたいAPIのエンドポイントを指定します。 - -`data`には、エンドポイントのパラメータを含めます。 +ここで、 +* `id`には、APIのレスポンスを識別するための、APIリクエストごとの一意なIDを設定する必要があります。UUIDや、簡単な乱数のようなもので構いません。 +* `endpoint`には、あなたがリクエストしたいAPIのエンドポイントを指定します。 +* `data`には、エンドポイントのパラメータを含めます。 <div class="ui info"> <p><i class="fas fa-info-circle"></i> APIのエンドポイントやパラメータについてはAPIリファレンスをご確認ください。</p> @@ -62,9 +160,9 @@ APIへリクエストすると、レスポンスがストリームから次の } ``` -`xxxxxxxxxxxxxxxx`の部分には、リクエストの際に設定された`id`が含まれています。これにより、どのリクエストに対するレスポンスなのか判別することができます。 - -`body`には、レスポンスが含まれています。 +ここで、 +* `xxxxxxxxxxxxxxxx`の部分には、リクエストの際に設定された`id`が含まれています。これにより、どのリクエストに対するレスポンスなのか判別することができます。 +* `body`には、レスポンスが含まれています。 ## 投稿のキャプチャ @@ -82,12 +180,15 @@ Misskeyは投稿のキャプチャと呼ばれる仕組みを提供していま ```json { - type: 'capture', - id: 'xxxxxxxxxxxxxxxx' + type: 'subNote', + body: { + id: 'xxxxxxxxxxxxxxxx' + } } ``` -`id`には、キャプチャしたい投稿の`id`を設定します。 +ここで、 +* `id`にキャプチャしたい投稿の`id`を設定します。 このメッセージを送信すると、Misskeyにキャプチャを要請したことになり、以後、その投稿に関するイベントが流れてくるようになります。 @@ -97,87 +198,159 @@ Misskeyは投稿のキャプチャと呼ばれる仕組みを提供していま { type: 'noteUpdated', body: { - note: { - ... + id: 'xxxxxxxxxxxxxxxx', + type: 'reacted', + body: { + reaction: 'like', + userId: 'yyyyyyyyyyyyyyyy' } } } ``` -`body`内の`note`には、その投稿の最新の情報が含まれています。 +ここで、 +* `body`内の`id`に、イベントを発生させた投稿のIDが設定されます。 +* `body`内の`type`に、イベントの種類が設定されます。 +* `body`内の`body`に、イベントの詳細が設定されます。 ---- +#### イベントの種類 -このように、投稿の情報が更新されると、`noteUpdated`イベントが流れてくるようになります。`noteUpdated`イベントが発生するのは、以下の場合です: +##### `reacted` +その投稿にリアクションがされた時に発生します。 -- 投稿にリアクションが付いた -- 投稿に添付されたアンケートに投票がされた -- 投稿が削除された +* `reaction`に、リアクションの種類が設定されます。 +* `userId`に、リアクションを行ったユーザーのIDが設定されます。 -### 投稿のキャプチャを解除する +例: +```json +{ + type: 'noteUpdated', + body: { + id: 'xxxxxxxxxxxxxxxx', + type: 'reacted', + body: { + reaction: 'like', + userId: 'yyyyyyyyyyyyyyyy' + } + } +} +``` -その投稿がもう画面に表示されなくなったりして、その投稿に関するイベントをもう受け取る必要がなくなったときは、キャプチャの解除を申請してください。 +##### `deleted` +その投稿が削除された時に発生します。 -次のメッセージを送信します: +* `deletedAt`に、削除日時が設定されます。 +例: ```json { - type: 'decapture', - id: 'xxxxxxxxxxxxxxxx' + type: 'noteUpdated', + body: { + id: 'xxxxxxxxxxxxxxxx', + type: 'deleted', + body: { + deletedAt: '2018-10-22T02:17:09.703Z' + } + } } ``` -`id`には、キャプチャを解除したい投稿の`id`を設定します。 +##### `pollVoted` +その投稿に添付されたアンケートに投票された時に発生します。 -このメッセージを送信すると、以後、その投稿に関するイベントは流れてこないようになります。 +* `choice`に、選択肢IDが設定されます。 +* `userId`に、投票を行ったユーザーのIDが設定されます。 + +例: +```json +{ + type: 'noteUpdated', + body: { + id: 'xxxxxxxxxxxxxxxx', + type: 'pollVoted', + body: { + choice: 2, + userId: 'yyyyyyyyyyyyyyyy' + } + } +} +``` -## 流れてくるイベント一覧 +### 投稿のキャプチャを解除する -流れてくるすべてのメッセージはJSON形式で、必ず`type`というプロパティが含まれています。これにより、メッセージの種類(イベント)を判別することができます。 +その投稿がもう画面に表示されなくなったりして、その投稿に関するイベントをもう受け取る必要がなくなったときは、キャプチャの解除を申請してください。 -### `note` +次のメッセージを送信します: -タイムラインに新しい投稿が流れてきたときに発生するイベントです。 +```json +{ + type: 'unsubNote', + body: { + id: 'xxxxxxxxxxxxxxxx' + } +} +``` -`body`プロパティの中に、投稿情報が含まれています。 +ここで、 +* `id`にキャプチャを解除したい投稿の`id`を設定します。 -### `renote` +このメッセージを送信すると、以後、その投稿に関するイベントは流れてこないようになります。 -自分の投稿がRenoteされた時に発生するイベントです。自分自身の投稿をRenoteしたときは発生しません。 +# チャンネル一覧 +## `main` +アカウントに関する基本的な情報が流れてきます。このチャンネルにパラメータはありません。 -`body`プロパティの中に、Renoteされた投稿情報が含まれています。 +### 流れてくるイベント一覧 -### `mention` +#### `renote` +自分の投稿がRenoteされた時に発生するイベントです。自分自身の投稿をRenoteしたときは発生しません。 +#### `mention` 誰かからメンションされたときに発生するイベントです。 -`body`プロパティの中に、投稿情報が含まれています。 +#### `readAllNotifications` +自分宛ての通知がすべて既読になったことを表すイベントです。このイベントを利用して、「通知があることを示すアイコン」のようなものをオフにしたりする等のケースが想定されます。 -### `readAllNotifications` +#### `meUpdated` +自分の情報が更新されたことを表すイベントです。 -自分宛ての通知がすべて既読になったことを表すイベントです。このイベントを利用して、「通知があることを示すアイコン」のようなものをオフにしたりする等のケースが想定されます。 +#### `follow` +自分が誰かをフォローしたときに発生するイベントです。 + +#### `unfollow` +自分が誰かのフォローを解除したときに発生するイベントです。 -### `meUpdated` +#### `followed` +自分が誰かにフォローされたときに発生するイベントです。 -自分の情報が更新されたことを表すイベントです。 +## `homeTimeline` +ホームタイムラインの投稿情報が流れてきます。このチャンネルにパラメータはありません。 -`body`プロパティの中に、最新の自分のアカウントの情報が含まれています。 +### 流れてくるイベント一覧 -### `follow` +#### `note` +タイムラインに新しい投稿が流れてきたときに発生するイベントです。 -自分が誰かをフォローしたときに発生するイベントです。 +## `localTimeline` +ローカルタイムラインの投稿情報が流れてきます。このチャンネルにパラメータはありません。 -`body`プロパティの中に、フォローしたユーザーの情報が含まれています。 +### 流れてくるイベント一覧 -### `unfollow` +#### `note` +ローカルタイムラインに新しい投稿が流れてきたときに発生するイベントです。 -自分が誰かのフォローを解除したときに発生するイベントです。 +## `hybridTimeline` +ソーシャルタイムラインの投稿情報が流れてきます。このチャンネルにパラメータはありません。 -`body`プロパティの中に、フォロー解除したユーザーの情報が含まれています。 +### 流れてくるイベント一覧 -### `followed` +#### `note` +ソーシャルタイムラインに新しい投稿が流れてきたときに発生するイベントです。 -自分が誰かにフォローされたときに発生するイベントです。 +## `globalTimeline` +グローバルタイムラインの投稿情報が流れてきます。このチャンネルにパラメータはありません。 -`body`プロパティの中に、フォローしてきたユーザーの情報が含まれています。 +### 流れてくるイベント一覧 +#### `note` +グローバルタイムラインに新しい投稿が流れてきたときに発生するイベントです。 diff --git a/src/docs/style.styl b/src/docs/style.styl index 70d77b5499..4af0f288b0 100644 --- a/src/docs/style.styl +++ b/src/docs/style.styl @@ -1,6 +1,9 @@ @import "../client/style" @import "./ui" +html + --primary #fb4e4e + body margin 0 color #34495e diff --git a/src/index.ts b/src/index.ts index ed23ff7e72..259f5b1b0c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -111,6 +111,7 @@ async function workerMain() { */ async function init(): Promise<Config> { Logger.info('Welcome to Misskey!'); + Logger.info(`<<< Misskey v${pkg.version} >>>`); new Logger('Deps').info(`Node.js ${process.version}`); MachineInfo.show(); diff --git a/src/mfm/parse/elements/hashtag.ts b/src/mfm/parse/elements/hashtag.ts index 339026228a..e4e9df6ce3 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/mention.ts b/src/mfm/parse/elements/mention.ts index a95ec00384..ade5954423 100644 --- a/src/mfm/parse/elements/mention.ts +++ b/src/mfm/parse/elements/mention.ts @@ -2,10 +2,12 @@ * Mention */ import parseAcct from '../../../misc/acct/parse'; +import { toUnicode } from 'punycode'; export type TextElementMention = { type: 'mention' content: string + canonical: string username: string host: string }; @@ -15,9 +17,11 @@ export default function(text: string) { if (!match) return null; const mention = match[0]; const { username, host } = parseAcct(mention.substr(1)); + const canonical = host != null ? `@${username}@${toUnicode(host)}` : mention; return { type: 'mention', content: mention, + canonical, username, host } as TextElementMention; diff --git a/src/misc/cafy-id.ts b/src/misc/cafy-id.ts index f3e1f5251b..3880f0bd0c 100644 --- a/src/misc/cafy-id.ts +++ b/src/misc/cafy-id.ts @@ -1,5 +1,6 @@ import * as mongo from 'mongodb'; import { Context } from 'cafy'; +import isObjectId from './is-objectid'; export const isAnId = (x: any) => mongo.ObjectID.isValid(x); export const isNotAnId = (x: any) => !isAnId(x); @@ -12,7 +13,7 @@ export default class ID extends Context<mongo.ObjectID> { super(); this.transform = v => { - if (isAnId(v) && !mongo.ObjectID.prototype.isPrototypeOf(v)) { + if (isAnId(v) && !isObjectId(v)) { return new mongo.ObjectID(v); } else { return v; @@ -20,7 +21,7 @@ export default class ID extends Context<mongo.ObjectID> { }; this.push(v => { - if (!mongo.ObjectID.prototype.isPrototypeOf(v) && isNotAnId(v)) { + if (!isObjectId(v) && isNotAnId(v)) { return new Error('must-be-an-id'); } return true; diff --git a/src/misc/get-note-summary.ts b/src/misc/get-note-summary.ts index 3c6f2dd3d6..3f96483032 100644 --- a/src/misc/get-note-summary.ts +++ b/src/misc/get-note-summary.ts @@ -17,7 +17,7 @@ const summarize = (note: any): string => { summary += note.text ? note.text : ''; // ファイルが添付されているとき - if (note.files.length != 0) { + if ((note.files || []).length != 0) { summary += ` (${note.files.length}つのファイル)`; } @@ -38,9 +38,9 @@ const summarize = (note: any): string => { // Renoteのとき if (note.renoteId) { if (note.renote) { - summary += ` RP: ${summarize(note.renote)}`; + summary += ` RN: ${summarize(note.renote)}`; } else { - summary += ' RP: ...'; + summary += ' RN: ...'; } } diff --git a/src/misc/is-objectid.ts b/src/misc/is-objectid.ts new file mode 100644 index 0000000000..8c1aabd568 --- /dev/null +++ b/src/misc/is-objectid.ts @@ -0,0 +1,3 @@ +export default function(x: any): boolean { + return x.hasOwnProperty('toHexString') || x.hasOwnProperty('_bsontype'); +} diff --git a/src/misc/should-mute-this-note.ts b/src/misc/should-mute-this-note.ts index 663e60af6d..b1d29c6a28 100644 --- a/src/misc/should-mute-this-note.ts +++ b/src/misc/should-mute-this-note.ts @@ -1,7 +1,8 @@ import * as mongo from 'mongodb'; +import isObjectId from './is-objectid'; function toString(id: any) { - return mongo.ObjectID.prototype.isPrototypeOf(id) ? (id as mongo.ObjectID).toHexString() : id; + return isObjectId(id) ? (id as mongo.ObjectID).toHexString() : id; } export default function(note: any, mutedUserIds: string[]): boolean { diff --git a/src/models/access-token.ts b/src/models/access-token.ts index 9909ea01ad..e9cbec7061 100644 --- a/src/models/access-token.ts +++ b/src/models/access-token.ts @@ -1,5 +1,6 @@ import * as mongo from 'mongodb'; import db from '../db/mongodb'; +import isObjectId from '../misc/is-objectid'; const AccessToken = db.get<IAccessToken>('accessTokens'); AccessToken.createIndex('token'); @@ -22,7 +23,7 @@ export async function deleteAccessToken(accessToken: string | mongo.ObjectID | I let a: IAccessToken; // Populate - if (mongo.ObjectID.prototype.isPrototypeOf(accessToken)) { + if (isObjectId(accessToken)) { a = await AccessToken.findOne({ _id: accessToken }); diff --git a/src/models/app.ts b/src/models/app.ts index c0b2b5a0f3..45686fe405 100644 --- a/src/models/app.ts +++ b/src/models/app.ts @@ -2,6 +2,7 @@ import * as mongo from 'mongodb'; const deepcopy = require('deepcopy'); import AccessToken from './access-token'; import db from '../db/mongodb'; +import isObjectId from '../misc/is-objectid'; import config from '../config'; const App = db.get<IApp>('apps'); @@ -43,7 +44,7 @@ export const pack = ( let _app: any; // Populate the app if 'app' is ID - if (mongo.ObjectID.prototype.isPrototypeOf(app)) { + if (isObjectId(app)) { _app = await App.findOne({ _id: app }); @@ -56,7 +57,7 @@ export const pack = ( } // Me - if (me && !mongo.ObjectID.prototype.isPrototypeOf(me)) { + if (me && !isObjectId(me)) { if (typeof me === 'string') { me = new mongo.ObjectID(me); } else { diff --git a/src/models/auth-session.ts b/src/models/auth-session.ts index 3d2c9ee3c1..3458d5675f 100644 --- a/src/models/auth-session.ts +++ b/src/models/auth-session.ts @@ -1,6 +1,7 @@ import * as mongo from 'mongodb'; const deepcopy = require('deepcopy'); import db from '../db/mongodb'; +import isObjectId from '../misc/is-objectid'; import { pack as packApp } from './app'; const AuthSession = db.get<IAuthSession>('authSessions'); @@ -31,7 +32,7 @@ export const pack = ( _session = deepcopy(session); // Me - if (me && !mongo.ObjectID.prototype.isPrototypeOf(me)) { + if (me && !isObjectId(me)) { if (typeof me === 'string') { me = new mongo.ObjectID(me); } else { diff --git a/src/models/drive-file-thumbnail.ts b/src/models/drive-file-thumbnail.ts index 46de24379f..5864b8d321 100644 --- a/src/models/drive-file-thumbnail.ts +++ b/src/models/drive-file-thumbnail.ts @@ -1,5 +1,6 @@ import * as mongo from 'mongodb'; import monkDb, { nativeDbConn } from '../db/mongodb'; +import isObjectId from '../misc/is-objectid'; const DriveFileThumbnail = monkDb.get<IDriveFileThumbnail>('driveFileThumbnails.files'); DriveFileThumbnail.createIndex('metadata.originalId', { sparse: true, unique: true }); @@ -35,7 +36,7 @@ export async function deleteDriveFileThumbnail(driveFile: string | mongo.ObjectI let d: IDriveFileThumbnail; // Populate - if (mongo.ObjectID.prototype.isPrototypeOf(driveFile)) { + if (isObjectId(driveFile)) { d = await DriveFileThumbnail.findOne({ _id: driveFile }); diff --git a/src/models/drive-file.ts b/src/models/drive-file.ts index 0d0886ad0b..c9fdb6156c 100644 --- a/src/models/drive-file.ts +++ b/src/models/drive-file.ts @@ -3,6 +3,7 @@ const deepcopy = require('deepcopy'); import { pack as packFolder } from './drive-folder'; import config from '../config'; import monkDb, { nativeDbConn } from '../db/mongodb'; +import isObjectId from '../misc/is-objectid'; import Note, { deleteNote } from './note'; import MessagingMessage, { deleteMessagingMessage } from './messaging-message'; import User from './user'; @@ -11,6 +12,8 @@ import DriveFileThumbnail, { deleteDriveFileThumbnail } from './drive-file-thumb const DriveFile = monkDb.get<IDriveFile>('driveFiles.files'); DriveFile.createIndex('md5'); DriveFile.createIndex('metadata.uri'); +DriveFile.createIndex('metadata.userId'); +DriveFile.createIndex('metadata.folderId'); export default DriveFile; export const DriveFileChunk = monkDb.get('driveFiles.chunks'); @@ -40,6 +43,11 @@ export type IMetadata = { isSensitive?: boolean; /** + * このファイルが添付された投稿のID一覧 + */ + attachedNoteIds?: mongo.ObjectID[]; + + /** * 外部の(信頼されていない)URLへの直リンクか否か */ isRemote?: boolean; @@ -76,7 +84,7 @@ export async function deleteDriveFile(driveFile: string | mongo.ObjectID | IDriv let d: IDriveFile; // Populate - if (mongo.ObjectID.prototype.isPrototypeOf(driveFile)) { + if (isObjectId(driveFile)) { d = await DriveFile.findOne({ _id: driveFile }); @@ -152,7 +160,7 @@ export const pack = ( let _file: any; // Populate the file if 'file' is ID - if (mongo.ObjectID.prototype.isPrototypeOf(file)) { + if (isObjectId(file)) { _file = await DriveFile.findOne({ _id: file }); @@ -166,7 +174,7 @@ export const pack = ( // (データベースの欠損などで)ファイルがデータベース上に見つからなかったとき if (_file == null) { - console.warn(`in packaging driveFile: driveFile not found on database: ${_file}`); + console.warn(`[DAMAGED DB] (missing) pkg: driveFile :: ${file}`); return resolve(null); } diff --git a/src/models/drive-folder.ts b/src/models/drive-folder.ts index def519fade..e826d78403 100644 --- a/src/models/drive-folder.ts +++ b/src/models/drive-folder.ts @@ -1,9 +1,11 @@ import * as mongo from 'mongodb'; const deepcopy = require('deepcopy'); import db from '../db/mongodb'; +import isObjectId from '../misc/is-objectid'; import DriveFile from './drive-file'; const DriveFolder = db.get<IDriveFolder>('driveFolders'); +DriveFolder.createIndex('userId'); export default DriveFolder; export type IDriveFolder = { @@ -28,7 +30,7 @@ export async function deleteDriveFolder(driveFolder: string | mongo.ObjectID | I let d: IDriveFolder; // Populate - if (mongo.ObjectID.prototype.isPrototypeOf(driveFolder)) { + if (isObjectId(driveFolder)) { d = await DriveFolder.findOne({ _id: driveFolder }); @@ -82,7 +84,7 @@ export const pack = ( let _folder: any; // Populate the folder if 'folder' is ID - if (mongo.ObjectID.prototype.isPrototypeOf(folder)) { + if (isObjectId(folder)) { _folder = await DriveFolder.findOne({ _id: folder }); } else if (typeof folder === 'string') { _folder = await DriveFolder.findOne({ _id: new mongo.ObjectID(folder) }); diff --git a/src/models/favorite.ts b/src/models/favorite.ts index 2c10674bcb..9a01d3a990 100644 --- a/src/models/favorite.ts +++ b/src/models/favorite.ts @@ -1,6 +1,7 @@ import * as mongo from 'mongodb'; const deepcopy = require('deepcopy'); import db from '../db/mongodb'; +import isObjectId from '../misc/is-objectid'; import { pack as packNote } from './note'; const Favorite = db.get<IFavorite>('favorites'); @@ -21,7 +22,7 @@ export async function deleteFavorite(favorite: string | mongo.ObjectID | IFavori let f: IFavorite; // Populate - if (mongo.ObjectID.prototype.isPrototypeOf(favorite)) { + if (isObjectId(favorite)) { f = await Favorite.findOne({ _id: favorite }); @@ -58,7 +59,7 @@ export const pack = ( let _favorite: any; // Populate the favorite if 'favorite' is ID - if (mongo.ObjectID.prototype.isPrototypeOf(favorite)) { + if (isObjectId(favorite)) { _favorite = await Favorite.findOne({ _id: favorite }); @@ -75,11 +76,13 @@ export const pack = ( delete _favorite._id; // Populate note - _favorite.note = await packNote(_favorite.noteId, me); + _favorite.note = await packNote(_favorite.noteId, me, { + detail: true + }); // (データベースの不具合などで)投稿が見つからなかったら if (_favorite.note == null) { - console.warn(`in packaging favorite: note not found on database: ${_favorite.noteId}`); + console.warn(`[DAMAGED DB] (missing) pkg: favorite -> note :: ${_favorite.id} (note ${_favorite.noteId})`); return resolve(null); } diff --git a/src/models/follow-request.ts b/src/models/follow-request.ts index 8ed131c80e..01d4b8ce6b 100644 --- a/src/models/follow-request.ts +++ b/src/models/follow-request.ts @@ -1,6 +1,7 @@ import * as mongo from 'mongodb'; const deepcopy = require('deepcopy'); import db from '../db/mongodb'; +import isObjectId from '../misc/is-objectid'; import { pack as packUser } from './user'; const FollowRequest = db.get<IFollowRequest>('followRequests'); @@ -12,6 +13,7 @@ export type IFollowRequest = { createdAt: Date; followeeId: mongo.ObjectID; followerId: mongo.ObjectID; + requestId?: string; // id of Follow Activity // 非正規化 _followee: { @@ -33,7 +35,7 @@ export async function deleteFollowRequest(followRequest: string | mongo.ObjectID let f: IFollowRequest; // Populate - if (mongo.ObjectID.prototype.isPrototypeOf(followRequest)) { + if (isObjectId(followRequest)) { f = await FollowRequest.findOne({ _id: followRequest }); @@ -63,7 +65,7 @@ export const pack = ( let _request: any; // Populate the request if 'request' is ID - if (mongo.ObjectID.prototype.isPrototypeOf(request)) { + if (isObjectId(request)) { _request = await FollowRequest.findOne({ _id: request }); diff --git a/src/models/followed-log.ts b/src/models/followed-log.ts deleted file mode 100644 index 7d488b9cd3..0000000000 --- a/src/models/followed-log.ts +++ /dev/null @@ -1,39 +0,0 @@ -import * as mongo from 'mongodb'; -import db from '../db/mongodb'; - -const FollowedLog = db.get<IFollowedLog>('followedLogs'); -export default FollowedLog; - -export type IFollowedLog = { - _id: mongo.ObjectID; - createdAt: Date; - userId: mongo.ObjectID; - count: number; -}; - -/** - * FollowedLogを物理削除します - */ -export async function deleteFollowedLog(followedLog: string | mongo.ObjectID | IFollowedLog) { - let f: IFollowedLog; - - // Populate - if (mongo.ObjectID.prototype.isPrototypeOf(followedLog)) { - f = await FollowedLog.findOne({ - _id: followedLog - }); - } else if (typeof followedLog === 'string') { - f = await FollowedLog.findOne({ - _id: new mongo.ObjectID(followedLog) - }); - } else { - f = followedLog as IFollowedLog; - } - - if (f == null) return; - - // このFollowedLogを削除 - await FollowedLog.remove({ - _id: f._id - }); -} diff --git a/src/models/following-log.ts b/src/models/following-log.ts deleted file mode 100644 index c06a337fd4..0000000000 --- a/src/models/following-log.ts +++ /dev/null @@ -1,39 +0,0 @@ -import * as mongo from 'mongodb'; -import db from '../db/mongodb'; - -const FollowingLog = db.get<IFollowingLog>('followingLogs'); -export default FollowingLog; - -export type IFollowingLog = { - _id: mongo.ObjectID; - createdAt: Date; - userId: mongo.ObjectID; - count: number; -}; - -/** - * FollowingLogを物理削除します - */ -export async function deleteFollowingLog(followingLog: string | mongo.ObjectID | IFollowingLog) { - let f: IFollowingLog; - - // Populate - if (mongo.ObjectID.prototype.isPrototypeOf(followingLog)) { - f = await FollowingLog.findOne({ - _id: followingLog - }); - } else if (typeof followingLog === 'string') { - f = await FollowingLog.findOne({ - _id: new mongo.ObjectID(followingLog) - }); - } else { - f = followingLog as IFollowingLog; - } - - if (f == null) return; - - // このFollowingLogを削除 - await FollowingLog.remove({ - _id: f._id - }); -} diff --git a/src/models/following.ts b/src/models/following.ts index 8aa588f557..2d55ce3616 100644 --- a/src/models/following.ts +++ b/src/models/following.ts @@ -1,5 +1,6 @@ import * as mongo from 'mongodb'; import db from '../db/mongodb'; +import isObjectId from '../misc/is-objectid'; const Following = db.get<IFollowing>('following'); Following.createIndex(['followerId', 'followeeId'], { unique: true }); @@ -32,7 +33,7 @@ export async function deleteFollowing(following: string | mongo.ObjectID | IFoll let f: IFollowing; // Populate - if (mongo.ObjectID.prototype.isPrototypeOf(following)) { + if (isObjectId(following)) { f = await Following.findOne({ _id: following }); diff --git a/src/models/games/reversi/game.ts b/src/models/games/reversi/game.ts index 6a6c6463d9..3393932af8 100644 --- a/src/models/games/reversi/game.ts +++ b/src/models/games/reversi/game.ts @@ -1,6 +1,7 @@ import * as mongo from 'mongodb'; const deepcopy = require('deepcopy'); import db from '../../../db/mongodb'; +import isObjectId from '../../../misc/is-objectid'; import { IUser, pack as packUser } from '../../user'; const ReversiGame = db.get<IReversiGame>('reversiGames'); @@ -62,7 +63,7 @@ export const pack = ( let _game: any; // Populate the game if 'game' is ID - if (mongo.ObjectID.prototype.isPrototypeOf(game)) { + if (isObjectId(game)) { _game = await ReversiGame.findOne({ _id: game }); @@ -76,7 +77,7 @@ export const pack = ( // Me const meId: mongo.ObjectID = me - ? mongo.ObjectID.prototype.isPrototypeOf(me) + ? isObjectId(me) ? me as mongo.ObjectID : typeof me === 'string' ? new mongo.ObjectID(me) diff --git a/src/models/games/reversi/matching.ts b/src/models/games/reversi/matching.ts index e94b832cb0..981665ca2c 100644 --- a/src/models/games/reversi/matching.ts +++ b/src/models/games/reversi/matching.ts @@ -1,6 +1,7 @@ import * as mongo from 'mongodb'; const deepcopy = require('deepcopy'); import db from '../../../db/mongodb'; +import isObjectId from '../../../misc/is-objectid'; import { IUser, pack as packUser } from '../../user'; const Matching = db.get<IMatching>('reversiMatchings'); @@ -23,7 +24,7 @@ export const pack = ( // Me const meId: mongo.ObjectID = me - ? mongo.ObjectID.prototype.isPrototypeOf(me) + ? isObjectId(me) ? me as mongo.ObjectID : typeof me === 'string' ? new mongo.ObjectID(me) diff --git a/src/models/instance.ts b/src/models/instance.ts new file mode 100644 index 0000000000..d3906df427 --- /dev/null +++ b/src/models/instance.ts @@ -0,0 +1,35 @@ +import * as mongo from 'mongodb'; +import db from '../db/mongodb'; + +const Instance = db.get<IInstance>('instances'); +Instance.createIndex('host', { unique: true }); +export default Instance; + +export interface IInstance { + _id: mongo.ObjectID; + + /** + * ホスト + */ + host: string; + + /** + * このインスタンスを捕捉した日時 + */ + caughtAt: Date; + + /** + * このインスタンスのシステム (MastodonとかMisskeyとかPleromaとか) + */ + system: string; + + /** + * このインスタンスのユーザー数 + */ + usersCount: number; + + /** + * このインスタンスから受け取った投稿数 + */ + notesCount: number; +} diff --git a/src/models/messaging-history.ts b/src/models/messaging-history.ts index 5367f81412..4d7db5617a 100644 --- a/src/models/messaging-history.ts +++ b/src/models/messaging-history.ts @@ -1,5 +1,6 @@ import * as mongo from 'mongodb'; import db from '../db/mongodb'; +import isObjectId from '../misc/is-objectid'; const MessagingHistory = db.get<IMessagingHistory>('messagingHistories'); export default MessagingHistory; @@ -19,7 +20,7 @@ export async function deleteMessagingHistory(messagingHistory: string | mongo.Ob let m: IMessagingHistory; // Populate - if (mongo.ObjectID.prototype.isPrototypeOf(messagingHistory)) { + if (isObjectId(messagingHistory)) { m = await MessagingHistory.findOne({ _id: messagingHistory }); diff --git a/src/models/messaging-message.ts b/src/models/messaging-message.ts index d778164de0..7e94205ca5 100644 --- a/src/models/messaging-message.ts +++ b/src/models/messaging-message.ts @@ -3,6 +3,7 @@ const deepcopy = require('deepcopy'); import { pack as packUser } from './user'; import { pack as packFile } from './drive-file'; import db from '../db/mongodb'; +import isObjectId from '../misc/is-objectid'; import MessagingHistory, { deleteMessagingHistory } from './messaging-history'; import { length } from 'stringz'; @@ -30,7 +31,7 @@ export async function deleteMessagingMessage(messagingMessage: string | mongo.Ob let m: IMessagingMessage; // Populate - if (mongo.ObjectID.prototype.isPrototypeOf(messagingMessage)) { + if (isObjectId(messagingMessage)) { m = await MessagingMessage.findOne({ _id: messagingMessage }); @@ -72,7 +73,7 @@ export const pack = ( let _message: any; // Populate the message if 'message' is ID - if (mongo.ObjectID.prototype.isPrototypeOf(message)) { + if (isObjectId(message)) { _message = await MessagingMessage.findOne({ _id: message }); diff --git a/src/models/mute.ts b/src/models/mute.ts index 8fe4eb2ee9..adcaf04b36 100644 --- a/src/models/mute.ts +++ b/src/models/mute.ts @@ -1,5 +1,6 @@ import * as mongo from 'mongodb'; import db from '../db/mongodb'; +import isObjectId from '../misc/is-objectid'; const Mute = db.get<IMute>('mute'); Mute.createIndex(['muterId', 'muteeId'], { unique: true }); @@ -19,7 +20,7 @@ export async function deleteMute(mute: string | mongo.ObjectID | IMute) { let m: IMute; // Populate - if (mongo.ObjectID.prototype.isPrototypeOf(mute)) { + if (isObjectId(mute)) { m = await Mute.findOne({ _id: mute }); diff --git a/src/models/note-reaction.ts b/src/models/note-reaction.ts index a710fef364..cdc859b5a7 100644 --- a/src/models/note-reaction.ts +++ b/src/models/note-reaction.ts @@ -2,6 +2,7 @@ import * as mongo from 'mongodb'; import $ from 'cafy'; const deepcopy = require('deepcopy'); import db from '../db/mongodb'; +import isObjectId from '../misc/is-objectid'; import Reaction from './note-reaction'; import { pack as packUser } from './user'; @@ -37,7 +38,7 @@ export async function deleteNoteReaction(noteReaction: string | mongo.ObjectID | let n: INoteReaction; // Populate - if (mongo.ObjectID.prototype.isPrototypeOf(noteReaction)) { + if (isObjectId(noteReaction)) { n = await NoteReaction.findOne({ _id: noteReaction }); @@ -67,7 +68,7 @@ export const pack = ( let _reaction: any; // Populate the reaction if 'reaction' is ID - if (mongo.ObjectID.prototype.isPrototypeOf(reaction)) { + if (isObjectId(reaction)) { _reaction = await Reaction.findOne({ _id: reaction }); diff --git a/src/models/note-watching.ts b/src/models/note-watching.ts index 479f92dd44..ae576907bc 100644 --- a/src/models/note-watching.ts +++ b/src/models/note-watching.ts @@ -1,5 +1,6 @@ import * as mongo from 'mongodb'; import db from '../db/mongodb'; +import isObjectId from '../misc/is-objectid'; const NoteWatching = db.get<INoteWatching>('noteWatching'); NoteWatching.createIndex(['userId', 'noteId'], { unique: true }); @@ -19,7 +20,7 @@ export async function deleteNoteWatching(noteWatching: string | mongo.ObjectID | let n: INoteWatching; // Populate - if (mongo.ObjectID.prototype.isPrototypeOf(noteWatching)) { + if (isObjectId(noteWatching)) { n = await NoteWatching.findOne({ _id: noteWatching }); diff --git a/src/models/note.ts b/src/models/note.ts index 6c16ab054b..c147b63f0d 100644 --- a/src/models/note.ts +++ b/src/models/note.ts @@ -2,6 +2,7 @@ import * as mongo from 'mongodb'; const deepcopy = require('deepcopy'); import rap from '@prezzemolo/rap'; import db from '../db/mongodb'; +import isObjectId from '../misc/is-objectid'; import { length } from 'stringz'; import { IUser, pack as packUser } from './user'; import { pack as packApp } from './app'; @@ -13,6 +14,7 @@ import NoteReaction from './note-reaction'; import Favorite, { deleteFavorite } from './favorite'; import Notification, { deleteNotification } from './notification'; import Following from './following'; +import config from '../config'; const Note = db.get<INote>('notes'); Note.createIndex('uri', { sparse: true, unique: true }); @@ -20,15 +22,15 @@ Note.createIndex('userId'); Note.createIndex('mentions'); Note.createIndex('visibleUserIds'); Note.createIndex('tagsLower'); +Note.createIndex('_user.host'); Note.createIndex('_files._id'); Note.createIndex('_files.contentType'); -Note.createIndex({ - createdAt: -1 -}); +Note.createIndex({ createdAt: -1 }); +Note.createIndex({ score: -1 }, { sparse: true }); export default Note; export function isValidText(text: string): boolean { - return length(text.trim()) <= 1000 && text.trim() != ''; + return length(text.trim()) <= config.maxNoteTextLength && text.trim() != ''; } export function isValidCw(text: string): boolean { @@ -83,8 +85,14 @@ export type INote = { heading: number; speed: number; }; + uri: string; + /** + * 人気の投稿度合いを表すスコア + */ + score: number; + // 非正規化 _reply?: { userId: mongo.ObjectID; @@ -107,7 +115,7 @@ export async function deleteNote(note: string | mongo.ObjectID | INote) { let n: INote; // Populate - if (mongo.ObjectID.prototype.isPrototypeOf(note)) { + if (isObjectId(note)) { n = await Note.findOne({ _id: note }); @@ -259,7 +267,7 @@ export const pack = async ( // Me const meId: mongo.ObjectID = me - ? mongo.ObjectID.prototype.isPrototypeOf(me) + ? isObjectId(me) ? me as mongo.ObjectID : typeof me === 'string' ? new mongo.ObjectID(me) @@ -269,7 +277,7 @@ export const pack = async ( let _note: any; // Populate the note if 'note' is ID - if (mongo.ObjectID.prototype.isPrototypeOf(note)) { + if (isObjectId(note)) { _note = await Note.findOne({ _id: note }); @@ -281,9 +289,9 @@ export const pack = async ( _note = deepcopy(note); } - // 投稿がデータベース上に見つからなかったとき + // (データベースの欠損などで)投稿がデータベース上に見つからなかったとき if (_note == null) { - console.warn(`note not found on database: ${note}`); + console.warn(`[DAMAGED DB] (missing) pkg: note :: ${note}`); return null; } @@ -296,6 +304,7 @@ export const pack = async ( delete _note.prev; delete _note.next; delete _note.tagsLower; + delete _note.score; delete _note._user; delete _note._reply; delete _note._renote; @@ -358,8 +367,8 @@ export const pack = async ( })(_note.poll); } - // Fetch my reaction if (meId) { + // Fetch my reaction _note.myReaction = (async () => { const reaction = await Reaction .findOne({ @@ -374,18 +383,44 @@ export const pack = async ( return null; })(); + + // isFavorited + _note.isFavorited = (async () => { + const favorite = await Favorite + .count({ + userId: meId, + noteId: id + }, { + limit: 1 + }); + + return favorite === 1; + })(); } } // resolve promises in _note object _note = await rap(_note); - // (データベースの欠損などで)ユーザーがデータベース上に見つからなかったとき + //#region (データベースの欠損などで)参照しているデータがデータベース上に見つからなかったとき if (_note.user == null) { - console.warn(`in packaging note: note user not found on database: note(${_note.id})`); + console.warn(`[DAMAGED DB] (missing) pkg: note -> user :: ${_note.id} (user ${_note.userId})`); return null; } + if (opts.detail) { + if (_note.replyId != null && _note.reply == null) { + console.warn(`[DAMAGED DB] (missing) pkg: note -> reply :: ${_note.id} (reply ${_note.replyId})`); + return null; + } + + if (_note.renoteId != null && _note.renote == null) { + console.warn(`[DAMAGED DB] (missing) pkg: note -> renote :: ${_note.id} (renote ${_note.renoteId})`); + return null; + } + } + //#endregion + if (_note.user.isCat && _note.text) { _note.text = _note.text.replace(/な/g, 'にゃ').replace(/ナ/g, 'ニャ').replace(/ナ/g, 'ニャ'); } diff --git a/src/models/notification.ts b/src/models/notification.ts index 57be4bef10..b385a1ed72 100644 --- a/src/models/notification.ts +++ b/src/models/notification.ts @@ -1,6 +1,7 @@ import * as mongo from 'mongodb'; const deepcopy = require('deepcopy'); import db from '../db/mongodb'; +import isObjectId from '../misc/is-objectid'; import { IUser, pack as packUser } from './user'; import { pack as packNote } from './note'; @@ -57,7 +58,7 @@ export async function deleteNotification(notification: string | mongo.ObjectID | let n: INotification; // Populate - if (mongo.ObjectID.prototype.isPrototypeOf(notification)) { + if (isObjectId(notification)) { n = await Notification.findOne({ _id: notification }); @@ -90,7 +91,7 @@ export const pack = (notification: any) => new Promise<any>(async (resolve, reje let _notification: any; // Populate the notification if 'notification' is ID - if (mongo.ObjectID.prototype.isPrototypeOf(notification)) { + if (isObjectId(notification)) { _notification = await Notification.findOne({ _id: notification }); @@ -132,7 +133,7 @@ export const pack = (notification: any) => new Promise<any>(async (resolve, reje // (データベースの不具合などで)投稿が見つからなかったら if (_notification.note == null) { - console.warn(`in packaging notification: note not found on database: ${_notification.noteId}`); + console.warn(`[DAMAGED DB] (missing) pkg: notification -> note :: ${_notification.id} (note ${_notification.noteId})`); return resolve(null); } break; diff --git a/src/models/poll-vote.ts b/src/models/poll-vote.ts index 85c8454ddc..f9faa8124d 100644 --- a/src/models/poll-vote.ts +++ b/src/models/poll-vote.ts @@ -1,5 +1,6 @@ import * as mongo from 'mongodb'; import db from '../db/mongodb'; +import isObjectId from '../misc/is-objectid'; const PollVote = db.get<IPollVote>('pollVotes'); export default PollVote; @@ -19,7 +20,7 @@ export async function deletePollVote(pollVote: string | mongo.ObjectID | IPollVo let p: IPollVote; // Populate - if (mongo.ObjectID.prototype.isPrototypeOf(pollVote)) { + if (isObjectId(pollVote)) { p = await PollVote.findOne({ _id: pollVote }); diff --git a/src/models/stats.ts b/src/models/stats.ts deleted file mode 100644 index 492784555e..0000000000 --- a/src/models/stats.ts +++ /dev/null @@ -1,228 +0,0 @@ -import * as mongo from 'mongodb'; -import db from '../db/mongodb'; - -const Stats = db.get<IStats>('stats'); - -Stats.createIndex({ span: -1, date: -1 }, { unique: true }); -export default Stats; - -export interface IStats { - _id: mongo.ObjectID; - - /** - * 集計日時 - */ - date: Date; - - /** - * 集計期間 - */ - span: 'day' | 'hour'; - - /** - * ユーザーに関する統計 - */ - users: { - local: { - /** - * 集計期間時点での、全ユーザー数 (ローカル) - */ - total: number; - - /** - * 増加したユーザー数 (ローカル) - */ - inc: number; - - /** - * 減少したユーザー数 (ローカル) - */ - dec: number; - }; - - remote: { - /** - * 集計期間時点での、全ユーザー数 (リモート) - */ - total: number; - - /** - * 増加したユーザー数 (リモート) - */ - inc: number; - - /** - * 減少したユーザー数 (リモート) - */ - dec: number; - }; - }; - - /** - * 投稿に関する統計 - */ - notes: { - local: { - /** - * 集計期間時点での、全投稿数 (ローカル) - */ - total: number; - - /** - * 増加した投稿数 (ローカル) - */ - inc: number; - - /** - * 減少した投稿数 (ローカル) - */ - dec: number; - - diffs: { - /** - * 通常の投稿数の差分 (ローカル) - */ - normal: number; - - /** - * リプライの投稿数の差分 (ローカル) - */ - reply: number; - - /** - * Renoteの投稿数の差分 (ローカル) - */ - renote: number; - }; - }; - - remote: { - /** - * 集計期間時点での、全投稿数 (リモート) - */ - total: number; - - /** - * 増加した投稿数 (リモート) - */ - inc: number; - - /** - * 減少した投稿数 (リモート) - */ - dec: number; - - diffs: { - /** - * 通常の投稿数の差分 (リモート) - */ - normal: number; - - /** - * リプライの投稿数の差分 (リモート) - */ - reply: number; - - /** - * Renoteの投稿数の差分 (リモート) - */ - renote: number; - }; - }; - }; - - /** - * ドライブ(のファイル)に関する統計 - */ - drive: { - local: { - /** - * 集計期間時点での、全ドライブファイル数 (ローカル) - */ - totalCount: number; - - /** - * 集計期間時点での、全ドライブファイルの合計サイズ (ローカル) - */ - totalSize: number; - - /** - * 増加したドライブファイル数 (ローカル) - */ - incCount: number; - - /** - * 増加したドライブ使用量 (ローカル) - */ - incSize: number; - - /** - * 減少したドライブファイル数 (ローカル) - */ - decCount: number; - - /** - * 減少したドライブ使用量 (ローカル) - */ - decSize: number; - }; - - remote: { - /** - * 集計期間時点での、全ドライブファイル数 (リモート) - */ - totalCount: number; - - /** - * 集計期間時点での、全ドライブファイルの合計サイズ (リモート) - */ - totalSize: number; - - /** - * 増加したドライブファイル数 (リモート) - */ - incCount: number; - - /** - * 増加したドライブ使用量 (リモート) - */ - incSize: number; - - /** - * 減少したドライブファイル数 (リモート) - */ - decCount: number; - - /** - * 減少したドライブ使用量 (リモート) - */ - decSize: number; - }; - }; - - /** - * ネットワークに関する統計 - */ - network: { - /** - * サーバーへのリクエスト数 - */ - requests: number; - - /** - * 応答時間の合計 - * TIP: (totalTime / requests) でひとつのリクエストに平均でどれくらいの時間がかかったか知れる - */ - totalTime: number; - - /** - * 合計受信データ量 - */ - incomingBytes: number; - - /** - * 合計送信データ量 - */ - outgoingBytes: number; - }; -} diff --git a/src/models/sw-subscription.ts b/src/models/sw-subscription.ts index a38edd3a50..baeccc28d5 100644 --- a/src/models/sw-subscription.ts +++ b/src/models/sw-subscription.ts @@ -1,5 +1,6 @@ import * as mongo from 'mongodb'; import db from '../db/mongodb'; +import isObjectId from '../misc/is-objectid'; const SwSubscription = db.get<ISwSubscription>('swSubscriptions'); export default SwSubscription; @@ -19,7 +20,7 @@ export async function deleteSwSubscription(swSubscription: string | mongo.Object let s: ISwSubscription; // Populate - if (mongo.ObjectID.prototype.isPrototypeOf(swSubscription)) { + if (isObjectId(swSubscription)) { s = await SwSubscription.findOne({ _id: swSubscription }); diff --git a/src/models/user-list.ts b/src/models/user-list.ts index 5cfa7e4dfc..9e0be6a944 100644 --- a/src/models/user-list.ts +++ b/src/models/user-list.ts @@ -1,6 +1,7 @@ import * as mongo from 'mongodb'; const deepcopy = require('deepcopy'); import db from '../db/mongodb'; +import isObjectId from '../misc/is-objectid'; const UserList = db.get<IUserList>('userList'); export default UserList; @@ -20,7 +21,7 @@ export async function deleteUserList(userList: string | mongo.ObjectID | IUserLi let u: IUserList; // Populate - if (mongo.ObjectID.prototype.isPrototypeOf(userList)) { + if (isObjectId(userList)) { u = await UserList.findOne({ _id: userList }); @@ -45,7 +46,7 @@ export const pack = ( ) => new Promise<any>(async (resolve, reject) => { let _userList: any; - if (mongo.ObjectID.prototype.isPrototypeOf(userList)) { + if (isObjectId(userList)) { _userList = await UserList.findOne({ _id: userList }); diff --git a/src/models/user.ts b/src/models/user.ts index e0ce561421..25c4a9eb0f 100644 --- a/src/models/user.ts +++ b/src/models/user.ts @@ -3,6 +3,7 @@ const deepcopy = require('deepcopy'); const sequential = require('promise-sequential'); import rap from '@prezzemolo/rap'; import db from '../db/mongodb'; +import isObjectId from '../misc/is-objectid'; import Note, { packMany as packNoteMany, deleteNote } from './note'; import Following, { deleteFollowing } from './following'; import Mute, { deleteMute } from './mute'; @@ -17,8 +18,6 @@ import MessagingHistory, { deleteMessagingHistory } from './messaging-history'; import DriveFile, { deleteDriveFile } from './drive-file'; import DriveFolder, { deleteDriveFolder } from './drive-folder'; import PollVote, { deletePollVote } from './poll-vote'; -import FollowingLog, { deleteFollowingLog } from './following-log'; -import FollowedLog, { deleteFollowedLog } from './followed-log'; import SwSubscription, { deleteSwSubscription } from './sw-subscription'; import Notification, { deleteNotification } from './notification'; import UserList, { deleteUserList } from './user-list'; @@ -66,6 +65,16 @@ type IUserBase = { isLocked: boolean; /** + * Botか否か + */ + isBot: boolean; + + /** + * Botからのフォローを承認制にするか + */ + carefulBot: boolean; + + /** * このアカウントに届いているフォローリクエストの数 */ pendingReceivedFollowRequestsCount: number; @@ -94,7 +103,6 @@ export interface ILocalUser extends IUserBase { tags: string[]; }; lastUsedAt: Date; - isBot: boolean; isCat: boolean; isAdmin?: boolean; isVerified?: boolean; @@ -166,7 +174,7 @@ export async function deleteUser(user: string | mongo.ObjectID | IUser) { let u: IUser; // Populate - if (mongo.ObjectID.prototype.isPrototypeOf(user)) { + if (isObjectId(user)) { u = await User.findOne({ _id: user }); @@ -267,16 +275,6 @@ export async function deleteUser(user: string | mongo.ObjectID | IUser) { await FollowRequest.find({ followeeId: u._id }) ).map(x => deleteFollowRequest(x))); - // このユーザーのFollowingLogをすべて削除 - await Promise.all(( - await FollowingLog.find({ userId: u._id }) - ).map(x => deleteFollowingLog(x))); - - // このユーザーのFollowedLogをすべて削除 - await Promise.all(( - await FollowedLog.find({ userId: u._id }) - ).map(x => deleteFollowedLog(x))); - // このユーザーのSwSubscriptionをすべて削除 await Promise.all(( await SwSubscription.find({ userId: u._id }) @@ -331,7 +329,6 @@ export const pack = ( includeHasUnreadNotes?: boolean } ) => new Promise<any>(async (resolve, reject) => { - const opts = Object.assign({ detail: false, includeSecrets: false @@ -349,7 +346,7 @@ export const pack = ( }; // Populate the user if 'user' is ID - if (mongo.ObjectID.prototype.isPrototypeOf(user)) { + if (isObjectId(user)) { _user = await User.findOne({ _id: user }, { fields }); @@ -369,7 +366,7 @@ export const pack = ( // Me const meId: mongo.ObjectID = me - ? mongo.ObjectID.prototype.isPrototypeOf(me) + ? isObjectId(me) ? me as mongo.ObjectID : typeof me === 'string' ? new mongo.ObjectID(me) diff --git a/src/remote/activitypub/kernel/follow.ts b/src/remote/activitypub/kernel/follow.ts index 464f8582b7..e2db70b20d 100644 --- a/src/remote/activitypub/kernel/follow.ts +++ b/src/remote/activitypub/kernel/follow.ts @@ -23,5 +23,5 @@ export default async (actor: IRemoteUser, activity: IFollow): Promise<void> => { throw new Error('フォローしようとしているユーザーはローカルユーザーではありません'); } - await follow(actor, followee); + await follow(actor, followee, activity.id); }; diff --git a/src/remote/activitypub/models/image.ts b/src/remote/activitypub/models/image.ts index 8b33187ef5..76e1bac901 100644 --- a/src/remote/activitypub/models/image.ts +++ b/src/remote/activitypub/models/image.ts @@ -2,7 +2,7 @@ import * as debug from 'debug'; import uploadFromUrl from '../../../services/drive/upload-from-url'; import { IRemoteUser } from '../../../models/user'; -import { IDriveFile } from '../../../models/drive-file'; +import DriveFile, { IDriveFile } from '../../../models/drive-file'; import Resolver from '../resolver'; const log = debug('misskey:activitypub'); @@ -24,7 +24,24 @@ export async function createImage(actor: IRemoteUser, value: any): Promise<IDriv log(`Creating the Image: ${image.url}`); - return await uploadFromUrl(image.url, actor, null, image.url, image.sensitive); + let file = await uploadFromUrl(image.url, actor, null, image.url, image.sensitive); + + if (file.metadata.isRemote) { + // URLが異なっている場合、同じ画像が以前に異なるURLで登録されていたということなので、 + // URLを更新する + if (file.metadata.url !== image.url) { + file = await DriveFile.findOneAndUpdate({ _id: file._id }, { + $set: { + 'metadata.url': image.url, + 'metadata.uri': image.url + } + }, { + returnNewDocument: true + }); + } + } + + return file; } /** diff --git a/src/remote/activitypub/models/person.ts b/src/remote/activitypub/models/person.ts index ee95e43ad3..f115dee87d 100644 --- a/src/remote/activitypub/models/person.ts +++ b/src/remote/activitypub/models/person.ts @@ -10,9 +10,11 @@ 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 usersChart from '../../../chart/users'; import { URL } from 'url'; import { resolveNote } from './note'; +import registerInstance from '../../../services/register-instance'; +import Instance from '../../../models/instance'; const log = debug('misskey:activitypub'); @@ -173,6 +175,18 @@ export async function createPerson(uri: string, resolver?: Resolver): Promise<IU throw e; } + // Register host + registerInstance(host).then(i => { + Instance.update({ _id: i._id }, { + $inc: { + usersCount: 1 + } + }); + + // TODO + //perInstanceChart.newUser(); + }); + //#region Increment users count Meta.update({}, { $inc: { @@ -180,7 +194,7 @@ export async function createPerson(uri: string, resolver?: Resolver): Promise<IU } }, { upsert: true }); - updateUserStats(user, true); + usersChart.update(user, true); //#endregion //#region アイコンとヘッダー画像をフェッチ @@ -190,7 +204,7 @@ export async function createPerson(uri: string, resolver?: Resolver): Promise<IU ].map(img => img == null ? Promise.resolve(null) - : resolveImage(user, img) + : resolveImage(user, img).catch(() => null) ))); const avatarId = avatar ? avatar._id : null; @@ -214,6 +228,7 @@ export async function createPerson(uri: string, resolver?: Resolver): Promise<IU //#endregion await updateFeatured(user._id).catch(err => console.log(err)); + return user; } @@ -276,7 +291,7 @@ export async function updatePerson(uri: string, resolver?: Resolver, hint?: obje ].map(img => img == null ? Promise.resolve(null) - : resolveImage(exist, img) + : resolveImage(exist, img).catch(() => null) ))); // Update user diff --git a/src/remote/activitypub/renderer/accept.ts b/src/remote/activitypub/renderer/accept.ts index 76326285fd..fdbdff3f12 100644 --- a/src/remote/activitypub/renderer/accept.ts +++ b/src/remote/activitypub/renderer/accept.ts @@ -1,4 +1,8 @@ -export default (object: any) => ({ +import config from '../../../config'; +import { ILocalUser } from '../../../models/user'; + +export default (object: any, user: ILocalUser) => ({ type: 'Accept', + actor: `${config.url}/users/${user._id}`, object }); diff --git a/src/remote/activitypub/renderer/follow.ts b/src/remote/activitypub/renderer/follow.ts index 522422bcff..98d4cdd020 100644 --- a/src/remote/activitypub/renderer/follow.ts +++ b/src/remote/activitypub/renderer/follow.ts @@ -1,8 +1,14 @@ import config from '../../../config'; import { IUser, isLocalUser } from '../../../models/user'; -export default (follower: IUser, followee: IUser) => ({ - type: 'Follow', - actor: isLocalUser(follower) ? `${config.url}/users/${follower._id}` : follower.uri, - object: isLocalUser(followee) ? `${config.url}/users/${followee._id}` : followee.uri -}); +export default (follower: IUser, followee: IUser, requestId?: string) => { + const follow = { + type: 'Follow', + actor: isLocalUser(follower) ? `${config.url}/users/${follower._id}` : follower.uri, + object: isLocalUser(followee) ? `${config.url}/users/${followee._id}` : followee.uri + } as any; + + if (requestId) follow.id = requestId; + + return follow; +}; diff --git a/src/remote/activitypub/renderer/reject.ts b/src/remote/activitypub/renderer/reject.ts index 2464486c2f..6d7d23708a 100644 --- a/src/remote/activitypub/renderer/reject.ts +++ b/src/remote/activitypub/renderer/reject.ts @@ -1,4 +1,8 @@ -export default (object: any) => ({ +import config from '../../../config'; +import { ILocalUser } from '../../../models/user'; + +export default (object: any, user: ILocalUser) => ({ type: 'Reject', + actor: `${config.url}/users/${user._id}`, object }); diff --git a/src/remote/activitypub/resolver.ts b/src/remote/activitypub/resolver.ts index ff26971758..8ec0d125a0 100644 --- a/src/remote/activitypub/resolver.ts +++ b/src/remote/activitypub/resolver.ts @@ -51,6 +51,7 @@ export default class Resolver { const object = await request({ url: value, + proxy: config.proxy, timeout: this.timeout, headers: { 'User-Agent': config.user_agent, diff --git a/src/server/activitypub.ts b/src/server/activitypub.ts index adbc6639fa..8da933a0f6 100644 --- a/src/server/activitypub.ts +++ b/src/server/activitypub.ts @@ -66,7 +66,7 @@ router.get('/notes/:note', async (ctx, next) => { const note = await Note.findOne({ _id: new mongo.ObjectID(ctx.params.note), - $or: [ { visibility: 'public' }, { visibility: 'home' } ] + visibility: { $in: ['public', 'home'] } }); if (note === null) { diff --git a/src/server/api/call.ts b/src/server/api/call.ts index 7419bdc95d..20cb1ab7d5 100644 --- a/src/server/api/call.ts +++ b/src/server/api/call.ts @@ -62,7 +62,7 @@ export default (endpoint: string, user: IUser, app: IApp, data: any, file?: any) console.warn(`SLOW API CALL DETECTED: ${ep.name} (${time}ms)`); } } catch (e) { - rej(e); + rej(e.message); return; } diff --git a/src/server/api/common/read-messaging-message.ts b/src/server/api/common/read-messaging-message.ts index 075e369832..63080d22a4 100644 --- a/src/server/api/common/read-messaging-message.ts +++ b/src/server/api/common/read-messaging-message.ts @@ -1,4 +1,5 @@ import * as mongo from 'mongodb'; +import isObjectId from '../../../misc/is-objectid'; import Message from '../../../models/messaging-message'; import { IMessagingMessage as IMessage } from '../../../models/messaging-message'; import { publishMainStream } from '../../../stream'; @@ -15,21 +16,21 @@ export default ( message: string | string[] | IMessage | IMessage[] | mongo.ObjectID | mongo.ObjectID[] ) => new Promise<any>(async (resolve, reject) => { - const userId = mongo.ObjectID.prototype.isPrototypeOf(user) + const userId = isObjectId(user) ? user : new mongo.ObjectID(user); - const otherpartyId = mongo.ObjectID.prototype.isPrototypeOf(otherparty) + const otherpartyId = isObjectId(otherparty) ? otherparty : new mongo.ObjectID(otherparty); const ids: mongo.ObjectID[] = Array.isArray(message) - ? mongo.ObjectID.prototype.isPrototypeOf(message[0]) + ? isObjectId(message[0]) ? (message as mongo.ObjectID[]) : typeof message[0] === 'string' ? (message as string[]).map(m => new mongo.ObjectID(m)) : (message as IMessage[]).map(m => m._id) - : mongo.ObjectID.prototype.isPrototypeOf(message) + : isObjectId(message) ? [(message as mongo.ObjectID)] : typeof message === 'string' ? [new mongo.ObjectID(message)] diff --git a/src/server/api/common/read-notification.ts b/src/server/api/common/read-notification.ts index 2d58ada4ce..27d3f1be32 100644 --- a/src/server/api/common/read-notification.ts +++ b/src/server/api/common/read-notification.ts @@ -1,4 +1,5 @@ import * as mongo from 'mongodb'; +import isObjectId from '../../../misc/is-objectid'; import { default as Notification, INotification } from '../../../models/notification'; import { publishMainStream } from '../../../stream'; import Mute from '../../../models/mute'; @@ -12,17 +13,17 @@ export default ( message: string | string[] | INotification | INotification[] | mongo.ObjectID | mongo.ObjectID[] ) => new Promise<any>(async (resolve, reject) => { - const userId = mongo.ObjectID.prototype.isPrototypeOf(user) + const userId = isObjectId(user) ? user : new mongo.ObjectID(user); const ids: mongo.ObjectID[] = Array.isArray(message) - ? mongo.ObjectID.prototype.isPrototypeOf(message[0]) + ? isObjectId(message[0]) ? (message as mongo.ObjectID[]) : typeof message[0] === 'string' ? (message as string[]).map(m => new mongo.ObjectID(m)) : (message as INotification[]).map(m => m._id) - : mongo.ObjectID.prototype.isPrototypeOf(message) + : isObjectId(message) ? [(message as mongo.ObjectID)] : typeof message === 'string' ? [new mongo.ObjectID(message)] diff --git a/src/server/api/common/signin.ts b/src/server/api/common/signin.ts index 44e1336f27..8d44b377fe 100644 --- a/src/server/api/common/signin.ts +++ b/src/server/api/common/signin.ts @@ -8,7 +8,9 @@ export default function(ctx: Koa.Context, user: ILocalUser, redirect = false) { ctx.cookies.set('i', user.token, { path: '/', domain: config.hostname, - secure: config.url.startsWith('https'), + // SEE: https://github.com/koajs/koa/issues/974 + //secure: config.url.startsWith('https'), + secure: false, httpOnly: false, expires: new Date(Date.now() + expires), maxAge: expires diff --git a/src/server/api/endpoints.ts b/src/server/api/endpoints.ts index 2b00094269..6e5ca90c63 100644 --- a/src/server/api/endpoints.ts +++ b/src/server/api/endpoints.ts @@ -2,6 +2,8 @@ import * as path from 'path'; import * as glob from 'glob'; export interface IEndpointMeta { + stability?: 'deprecated' | 'experimental' | 'stable'; + desc?: any; params?: any; diff --git a/src/server/api/endpoints/aggregation/users/followers.ts b/src/server/api/endpoints/aggregation/users/followers.ts deleted file mode 100644 index 94eb83febc..0000000000 --- a/src/server/api/endpoints/aggregation/users/followers.ts +++ /dev/null @@ -1,61 +0,0 @@ -/** - * Module dependencies - */ -import $ from 'cafy'; import ID from '../../../../../misc/cafy-id'; -import User from '../../../../../models/user'; -import FollowedLog from '../../../../../models/followed-log'; - -/** - * Aggregate followers of a user - */ -export default (params: any) => new Promise(async (res, rej) => { - // Get 'userId' parameter - const [userId, userIdErr] = $.type(ID).get(params.userId); - if (userIdErr) return rej('invalid userId param'); - - // Lookup user - const user = await User.findOne({ - _id: userId - }, { - fields: { - _id: true - } - }); - - if (user === null) { - return rej('user not found'); - } - - const today = new Date(); - const graph = []; - - today.setMinutes(0); - today.setSeconds(0); - today.setMilliseconds(0); - - let cursorDate = new Date(today.getTime()); - let cursorTime = cursorDate.setDate(new Date(today.getTime()).getDate() + 1); - - for (let i = 0; i < 30; i++) { - graph.push(FollowedLog.findOne({ - createdAt: { $lt: new Date(cursorTime / 1000) }, - userId: user._id - }, { - sort: { createdAt: -1 }, - }).then(log => { - cursorDate = new Date(today.getTime()); - cursorTime = cursorDate.setDate(today.getDate() - i); - - return { - date: { - year: cursorDate.getFullYear(), - month: cursorDate.getMonth() + 1, // In JavaScript, month is zero-based. - day: cursorDate.getDate() - }, - count: log ? log.count : 0 - }; - })); - } - - res(await Promise.all(graph)); -}); diff --git a/src/server/api/endpoints/aggregation/users/following.ts b/src/server/api/endpoints/aggregation/users/following.ts deleted file mode 100644 index d2e4d256fe..0000000000 --- a/src/server/api/endpoints/aggregation/users/following.ts +++ /dev/null @@ -1,61 +0,0 @@ -/** - * Module dependencies - */ -import $ from 'cafy'; import ID from '../../../../../misc/cafy-id'; -import User from '../../../../../models/user'; -import FollowingLog from '../../../../../models/following-log'; - -/** - * Aggregate following of a user - */ -export default (params: any) => new Promise(async (res, rej) => { - // Get 'userId' parameter - const [userId, userIdErr] = $.type(ID).get(params.userId); - if (userIdErr) return rej('invalid userId param'); - - // Lookup user - const user = await User.findOne({ - _id: userId - }, { - fields: { - _id: true - } - }); - - if (user === null) { - return rej('user not found'); - } - - const today = new Date(); - const graph = []; - - today.setMinutes(0); - today.setSeconds(0); - today.setMilliseconds(0); - - let cursorDate = new Date(today.getTime()); - let cursorTime = cursorDate.setDate(new Date(today.getTime()).getDate() + 1); - - for (let i = 0; i < 30; i++) { - graph.push(FollowingLog.findOne({ - createdAt: { $lt: new Date(cursorTime / 1000) }, - userId: user._id - }, { - sort: { createdAt: -1 }, - }).then(log => { - cursorDate = new Date(today.getTime()); - cursorTime = cursorDate.setDate(today.getDate() - i); - - return { - date: { - year: cursorDate.getFullYear(), - month: cursorDate.getMonth() + 1, // In JavaScript, month is zero-based. - day: cursorDate.getDate() - }, - count: log ? log.count : 0 - }; - })); - } - - res(await Promise.all(graph)); -}); diff --git a/src/server/api/endpoints/ap/show.ts b/src/server/api/endpoints/ap/show.ts index 1f390d01aa..6cbd4ef87e 100644 --- a/src/server/api/endpoints/ap/show.ts +++ b/src/server/api/endpoints/ap/show.ts @@ -24,15 +24,15 @@ export const meta = { }, }; -export default (params: any) => new Promise(async (res, rej) => { +export default async (params: any) => { const [ps, psErr] = getParams(meta, params); - if (psErr) return rej(psErr); + if (psErr) throw psErr; const object = await fetchAny(ps.uri); - if (object !== null) return res(object); + if (object !== null) return object; - return rej('object not found'); -}); + throw new Error('object not found'); +}; /*** * URIからUserかNoteを解決する diff --git a/src/server/api/endpoints/chart.ts b/src/server/api/endpoints/chart.ts deleted file mode 100644 index 3b1a3b56fc..0000000000 --- a/src/server/api/endpoints/chart.ts +++ /dev/null @@ -1,277 +0,0 @@ -import $ from 'cafy'; -import Stats, { IStats } from '../../../models/stats'; -import getParams from '../get-params'; - -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 || - stat.users.remote.inc == null || - stat.users.remote.dec == null || - stat.notes.local.inc == null || - stat.notes.local.dec == null || - stat.notes.remote.inc == null || - stat.notes.remote.dec == null || - stat.drive.local.incCount == null || - stat.drive.local.decCount == null || - stat.drive.local.incSize == null || - stat.drive.local.decSize == null || - stat.drive.remote.incCount == null || - stat.drive.remote.decCount == null || - stat.drive.remote.incSize == null || - stat.drive.remote.decSize == null; - - if (!isOldData) return; - - stat.users.local.inc = (stat as any).users.local.diff; - stat.users.local.dec = 0; - stat.users.remote.inc = (stat as any).users.remote.diff; - stat.users.remote.dec = 0; - stat.notes.local.inc = (stat as any).notes.local.diff; - stat.notes.local.dec = 0; - stat.notes.remote.inc = (stat as any).notes.remote.diff; - stat.notes.remote.dec = 0; - stat.drive.local.incCount = (stat as any).drive.local.diffCount; - stat.drive.local.decCount = 0; - stat.drive.local.incSize = (stat as any).drive.local.diffSize; - stat.drive.local.decSize = 0; - stat.drive.remote.incCount = (stat as any).drive.remote.diffCount; - stat.drive.remote.decCount = 0; - stat.drive.remote.incSize = (stat as any).drive.remote.diffSize; - stat.drive.remote.decSize = 0; - }); -} - -export const meta = { - desc: { - 'ja-JP': 'インスタンスの統計を取得します。' - }, - - params: { - limit: $.num.optional.range(1, 100).note({ - default: 30, - desc: { - 'ja-JP': '最大数' - } - }), - } -}; - -export default (params: any) => new Promise(async (res, rej) => { - const [ps, psErr] = getParams(meta, params); - if (psErr) throw psErr; - - const daysRange = ps.limit; - const hoursRange = ps.limit; - - const now = new Date(); - const y = now.getFullYear(); - const m = now.getMonth(); - const d = now.getDate(); - const h = now.getHours(); - - const [statsPerDay, statsPerHour] = await Promise.all([ - Stats.find({ - span: 'day', - date: { - $gt: new Date(y, m, d - daysRange) - } - }, { - sort: { - date: -1 - }, - fields: { - _id: 0 - } - }), - Stats.find({ - span: 'hour', - date: { - $gt: new Date(y, m, d, h - hoursRange) - } - }, { - sort: { - date: -1 - }, - fields: { - _id: 0 - } - }), - ]); - - // 後方互換性のため - migrateStats(statsPerDay); - migrateStats(statsPerHour); - - const format = (src: IStats[], span: 'day' | 'hour') => { - const chart: Array<Omit<Omit<IStats, '_id'>, 'span'>> = []; - - const range = - span == 'day' ? daysRange : - span == 'hour' ? hoursRange : - null; - - for (let i = (range - 1); i >= 0; i--) { - const current = - span == 'day' ? new Date(y, m, d - i) : - span == 'hour' ? new Date(y, m, d, h - i) : - null; - - const stat = src.find(s => s.date.getTime() == current.getTime()); - - if (stat) { - chart.unshift(stat); - } else { // 隙間埋め - const mostRecent = src.find(s => s.date.getTime() < current.getTime()); - if (mostRecent) { - chart.unshift({ - date: current, - users: { - local: { - total: mostRecent.users.local.total, - inc: 0, - dec: 0 - }, - remote: { - total: mostRecent.users.remote.total, - inc: 0, - dec: 0 - } - }, - notes: { - local: { - total: mostRecent.notes.local.total, - inc: 0, - dec: 0, - diffs: { - normal: 0, - reply: 0, - renote: 0 - } - }, - remote: { - total: mostRecent.notes.remote.total, - inc: 0, - dec: 0, - diffs: { - normal: 0, - reply: 0, - renote: 0 - } - } - }, - drive: { - local: { - totalCount: mostRecent.drive.local.totalCount, - totalSize: mostRecent.drive.local.totalSize, - incCount: 0, - incSize: 0, - decCount: 0, - decSize: 0 - }, - remote: { - totalCount: mostRecent.drive.remote.totalCount, - totalSize: mostRecent.drive.remote.totalSize, - incCount: 0, - incSize: 0, - decCount: 0, - decSize: 0 - } - }, - network: { - requests: 0, - totalTime: 0, - incomingBytes: 0, - outgoingBytes: 0 - } - }); - } else { - chart.unshift({ - date: current, - users: { - local: { - total: 0, - inc: 0, - dec: 0 - }, - remote: { - total: 0, - inc: 0, - dec: 0 - } - }, - notes: { - local: { - total: 0, - inc: 0, - dec: 0, - diffs: { - normal: 0, - reply: 0, - renote: 0 - } - }, - remote: { - total: 0, - inc: 0, - dec: 0, - diffs: { - normal: 0, - reply: 0, - renote: 0 - } - } - }, - drive: { - local: { - totalCount: 0, - totalSize: 0, - incCount: 0, - incSize: 0, - decCount: 0, - decSize: 0 - }, - remote: { - totalCount: 0, - totalSize: 0, - incCount: 0, - incSize: 0, - decCount: 0, - decSize: 0 - } - }, - network: { - requests: 0, - totalTime: 0, - incomingBytes: 0, - outgoingBytes: 0 - } - }); - } - } - } - - chart.forEach(x => { - delete (x as any).span; - }); - - return chart; - }; - - res({ - perDay: format(statsPerDay, 'day'), - perHour: format(statsPerHour, 'hour') - }); -}); diff --git a/src/server/api/endpoints/charts/drive.ts b/src/server/api/endpoints/charts/drive.ts new file mode 100644 index 0000000000..dc18331847 --- /dev/null +++ b/src/server/api/endpoints/charts/drive.ts @@ -0,0 +1,33 @@ +import $ from 'cafy'; +import getParams from '../../get-params'; +import driveChart from '../../../../chart/drive'; + +export const meta = { + desc: { + 'ja-JP': 'ドライブのチャートを取得します。' + }, + + params: { + span: $.str.or(['day', 'hour']).note({ + desc: { + 'ja-JP': '集計のスパン (day または hour)' + } + }), + + limit: $.num.optional.range(1, 100).note({ + default: 30, + desc: { + 'ja-JP': '最大数。例えば 30 を指定したとすると、スパンが"day"の場合は30日分のデータが、スパンが"hour"の場合は30時間分のデータが返ります。' + } + }), + } +}; + +export default (params: any) => new Promise(async (res, rej) => { + const [ps, psErr] = getParams(meta, params); + if (psErr) return rej(psErr); + + const stats = await driveChart.getChart(ps.span as any, ps.limit); + + res(stats); +}); diff --git a/src/server/api/endpoints/charts/federation.ts b/src/server/api/endpoints/charts/federation.ts new file mode 100644 index 0000000000..5b24783c69 --- /dev/null +++ b/src/server/api/endpoints/charts/federation.ts @@ -0,0 +1,33 @@ +import $ from 'cafy'; +import getParams from '../../get-params'; +import federationChart from '../../../../chart/federation'; + +export const meta = { + desc: { + 'ja-JP': 'フェデレーションのチャートを取得します。' + }, + + params: { + span: $.str.or(['day', 'hour']).note({ + desc: { + 'ja-JP': '集計のスパン (day または hour)' + } + }), + + limit: $.num.optional.range(1, 100).note({ + default: 30, + desc: { + 'ja-JP': '最大数。例えば 30 を指定したとすると、スパンが"day"の場合は30日分のデータが、スパンが"hour"の場合は30時間分のデータが返ります。' + } + }), + } +}; + +export default (params: any) => new Promise(async (res, rej) => { + const [ps, psErr] = getParams(meta, params); + if (psErr) return rej(psErr); + + const stats = await federationChart.getChart(ps.span as any, ps.limit); + + res(stats); +}); diff --git a/src/server/api/endpoints/charts/hashtag.ts b/src/server/api/endpoints/charts/hashtag.ts new file mode 100644 index 0000000000..bcd48dc485 --- /dev/null +++ b/src/server/api/endpoints/charts/hashtag.ts @@ -0,0 +1,39 @@ +import $ from 'cafy'; +import getParams from '../../get-params'; +import hashtagChart from '../../../../chart/hashtag'; + +export const meta = { + desc: { + 'ja-JP': 'ハッシュタグごとのチャートを取得します。' + }, + + params: { + span: $.str.or(['day', 'hour']).note({ + desc: { + 'ja-JP': '集計のスパン (day または hour)' + } + }), + + limit: $.num.optional.range(1, 100).note({ + default: 30, + desc: { + 'ja-JP': '最大数。例えば 30 を指定したとすると、スパンが"day"の場合は30日分のデータが、スパンが"hour"の場合は30時間分のデータが返ります。' + } + }), + + tag: $.str.note({ + desc: { + 'ja-JP': '対象のハッシュタグ' + } + }), + } +}; + +export default (params: any) => new Promise(async (res, rej) => { + const [ps, psErr] = getParams(meta, params); + if (psErr) return rej(psErr); + + const stats = await hashtagChart.getChart(ps.span as any, ps.limit, ps.tag); + + res(stats); +}); diff --git a/src/server/api/endpoints/charts/network.ts b/src/server/api/endpoints/charts/network.ts new file mode 100644 index 0000000000..d5b0791994 --- /dev/null +++ b/src/server/api/endpoints/charts/network.ts @@ -0,0 +1,33 @@ +import $ from 'cafy'; +import getParams from '../../get-params'; +import networkChart from '../../../../chart/network'; + +export const meta = { + desc: { + 'ja-JP': 'ネットワークのチャートを取得します。' + }, + + params: { + span: $.str.or(['day', 'hour']).note({ + desc: { + 'ja-JP': '集計のスパン (day または hour)' + } + }), + + limit: $.num.optional.range(1, 100).note({ + default: 30, + desc: { + 'ja-JP': '最大数。例えば 30 を指定したとすると、スパンが"day"の場合は30日分のデータが、スパンが"hour"の場合は30時間分のデータが返ります。' + } + }), + } +}; + +export default (params: any) => new Promise(async (res, rej) => { + const [ps, psErr] = getParams(meta, params); + if (psErr) return rej(psErr); + + const stats = await networkChart.getChart(ps.span as any, ps.limit); + + res(stats); +}); diff --git a/src/server/api/endpoints/charts/notes.ts b/src/server/api/endpoints/charts/notes.ts new file mode 100644 index 0000000000..573b012469 --- /dev/null +++ b/src/server/api/endpoints/charts/notes.ts @@ -0,0 +1,33 @@ +import $ from 'cafy'; +import getParams from '../../get-params'; +import notesChart from '../../../../chart/notes'; + +export const meta = { + desc: { + 'ja-JP': '投稿のチャートを取得します。' + }, + + params: { + span: $.str.or(['day', 'hour']).note({ + desc: { + 'ja-JP': '集計のスパン (day または hour)' + } + }), + + limit: $.num.optional.range(1, 100).note({ + default: 30, + desc: { + 'ja-JP': '最大数。例えば 30 を指定したとすると、スパンが"day"の場合は30日分のデータが、スパンが"hour"の場合は30時間分のデータが返ります。' + } + }), + } +}; + +export default (params: any) => new Promise(async (res, rej) => { + const [ps, psErr] = getParams(meta, params); + if (psErr) return rej(psErr); + + const stats = await notesChart.getChart(ps.span as any, ps.limit); + + res(stats); +}); diff --git a/src/server/api/endpoints/charts/user/drive.ts b/src/server/api/endpoints/charts/user/drive.ts new file mode 100644 index 0000000000..2626c36c9d --- /dev/null +++ b/src/server/api/endpoints/charts/user/drive.ts @@ -0,0 +1,41 @@ +import $ from 'cafy'; +import getParams from '../../../get-params'; +import perUserDriveChart from '../../../../../chart/per-user-drive'; +import ID from '../../../../../misc/cafy-id'; + +export const meta = { + desc: { + 'ja-JP': 'ユーザーごとのドライブのチャートを取得します。' + }, + + params: { + span: $.str.or(['day', 'hour']).note({ + desc: { + 'ja-JP': '集計のスパン (day または hour)' + } + }), + + limit: $.num.optional.range(1, 100).note({ + default: 30, + desc: { + 'ja-JP': '最大数。例えば 30 を指定したとすると、スパンが"day"の場合は30日分のデータが、スパンが"hour"の場合は30時間分のデータが返ります。' + } + }), + + userId: $.type(ID).note({ + desc: { + 'ja-JP': '対象のユーザーのID', + 'en-US': 'Target user ID' + } + }) + } +}; + +export default (params: any) => new Promise(async (res, rej) => { + const [ps, psErr] = getParams(meta, params); + if (psErr) return rej(psErr); + + const stats = await perUserDriveChart.getChart(ps.span as any, ps.limit, ps.userId); + + res(stats); +}); diff --git a/src/server/api/endpoints/charts/user/following.ts b/src/server/api/endpoints/charts/user/following.ts new file mode 100644 index 0000000000..57c15cdcfe --- /dev/null +++ b/src/server/api/endpoints/charts/user/following.ts @@ -0,0 +1,41 @@ +import $ from 'cafy'; +import getParams from '../../../get-params'; +import perUserFollowingChart from '../../../../../chart/per-user-following'; +import ID from '../../../../../misc/cafy-id'; + +export const meta = { + desc: { + 'ja-JP': 'ユーザーごとのフォロー/フォロワーのチャートを取得します。' + }, + + params: { + span: $.str.or(['day', 'hour']).note({ + desc: { + 'ja-JP': '集計のスパン (day または hour)' + } + }), + + limit: $.num.optional.range(1, 100).note({ + default: 30, + desc: { + 'ja-JP': '最大数。例えば 30 を指定したとすると、スパンが"day"の場合は30日分のデータが、スパンが"hour"の場合は30時間分のデータが返ります。' + } + }), + + userId: $.type(ID).note({ + desc: { + 'ja-JP': '対象のユーザーのID', + 'en-US': 'Target user ID' + } + }) + } +}; + +export default (params: any) => new Promise(async (res, rej) => { + const [ps, psErr] = getParams(meta, params); + if (psErr) return rej(psErr); + + const stats = await perUserFollowingChart.getChart(ps.span as any, ps.limit, ps.userId); + + res(stats); +}); diff --git a/src/server/api/endpoints/charts/user/notes.ts b/src/server/api/endpoints/charts/user/notes.ts new file mode 100644 index 0000000000..66051fa7c6 --- /dev/null +++ b/src/server/api/endpoints/charts/user/notes.ts @@ -0,0 +1,41 @@ +import $ from 'cafy'; +import getParams from '../../../get-params'; +import perUserNotesChart from '../../../../../chart/per-user-notes'; +import ID from '../../../../../misc/cafy-id'; + +export const meta = { + desc: { + 'ja-JP': 'ユーザーごとの投稿のチャートを取得します。' + }, + + params: { + span: $.str.or(['day', 'hour']).note({ + desc: { + 'ja-JP': '集計のスパン (day または hour)' + } + }), + + limit: $.num.optional.range(1, 100).note({ + default: 30, + desc: { + 'ja-JP': '最大数。例えば 30 を指定したとすると、スパンが"day"の場合は30日分のデータが、スパンが"hour"の場合は30時間分のデータが返ります。' + } + }), + + userId: $.type(ID).note({ + desc: { + 'ja-JP': '対象のユーザーのID', + 'en-US': 'Target user ID' + } + }) + } +}; + +export default (params: any) => new Promise(async (res, rej) => { + const [ps, psErr] = getParams(meta, params); + if (psErr) return rej(psErr); + + const stats = await perUserNotesChart.getChart(ps.span as any, ps.limit, ps.userId); + + res(stats); +}); diff --git a/src/server/api/endpoints/charts/user/reactions.ts b/src/server/api/endpoints/charts/user/reactions.ts new file mode 100644 index 0000000000..60cdaa70bb --- /dev/null +++ b/src/server/api/endpoints/charts/user/reactions.ts @@ -0,0 +1,41 @@ +import $ from 'cafy'; +import getParams from '../../../get-params'; +import perUserReactionsChart from '../../../../../chart/per-user-reactions'; +import ID from '../../../../../misc/cafy-id'; + +export const meta = { + desc: { + 'ja-JP': 'ユーザーごとの被リアクション数のチャートを取得します。' + }, + + params: { + span: $.str.or(['day', 'hour']).note({ + desc: { + 'ja-JP': '集計のスパン (day または hour)' + } + }), + + limit: $.num.optional.range(1, 100).note({ + default: 30, + desc: { + 'ja-JP': '最大数。例えば 30 を指定したとすると、スパンが"day"の場合は30日分のデータが、スパンが"hour"の場合は30時間分のデータが返ります。' + } + }), + + userId: $.type(ID).note({ + desc: { + 'ja-JP': '対象のユーザーのID', + 'en-US': 'Target user ID' + } + }) + } +}; + +export default (params: any) => new Promise(async (res, rej) => { + const [ps, psErr] = getParams(meta, params); + if (psErr) return rej(psErr); + + const stats = await perUserReactionsChart.getChart(ps.span as any, ps.limit, ps.userId); + + res(stats); +}); diff --git a/src/server/api/endpoints/charts/users.ts b/src/server/api/endpoints/charts/users.ts new file mode 100644 index 0000000000..595bb63f0b --- /dev/null +++ b/src/server/api/endpoints/charts/users.ts @@ -0,0 +1,33 @@ +import $ from 'cafy'; +import getParams from '../../get-params'; +import usersChart from '../../../../chart/users'; + +export const meta = { + desc: { + 'ja-JP': 'ユーザーのチャートを取得します。' + }, + + params: { + span: $.str.or(['day', 'hour']).note({ + desc: { + 'ja-JP': '集計のスパン (day または hour)' + } + }), + + limit: $.num.optional.range(1, 100).note({ + default: 30, + desc: { + 'ja-JP': '最大数。例えば 30 を指定したとすると、スパンが"day"の場合は30日分のデータが、スパンが"hour"の場合は30時間分のデータが返ります。' + } + }), + } +}; + +export default (params: any) => new Promise(async (res, rej) => { + const [ps, psErr] = getParams(meta, params); + if (psErr) return rej(psErr); + + const stats = await usersChart.getChart(ps.span as any, ps.limit); + + res(stats); +}); diff --git a/src/server/api/endpoints/drive/files/attached_notes.ts b/src/server/api/endpoints/drive/files/attached_notes.ts new file mode 100644 index 0000000000..1187169c64 --- /dev/null +++ b/src/server/api/endpoints/drive/files/attached_notes.ts @@ -0,0 +1,48 @@ +import $ from 'cafy'; import ID from '../../../../../misc/cafy-id'; +import DriveFile from '../../../../../models/drive-file'; +import { ILocalUser } from '../../../../../models/user'; +import getParams from '../../../get-params'; +import { packMany } from '../../../../../models/note'; + +export const meta = { + stability: 'stable', + + desc: { + 'ja-JP': '指定したドライブのファイルが添付されている投稿一覧を取得します。', + 'en-US': 'Get the notes that specified file of drive attached.' + }, + + requireCredential: true, + + kind: 'drive-read', + + params: { + fileId: $.type(ID).note({ + desc: { + 'ja-JP': '対象のファイルID', + 'en-US': 'Target file ID' + } + }) + } +}; + +export default (params: any, user: ILocalUser) => new Promise(async (res, rej) => { + const [ps, psErr] = getParams(meta, params); + if (psErr) return rej(psErr); + + // Fetch file + const file = await DriveFile + .findOne({ + _id: ps.fileId, + 'metadata.userId': user._id, + 'metadata.deletedAt': { $exists: false } + }); + + if (file === null) { + return rej('file-not-found'); + } + + res(await packMany(file.metadata.attachedNoteIds || [], user, { + detail: true + })); +}); diff --git a/src/server/api/endpoints/drive/files/check_existence.ts b/src/server/api/endpoints/drive/files/check_existence.ts index 73d75b7caf..a024701655 100644 --- a/src/server/api/endpoints/drive/files/check_existence.ts +++ b/src/server/api/endpoints/drive/files/check_existence.ts @@ -27,7 +27,8 @@ export default (params: any, user: ILocalUser) => new Promise(async (res, rej) = const file = await DriveFile.findOne({ md5: md5, - 'metadata.userId': user._id + 'metadata.userId': user._id, + 'metadata.deletedAt': { $exists: false } }); if (file === null) { diff --git a/src/server/api/endpoints/drive/files/create.ts b/src/server/api/endpoints/drive/files/create.ts index 4b5ffa90e0..29e65a7ad3 100644 --- a/src/server/api/endpoints/drive/files/create.ts +++ b/src/server/api/endpoints/drive/files/create.ts @@ -31,19 +31,23 @@ export const meta = { } }), - isSensitive: $.bool.optional.nullable.note({ - default: null, + isSensitive: $.bool.optional.note({ + default: false, desc: { 'ja-JP': 'このメディアが「閲覧注意」(NSFW)かどうか', 'en-US': 'Whether this media is NSFW' } + }), + + force: $.bool.optional.note({ + default: false, + desc: { + 'ja-JP': 'true にすると、同じハッシュを持つファイルが既にアップロードされていても強制的にファイルを作成します。', + } }) } }; -/** - * Create a file - */ export default async (file: any, params: any, user: ILocalUser): Promise<any> => { if (file == null) { throw 'file is required'; @@ -76,7 +80,7 @@ export default async (file: any, params: any, user: ILocalUser): Promise<any> => try { // Create file - const driveFile = await create(user, file.path, name, null, ps.folderId, false, false, null, null, ps.isSensitive); + const driveFile = await create(user, file.path, name, null, ps.folderId, ps.force, false, null, null, ps.isSensitive); cleanup(); diff --git a/src/server/api/endpoints/drive/files/delete.ts b/src/server/api/endpoints/drive/files/delete.ts index fb7340df38..fc6849e57e 100644 --- a/src/server/api/endpoints/drive/files/delete.ts +++ b/src/server/api/endpoints/drive/files/delete.ts @@ -3,8 +3,11 @@ import DriveFile from '../../../../../models/drive-file'; import del from '../../../../../services/drive/delete-file'; import { publishDriveStream } from '../../../../../stream'; import { ILocalUser } from '../../../../../models/user'; +import getParams from '../../../get-params'; export const meta = { + stability: 'stable', + desc: { 'ja-JP': 'ドライブのファイルを削除します。', 'en-US': 'Delete a file of drive.' @@ -12,30 +15,38 @@ export const meta = { requireCredential: true, - kind: 'drive-write' + kind: 'drive-write', + + params: { + fileId: $.type(ID).note({ + desc: { + 'ja-JP': '対象のファイルID', + 'en-US': 'Target file ID' + } + }) + } }; -export default async (params: any, user: ILocalUser) => { - // Get 'fileId' parameter - const [fileId, fileIdErr] = $.type(ID).get(params.fileId); - if (fileIdErr) throw 'invalid fileId param'; +export default (params: any, user: ILocalUser) => new Promise(async (res, rej) => { + const [ps, psErr] = getParams(meta, params); + if (psErr) return rej(psErr); // Fetch file const file = await DriveFile .findOne({ - _id: fileId, + _id: ps.fileId, 'metadata.userId': user._id }); if (file === null) { - throw 'file-not-found'; + return rej('file-not-found'); } // Delete await del(file); - // Publish file_deleted event - publishDriveStream(user._id, 'file_deleted', file._id); + // Publish fileDeleted event + publishDriveStream(user._id, 'fileDeleted', file._id); - return; -}; + res(); +}); diff --git a/src/server/api/endpoints/drive/files/show.ts b/src/server/api/endpoints/drive/files/show.ts index 718fb8c2d7..49d6027add 100644 --- a/src/server/api/endpoints/drive/files/show.ts +++ b/src/server/api/endpoints/drive/files/show.ts @@ -1,8 +1,11 @@ import $ from 'cafy'; import ID from '../../../../../misc/cafy-id'; import DriveFile, { pack } from '../../../../../models/drive-file'; import { ILocalUser } from '../../../../../models/user'; +import getParams from '../../../get-params'; export const meta = { + stability: 'stable', + desc: { 'ja-JP': '指定したドライブのファイルの情報を取得します。', 'en-US': 'Get specified file of drive.' @@ -10,23 +13,32 @@ export const meta = { requireCredential: true, - kind: 'drive-read' + kind: 'drive-read', + + params: { + fileId: $.type(ID).note({ + desc: { + 'ja-JP': '対象のファイルID', + 'en-US': 'Target file ID' + } + }) + } }; -export default async (params: any, user: ILocalUser) => { - // Get 'fileId' parameter - const [fileId, fileIdErr] = $.type(ID).get(params.fileId); - if (fileIdErr) throw 'invalid fileId param'; +export default (params: any, user: ILocalUser) => new Promise(async (res, rej) => { + const [ps, psErr] = getParams(meta, params); + if (psErr) return rej(psErr); // Fetch file const file = await DriveFile .findOne({ - _id: fileId, - 'metadata.userId': user._id + _id: ps.fileId, + 'metadata.userId': user._id, + 'metadata.deletedAt': { $exists: false } }); if (file === null) { - throw 'file-not-found'; + return rej('file-not-found'); } // Serialize @@ -34,5 +46,5 @@ export default async (params: any, user: ILocalUser) => { detail: true }); - return _file; -}; + res(_file); +}); diff --git a/src/server/api/endpoints/drive/files/update.ts b/src/server/api/endpoints/drive/files/update.ts index 3c7932c341..915cf4ceb2 100644 --- a/src/server/api/endpoints/drive/files/update.ts +++ b/src/server/api/endpoints/drive/files/update.ts @@ -100,8 +100,10 @@ export default (params: any, user: ILocalUser) => new Promise(async (res, rej) = }).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 + Note.update({ _id: note._id }, { + $set: { + _files: note._files + } }); }); }); @@ -112,6 +114,6 @@ export default (params: any, user: ILocalUser) => new Promise(async (res, rej) = // Response res(fileObj); - // Publish file_updated event - publishDriveStream(user._id, 'file_updated', fileObj); + // Publish fileUpdated event + publishDriveStream(user._id, 'fileUpdated', fileObj); }); diff --git a/src/server/api/endpoints/drive/folders/create.ts b/src/server/api/endpoints/drive/folders/create.ts index 5997dedf0f..cca25b0596 100644 --- a/src/server/api/endpoints/drive/folders/create.ts +++ b/src/server/api/endpoints/drive/folders/create.ts @@ -2,8 +2,11 @@ import $ from 'cafy'; import ID from '../../../../../misc/cafy-id'; import DriveFolder, { isValidFolderName, pack } from '../../../../../models/drive-folder'; import { publishDriveStream } from '../../../../../stream'; import { ILocalUser } from '../../../../../models/user'; +import getParams from '../../../get-params'; export const meta = { + stability: 'stable', + desc: { 'ja-JP': 'ドライブのフォルダを作成します。', 'en-US': 'Create a folder of drive.' @@ -11,25 +14,37 @@ export const meta = { requireCredential: true, - kind: 'drive-write' + kind: 'drive-write', + + params: { + name: $.str.optional.pipe(isValidFolderName).note({ + default: 'Untitled', + desc: { + 'ja-JP': 'フォルダ名', + 'en-US': 'Folder name' + } + }), + + parentId: $.type(ID).optional.nullable.note({ + desc: { + 'ja-JP': '親フォルダID', + 'en-US': 'Parent folder ID' + } + }) + } }; export default (params: any, user: ILocalUser) => new Promise(async (res, rej) => { - // Get 'name' parameter - const [name = '無題のフォルダー', nameErr] = $.str.optional.pipe(isValidFolderName).get(params.name); - if (nameErr) return rej('invalid name param'); - - // Get 'parentId' parameter - const [parentId = null, parentIdErr] = $.type(ID).optional.nullable.get(params.parentId); - if (parentIdErr) return rej('invalid parentId param'); + const [ps, psErr] = getParams(meta, params); + if (psErr) return rej(psErr); // If the parent folder is specified let parent = null; - if (parentId) { + if (ps.parentId) { // Fetch parent folder parent = await DriveFolder .findOne({ - _id: parentId, + _id: ps.parentId, userId: user._id }); @@ -41,7 +56,7 @@ export default (params: any, user: ILocalUser) => new Promise(async (res, rej) = // Create folder const folder = await DriveFolder.insert({ createdAt: new Date(), - name: name, + name: ps.name, parentId: parent !== null ? parent._id : null, userId: user._id }); @@ -52,6 +67,6 @@ export default (params: any, user: ILocalUser) => new Promise(async (res, rej) = // Response res(folderObj); - // Publish folder_created event - publishDriveStream(user._id, 'folder_created', folderObj); + // Publish folderCreated event + publishDriveStream(user._id, 'folderCreated', folderObj); }); diff --git a/src/server/api/endpoints/drive/folders/delete.ts b/src/server/api/endpoints/drive/folders/delete.ts new file mode 100644 index 0000000000..ece16ebed4 --- /dev/null +++ b/src/server/api/endpoints/drive/folders/delete.ts @@ -0,0 +1,60 @@ +import $ from 'cafy'; import ID from '../../../../../misc/cafy-id'; +import DriveFolder from '../../../../../models/drive-folder'; +import { ILocalUser } from '../../../../../models/user'; +import getParams from '../../../get-params'; +import { publishDriveStream } from '../../../../../stream'; +import DriveFile from '../../../../../models/drive-file'; + +export const meta = { + stability: 'stable', + + desc: { + 'ja-JP': '指定したドライブのフォルダを削除します。', + 'en-US': 'Delete specified folder of drive.' + }, + + requireCredential: true, + + kind: 'drive-write', + + params: { + folderId: $.type(ID).note({ + desc: { + 'ja-JP': '対象のフォルダID', + 'en-US': 'Target folder ID' + } + }) + } +}; + +export default (params: any, user: ILocalUser) => new Promise(async (res, rej) => { + const [ps, psErr] = getParams(meta, params); + if (psErr) return rej(psErr); + + // Get folder + const folder = await DriveFolder + .findOne({ + _id: ps.folderId, + userId: user._id + }); + + if (folder === null) { + return rej('folder-not-found'); + } + + const [childFoldersCount, childFilesCount] = await Promise.all([ + DriveFolder.count({ parentId: folder._id }), + DriveFile.count({ folderId: folder._id }) + ]); + + if (childFoldersCount !== 0 || childFilesCount !== 0) { + return rej('has-child-contents'); + } + + await DriveFolder.remove({ _id: folder._id }); + + // Publish folderCreated event + publishDriveStream(user._id, 'folderDeleted', folder._id); + + res(); +}); diff --git a/src/server/api/endpoints/drive/folders/show.ts b/src/server/api/endpoints/drive/folders/show.ts index bb25bcba3c..f01c75d957 100644 --- a/src/server/api/endpoints/drive/folders/show.ts +++ b/src/server/api/endpoints/drive/folders/show.ts @@ -1,26 +1,38 @@ import $ from 'cafy'; import ID from '../../../../../misc/cafy-id'; import DriveFolder, { pack } from '../../../../../models/drive-folder'; import { ILocalUser } from '../../../../../models/user'; +import getParams from '../../../get-params'; export const meta = { + stability: 'stable', + desc: { - 'ja-JP': '指定したドライブのフォルダの情報を取得します。' + 'ja-JP': '指定したドライブのフォルダの情報を取得します。', + 'en-US': 'Get specified folder of drive.' }, requireCredential: true, - kind: 'drive-read' + kind: 'drive-read', + + params: { + folderId: $.type(ID).note({ + desc: { + 'ja-JP': '対象のフォルダID', + 'en-US': 'Target folder ID' + } + }) + } }; export default (params: any, user: ILocalUser) => new Promise(async (res, rej) => { - // Get 'folderId' parameter - const [folderId, folderIdErr] = $.type(ID).get(params.folderId); - if (folderIdErr) return rej('invalid folderId param'); + const [ps, psErr] = getParams(meta, params); + if (psErr) return rej(psErr); // Get folder const folder = await DriveFolder .findOne({ - _id: folderId, + _id: ps.folderId, userId: user._id }); diff --git a/src/server/api/endpoints/drive/folders/update.ts b/src/server/api/endpoints/drive/folders/update.ts index 259f373bfc..b041a15920 100644 --- a/src/server/api/endpoints/drive/folders/update.ts +++ b/src/server/api/endpoints/drive/folders/update.ts @@ -2,8 +2,11 @@ import $ from 'cafy'; import ID from '../../../../../misc/cafy-id'; import DriveFolder, { isValidFolderName, pack } from '../../../../../models/drive-folder'; import { publishDriveStream } from '../../../../../stream'; import { ILocalUser } from '../../../../../models/user'; +import getParams from '../../../get-params'; export const meta = { + stability: 'stable', + desc: { 'ja-JP': '指定したドライブのフォルダの情報を更新します。', 'en-US': 'Update specified folder of drive.' @@ -11,18 +14,40 @@ export const meta = { requireCredential: true, - kind: 'drive-write' + kind: 'drive-write', + + params: { + folderId: $.type(ID).note({ + desc: { + 'ja-JP': '対象のフォルダID', + 'en-US': 'Target folder ID' + } + }), + + name: $.str.optional.pipe(isValidFolderName).note({ + desc: { + 'ja-JP': 'フォルダ名', + 'en-US': 'Folder name' + } + }), + + parentId: $.type(ID).optional.nullable.note({ + desc: { + 'ja-JP': '親フォルダID', + 'en-US': 'Parent folder ID' + } + }) + } }; export default (params: any, user: ILocalUser) => new Promise(async (res, rej) => { - // Get 'folderId' parameter - const [folderId, folderIdErr] = $.type(ID).get(params.folderId); - if (folderIdErr) return rej('invalid folderId param'); + const [ps, psErr] = getParams(meta, params); + if (psErr) return rej(psErr); // Fetch folder const folder = await DriveFolder .findOne({ - _id: folderId, + _id: ps.folderId, userId: user._id }); @@ -30,22 +55,16 @@ export default (params: any, user: ILocalUser) => new Promise(async (res, rej) = return rej('folder-not-found'); } - // Get 'name' parameter - const [name, nameErr] = $.str.optional.pipe(isValidFolderName).get(params.name); - if (nameErr) return rej('invalid name param'); - if (name) folder.name = name; + if (ps.name) folder.name = ps.name; - // Get 'parentId' parameter - const [parentId, parentIdErr] = $.type(ID).optional.nullable.get(params.parentId); - if (parentIdErr) return rej('invalid parentId param'); - if (parentId !== undefined) { - if (parentId === null) { + if (ps.parentId !== undefined) { + if (ps.parentId === null) { folder.parentId = null; } else { // Get parent folder const parent = await DriveFolder .findOne({ - _id: parentId, + _id: ps.parentId, userId: user._id }); @@ -96,6 +115,6 @@ export default (params: any, user: ILocalUser) => new Promise(async (res, rej) = // Response res(folderObj); - // Publish folder_updated event - publishDriveStream(user._id, 'folder_updated', folderObj); + // Publish folderUpdated event + publishDriveStream(user._id, 'folderUpdated', folderObj); }); diff --git a/src/server/api/endpoints/following/create.ts b/src/server/api/endpoints/following/create.ts index 00aa904f08..372bad0222 100644 --- a/src/server/api/endpoints/following/create.ts +++ b/src/server/api/endpoints/following/create.ts @@ -3,8 +3,11 @@ const ms = require('ms'); import User, { pack, ILocalUser } from '../../../../models/user'; import Following from '../../../../models/following'; import create from '../../../../services/following/create'; +import getParams from '../../get-params'; export const meta = { + stability: 'stable', + desc: { 'ja-JP': '指定したユーザーをフォローします。', 'en-US': 'Follow a user.' @@ -17,24 +20,32 @@ export const meta = { requireCredential: true, - kind: 'following-write' + kind: 'following-write', + + params: { + userId: $.type(ID).note({ + desc: { + 'ja-JP': '対象のユーザーのID', + 'en-US': 'Target user ID' + } + }) + } }; export default (params: any, user: ILocalUser) => new Promise(async (res, rej) => { - const follower = user; + const [ps, psErr] = getParams(meta, params); + if (psErr) return rej(psErr); - // Get 'userId' parameter - const [userId, userIdErr] = $.type(ID).get(params.userId); - if (userIdErr) return rej('invalid userId param'); + const follower = user; // 自分自身 - if (user._id.equals(userId)) { + if (user._id.equals(ps.userId)) { return rej('followee is yourself'); } // Get followee const followee = await User.findOne({ - _id: userId + _id: ps.userId }, { fields: { data: false, diff --git a/src/server/api/endpoints/following/delete.ts b/src/server/api/endpoints/following/delete.ts index cdfbf43cd1..0489c1e041 100644 --- a/src/server/api/endpoints/following/delete.ts +++ b/src/server/api/endpoints/following/delete.ts @@ -3,8 +3,11 @@ const ms = require('ms'); import User, { pack, ILocalUser } from '../../../../models/user'; import Following from '../../../../models/following'; import deleteFollowing from '../../../../services/following/delete'; +import getParams from '../../get-params'; export const meta = { + stability: 'stable', + desc: { 'ja-JP': '指定したユーザーのフォローを解除します。', 'en-US': 'Unfollow a user.' @@ -17,24 +20,32 @@ export const meta = { requireCredential: true, - kind: 'following-write' + kind: 'following-write', + + params: { + userId: $.type(ID).note({ + desc: { + 'ja-JP': '対象のユーザーのID', + 'en-US': 'Target user ID' + } + }) + } }; export default (params: any, user: ILocalUser) => new Promise(async (res, rej) => { - const follower = user; + const [ps, psErr] = getParams(meta, params); + if (psErr) return rej(psErr); - // Get 'userId' parameter - const [userId, userIdErr] = $.type(ID).get(params.userId); - if (userIdErr) return rej('invalid userId param'); + const follower = user; // Check if the followee is yourself - if (user._id.equals(userId)) { + if (user._id.equals(ps.userId)) { return rej('followee is yourself'); } // Get followee const followee = await User.findOne({ - _id: userId + _id: ps.userId }, { fields: { data: false, diff --git a/src/server/api/endpoints/hashtags/search.ts b/src/server/api/endpoints/hashtags/search.ts index f6fb35b2ff..69cdc62ab8 100644 --- a/src/server/api/endpoints/hashtags/search.ts +++ b/src/server/api/endpoints/hashtags/search.ts @@ -35,7 +35,7 @@ export const meta = { export default (params: any) => new Promise(async (res, rej) => { const [ps, psErr] = getParams(meta, params); - if (psErr) throw psErr; + if (psErr) return rej(psErr); const hashtags = await Hashtag .find({ diff --git a/src/server/api/endpoints/i.ts b/src/server/api/endpoints/i.ts index 5aa2070650..1c488d94c6 100644 --- a/src/server/api/endpoints/i.ts +++ b/src/server/api/endpoints/i.ts @@ -2,6 +2,8 @@ import User, { pack, ILocalUser } from '../../../models/user'; import { IApp } from '../../../models/app'; export const meta = { + stability: 'stable', + desc: { 'ja-JP': '自分のアカウント情報を取得します。' }, diff --git a/src/server/api/endpoints/i/pin.ts b/src/server/api/endpoints/i/pin.ts index bf729ca091..44c7fe77b8 100644 --- a/src/server/api/endpoints/i/pin.ts +++ b/src/server/api/endpoints/i/pin.ts @@ -5,6 +5,8 @@ import { addPinned } from '../../../../services/i/pin'; import getParams from '../../get-params'; export const meta = { + stability: 'stable', + desc: { 'ja-JP': '指定した投稿をピン留めします。' }, @@ -16,7 +18,8 @@ export const meta = { params: { noteId: $.type(ID).note({ desc: { - 'ja-JP': '対象の投稿のID' + 'ja-JP': '対象の投稿のID', + 'en-US': 'Target note ID' } }) } diff --git a/src/server/api/endpoints/i/read_all_unread_notes.ts b/src/server/api/endpoints/i/read_all_unread_notes.ts new file mode 100644 index 0000000000..6a4f72ea29 --- /dev/null +++ b/src/server/api/endpoints/i/read_all_unread_notes.ts @@ -0,0 +1,37 @@ +import User, { ILocalUser } from '../../../../models/user'; +import { publishMainStream } from '../../../../stream'; +import NoteUnread from '../../../../models/note-unread'; + +export const meta = { + desc: { + 'ja-JP': '未読の投稿をすべて既読にします。', + 'en-US': 'Mark all messages as read.' + }, + + requireCredential: true, + + kind: 'account-write', + + params: { + } +}; + +export default async (params: any, user: ILocalUser) => new Promise(async (res, rej) => { + // Remove documents + await NoteUnread.remove({ + userId: user._id + }); + + User.update({ _id: user._id }, { + $set: { + hasUnreadMentions: false, + hasUnreadSpecifiedNotes: false + } + }); + + // 全て既読になったイベントを発行 + publishMainStream(user._id, 'readAllUnreadMentions'); + publishMainStream(user._id, 'readAllUnreadSpecifiedNotes'); + + res(); +}); diff --git a/src/server/api/endpoints/i/unpin.ts b/src/server/api/endpoints/i/unpin.ts index 2a81993e4b..6c20e2771d 100644 --- a/src/server/api/endpoints/i/unpin.ts +++ b/src/server/api/endpoints/i/unpin.ts @@ -5,6 +5,8 @@ import { removePinned } from '../../../../services/i/pin'; import getParams from '../../get-params'; export const meta = { + stability: 'stable', + desc: { 'ja-JP': '指定した投稿のピン留めを解除します。' }, @@ -16,7 +18,8 @@ export const meta = { params: { noteId: $.type(ID).note({ desc: { - 'ja-JP': '対象の投稿のID' + 'ja-JP': '対象の投稿のID', + 'en-US': 'Target note ID' } }) } diff --git a/src/server/api/endpoints/i/update.ts b/src/server/api/endpoints/i/update.ts index 548ce5cadb..7b8431f0ee 100644 --- a/src/server/api/endpoints/i/update.ts +++ b/src/server/api/endpoints/i/update.ts @@ -67,6 +67,12 @@ export const meta = { } }), + carefulBot: $.bool.optional.note({ + desc: { + 'ja-JP': 'Botからのフォローを承認制にするか' + } + }), + isBot: $.bool.optional.note({ desc: { 'ja-JP': 'Botか否か' @@ -95,7 +101,7 @@ export const meta = { export default async (params: any, user: ILocalUser, app: IApp) => new Promise(async (res, rej) => { const [ps, psErr] = getParams(meta, params); - if (psErr) throw psErr; + if (psErr) return rej(psErr); const isSecure = user != null && app == null; @@ -110,6 +116,7 @@ export default async (params: any, user: ILocalUser, app: IApp) => new Promise(a 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.carefulBot == 'boolean') updates.carefulBot = ps.carefulBot; 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; diff --git a/src/server/api/endpoints/messaging/messages/read.ts b/src/server/api/endpoints/messaging/messages/read.ts index 581b57579b..1c0bdf5230 100644 --- a/src/server/api/endpoints/messaging/messages/read.ts +++ b/src/server/api/endpoints/messaging/messages/read.ts @@ -26,7 +26,7 @@ export const meta = { export default (params: any, user: ILocalUser) => new Promise(async (res, rej) => { const [ps, psErr] = getParams(meta, params); - if (psErr) throw psErr; + if (psErr) return rej(psErr); const message = await Message.findOne({ _id: ps.messageId, diff --git a/src/server/api/endpoints/meta.ts b/src/server/api/endpoints/meta.ts index c76d7f2e8f..0cd5842312 100644 --- a/src/server/api/endpoints/meta.ts +++ b/src/server/api/endpoints/meta.ts @@ -1,6 +1,3 @@ -/** - * Module dependencies - */ import * as os from 'os'; import config from '../../../config'; import Meta from '../../../models/meta'; @@ -9,9 +6,19 @@ import { ILocalUser } from '../../../models/user'; const pkg = require('../../../../package.json'); const client = require('../../../../built/client/meta.json'); -/** - * Show core info - */ +export const meta = { + stability: 'stable', + + desc: { + 'ja-JP': 'インスタンス情報を取得します。', + 'en-US': 'Get the information of this instance.' + }, + + requireCredential: false, + + params: {}, +}; + export default (params: any, me: ILocalUser) => new Promise(async (res, rej) => { const meta: any = (await Meta.findOne()) || {}; @@ -28,10 +35,12 @@ export default (params: any, me: ILocalUser) => new Promise(async (res, rej) => machine: os.hostname(), os: os.platform(), node: process.version, + cpu: { model: os.cpus()[0].model, cores: os.cpus().length }, + broadcasts: meta.broadcasts || [], disableRegistration: meta.disableRegistration, disableLocalTimeline: meta.disableLocalTimeline, @@ -40,6 +49,8 @@ export default (params: any, me: ILocalUser) => new Promise(async (res, rej) => swPublickey: config.sw ? config.sw.public_key : null, hidedTags: (me && me.isAdmin) ? meta.hidedTags : undefined, bannerUrl: meta.bannerUrl, + maxNoteTextLength: config.maxNoteTextLength, + features: { registration: !meta.disableRegistration, localTimeLine: !meta.disableLocalTimeline, @@ -47,7 +58,8 @@ export default (params: any, me: ILocalUser) => new Promise(async (res, rej) => recaptcha: config.recaptcha ? true : false, objectStorage: config.drive && config.drive.storage === 'minio', twitter: config.twitter ? true : false, - serviceWorker: config.sw ? true : false + serviceWorker: config.sw ? true : false, + userRecommendation: config.user_recommendation ? config.user_recommendation : {} } }); }); diff --git a/src/server/api/endpoints/notes.ts b/src/server/api/endpoints/notes.ts index d65710d33f..4f5a211240 100644 --- a/src/server/api/endpoints/notes.ts +++ b/src/server/api/endpoints/notes.ts @@ -56,7 +56,7 @@ export const meta = { export default (params: any) => new Promise(async (res, rej) => { const [ps, psErr] = getParams(meta, params); - if (psErr) throw psErr; + if (psErr) return rej(psErr); // Check if both of sinceId and untilId is specified if (ps.sinceId && ps.untilId) { diff --git a/src/server/api/endpoints/notes/create.ts b/src/server/api/endpoints/notes/create.ts index 96745132a3..432561da38 100644 --- a/src/server/api/endpoints/notes/create.ts +++ b/src/server/api/endpoints/notes/create.ts @@ -8,6 +8,8 @@ import { IApp } from '../../../../models/app'; import getParams from '../../get-params'; export const meta = { + stability: 'stable', + desc: { 'ja-JP': '投稿します。' }, diff --git a/src/server/api/endpoints/notes/delete.ts b/src/server/api/endpoints/notes/delete.ts index 2fe36897c0..160d5c4cf6 100644 --- a/src/server/api/endpoints/notes/delete.ts +++ b/src/server/api/endpoints/notes/delete.ts @@ -2,8 +2,11 @@ import $ from 'cafy'; import ID from '../../../../misc/cafy-id'; import Note from '../../../../models/note'; import deleteNote from '../../../../services/note/delete'; import User, { ILocalUser } from '../../../../models/user'; +import getParams from '../../get-params'; export const meta = { + stability: 'stable', + desc: { 'ja-JP': '指定した投稿を削除します。', 'en-US': 'Delete a note.' @@ -11,17 +14,25 @@ export const meta = { requireCredential: true, - kind: 'note-write' + kind: 'note-write', + + params: { + noteId: $.type(ID).note({ + desc: { + 'ja-JP': '対象の投稿のID', + 'en-US': 'Target note 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); // Fetch note const note = await Note.findOne({ - _id: noteId + _id: ps.noteId }); if (note === null) { diff --git a/src/server/api/endpoints/notes/favorites/create.ts b/src/server/api/endpoints/notes/favorites/create.ts index 9aefb701ae..76673e248a 100644 --- a/src/server/api/endpoints/notes/favorites/create.ts +++ b/src/server/api/endpoints/notes/favorites/create.ts @@ -5,6 +5,8 @@ import { ILocalUser } from '../../../../../models/user'; import getParams from '../../../get-params'; export const meta = { + stability: 'stable', + desc: { 'ja-JP': '指定した投稿をお気に入りに登録します。', 'en-US': 'Favorite a note.' @@ -17,7 +19,8 @@ export const meta = { params: { noteId: $.type(ID).note({ desc: { - 'ja-JP': '対象の投稿のID' + 'ja-JP': '対象の投稿のID', + 'en-US': 'Target note ID.' } }) } diff --git a/src/server/api/endpoints/notes/favorites/delete.ts b/src/server/api/endpoints/notes/favorites/delete.ts index e42b24d324..70e1ca8ccf 100644 --- a/src/server/api/endpoints/notes/favorites/delete.ts +++ b/src/server/api/endpoints/notes/favorites/delete.ts @@ -2,8 +2,11 @@ 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 = { + stability: 'stable', + desc: { 'ja-JP': '指定した投稿のお気に入りを解除します。', 'en-US': 'Unfavorite a note.' @@ -11,17 +14,25 @@ export const meta = { requireCredential: true, - kind: 'favorite-write' + kind: 'favorite-write', + + params: { + noteId: $.type(ID).note({ + desc: { + 'ja-JP': '対象の投稿のID', + 'en-US': 'Target note 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/featured.ts b/src/server/api/endpoints/notes/featured.ts new file mode 100644 index 0000000000..363170ead6 --- /dev/null +++ b/src/server/api/endpoints/notes/featured.ts @@ -0,0 +1,49 @@ +import $ from 'cafy'; +import Note from '../../../../models/note'; +import { packMany } from '../../../../models/note'; +import { ILocalUser } from '../../../../models/user'; +import getParams from '../../get-params'; + +export const meta = { + desc: { + 'ja-JP': 'Featuredな投稿を取得します。', + 'en-US': 'Get featured notes.' + }, + + requireCredential: false, + + params: { + limit: $.num.optional.range(1, 30).note({ + default: 10, + desc: { + 'ja-JP': '最大数' + } + }) + } +}; + +export default async (params: any, user: ILocalUser) => { + const [ps, psErr] = getParams(meta, params); + if (psErr) throw psErr; + + const day = 1000 * 60 * 60 * 24; + + const notes = await Note + .find({ + createdAt: { + $gt: new Date(Date.now() - day) + }, + deletedAt: null, + visibility: { $in: ['public', 'home'] } + }, { + limit: ps.limit, + sort: { + score: -1 + }, + hint: { + score: -1 + } + }); + + return await packMany(notes, user); +}; diff --git a/src/server/api/endpoints/notes/global-timeline.ts b/src/server/api/endpoints/notes/global-timeline.ts index 8362143bb2..8a6c848943 100644 --- a/src/server/api/endpoints/notes/global-timeline.ts +++ b/src/server/api/endpoints/notes/global-timeline.ts @@ -58,6 +58,8 @@ export default async (params: any, user: ILocalUser) => { }; const query = { + deletedAt: null, + // public only visibility: 'public', diff --git a/src/server/api/endpoints/notes/hybrid-timeline.ts b/src/server/api/endpoints/notes/hybrid-timeline.ts index 14b4432b33..b2ea9c60ac 100644 --- a/src/server/api/endpoints/notes/hybrid-timeline.ts +++ b/src/server/api/endpoints/notes/hybrid-timeline.ts @@ -129,6 +129,8 @@ export default async (params: any, user: ILocalUser) => { const query = { $and: [{ + deletedAt: null, + $or: [{ // フォローしている人の投稿 $or: followQuery diff --git a/src/server/api/endpoints/notes/local-timeline.ts b/src/server/api/endpoints/notes/local-timeline.ts index 8ab07d8ea7..510564129c 100644 --- a/src/server/api/endpoints/notes/local-timeline.ts +++ b/src/server/api/endpoints/notes/local-timeline.ts @@ -71,6 +71,8 @@ export default async (params: any, user: ILocalUser) => { }; const query = { + deletedAt: null, + // public only visibility: 'public', diff --git a/src/server/api/endpoints/notes/mentions.ts b/src/server/api/endpoints/notes/mentions.ts index 592a94263d..7de0102c6d 100644 --- a/src/server/api/endpoints/notes/mentions.ts +++ b/src/server/api/endpoints/notes/mentions.ts @@ -36,7 +36,7 @@ export const meta = { export default (params: any, user: ILocalUser) => new Promise(async (res, rej) => { const [ps, psErr] = getParams(meta, params); - if (psErr) throw psErr; + if (psErr) return rej(psErr); // Check if both of sinceId and untilId is specified if (ps.sinceId && ps.untilId) { @@ -45,6 +45,8 @@ export default (params: any, user: ILocalUser) => new Promise(async (res, rej) = // Construct query const query = { + deletedAt: null, + $or: [{ mentions: user._id }, { diff --git a/src/server/api/endpoints/notes/reactions/create.ts b/src/server/api/endpoints/notes/reactions/create.ts index ec68f065d8..aa9ab07384 100644 --- a/src/server/api/endpoints/notes/reactions/create.ts +++ b/src/server/api/endpoints/notes/reactions/create.ts @@ -6,6 +6,8 @@ import { ILocalUser } from '../../../../../models/user'; import getParams from '../../../get-params'; export const meta = { + stability: 'stable', + desc: { 'ja-JP': '指定した投稿にリアクションします。', 'en-US': 'React to a note.' diff --git a/src/server/api/endpoints/notes/search_by_tag.ts b/src/server/api/endpoints/notes/search_by_tag.ts index d380f27f9c..e7fa15f768 100644 --- a/src/server/api/endpoints/notes/search_by_tag.ts +++ b/src/server/api/endpoints/notes/search_by_tag.ts @@ -111,7 +111,7 @@ export const meta = { export default (params: any, me: ILocalUser) => new Promise(async (res, rej) => { const [ps, psErr] = getParams(meta, params); - if (psErr) throw psErr; + if (psErr) return rej(psErr); if (ps.includeUserUsernames != null) { const ids = erase(null, await Promise.all(ps.includeUserUsernames.map(async (username) => { diff --git a/src/server/api/endpoints/notes/show.ts b/src/server/api/endpoints/notes/show.ts index 3f94eeede5..e84a948c97 100644 --- a/src/server/api/endpoints/notes/show.ts +++ b/src/server/api/endpoints/notes/show.ts @@ -1,18 +1,35 @@ import $ from 'cafy'; import ID from '../../../../misc/cafy-id'; import Note, { pack } from '../../../../models/note'; import { ILocalUser } from '../../../../models/user'; +import getParams from '../../get-params'; + +export const meta = { + stability: 'stable', + + desc: { + 'ja-JP': '指定した投稿を取得します。', + 'en-US': 'Get a note.' + }, + + requireCredential: false, + + params: { + noteId: $.type(ID).note({ + desc: { + 'ja-JP': '対象の投稿のID', + 'en-US': 'Target note ID.' + } + }) + } +}; -/** - * Show a note - */ 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 note const note = await Note.findOne({ - _id: noteId + _id: ps.noteId }); if (note === null) { diff --git a/src/server/api/endpoints/notes/timeline.ts b/src/server/api/endpoints/notes/timeline.ts index 44a504eb18..31a4978407 100644 --- a/src/server/api/endpoints/notes/timeline.ts +++ b/src/server/api/endpoints/notes/timeline.ts @@ -132,6 +132,8 @@ export default async (params: any, user: ILocalUser) => { const query = { $and: [{ + deletedAt: null, + // フォローしている人の投稿 $or: followQuery, diff --git a/src/server/api/endpoints/notes/user-list-timeline.ts b/src/server/api/endpoints/notes/user-list-timeline.ts index 6758b4eb73..7dddc4834e 100644 --- a/src/server/api/endpoints/notes/user-list-timeline.ts +++ b/src/server/api/endpoints/notes/user-list-timeline.ts @@ -137,6 +137,8 @@ export default async (params: any, user: ILocalUser) => { const query = { $and: [{ + deletedAt: null, + // リストに入っている人のタイムラインへの投稿 $or: listQuery, diff --git a/src/server/api/endpoints/users/lists/update.ts b/src/server/api/endpoints/users/lists/update.ts index e6577eca4f..e39f66fb5b 100644 --- a/src/server/api/endpoints/users/lists/update.ts +++ b/src/server/api/endpoints/users/lists/update.ts @@ -32,7 +32,7 @@ export const meta = { export default (params: any, user: ILocalUser) => new Promise(async (res, rej) => { const [ps, psErr] = getParams(meta, params); - if (psErr) throw psErr; + if (psErr) return rej(psErr); // Fetch the list const userList = await UserList.findOne({ diff --git a/src/server/api/endpoints/users/notes.ts b/src/server/api/endpoints/users/notes.ts index 1bfe832c51..8605a32dee 100644 --- a/src/server/api/endpoints/users/notes.ts +++ b/src/server/api/endpoints/users/notes.ts @@ -99,12 +99,18 @@ export const meta = { 'ja-JP': 'true にすると、ファイルが添付された投稿だけ取得します (このパラメータは廃止予定です。代わりに withFiles を使ってください。)' } }), + + fileType: $.arr($.str).optional.note({ + desc: { + 'ja-JP': '指定された種類のファイルが添付された投稿のみを取得します' + } + }), } }; export default (params: any, me: ILocalUser) => new Promise(async (res, rej) => { const [ps, psErr] = getParams(meta, params); - if (psErr) throw psErr; + if (psErr) return rej(psErr); if (ps.userId === undefined && ps.username === undefined) { return rej('userId or username is required'); @@ -136,7 +142,9 @@ export default (params: any, me: ILocalUser) => new Promise(async (res, rej) => }; const query = { - userId: user._id + deletedAt: null, + userId: user._id, + visibility: { $in: ['public', 'home'] } } as any; if (ps.sinceId) { @@ -171,6 +179,14 @@ export default (params: any, me: ILocalUser) => new Promise(async (res, rej) => $ne: [] }; } + + if (ps.fileType) { + query.fileIds = { $exists: true, $ne: [] }; + + query['_files.contentType'] = { + $in: ps.fileType + }; + } //#endregion // Issue query diff --git a/src/server/api/endpoints/users/recommendation.ts b/src/server/api/endpoints/users/recommendation.ts index e0a5cb9e36..df85343f0f 100644 --- a/src/server/api/endpoints/users/recommendation.ts +++ b/src/server/api/endpoints/users/recommendation.ts @@ -3,6 +3,8 @@ import $ from 'cafy'; import User, { pack, ILocalUser } from '../../../../models/user'; import { getFriendIds } from '../../common/get-friends'; import Mute from '../../../../models/mute'; +import * as request from 'request'; +import config from '../../../../config'; export const meta = { desc: { @@ -15,44 +17,70 @@ export const meta = { }; export default (params: any, me: ILocalUser) => new Promise(async (res, rej) => { - // Get 'limit' parameter - const [limit = 10, limitErr] = $.num.optional.range(1, 100).get(params.limit); - if (limitErr) return rej('invalid limit param'); + if (config.user_recommendation && config.user_recommendation.external) { + const userName = me.username; + const hostName = config.hostname; + const limit = params.limit; + const offset = params.offset; + const timeout = config.user_recommendation.timeout; + const engine = config.user_recommendation.engine; + const url = engine + .replace('{{host}}', hostName) + .replace('{{user}}', userName) + .replace('{{limit}}', limit) + .replace('{{offset}}', offset); - // Get 'offset' parameter - const [offset = 0, offsetErr] = $.num.optional.min(0).get(params.offset); - if (offsetErr) return rej('invalid offset param'); + request({ + url: url, + proxy: config.proxy, + timeout: timeout, + json: true, + followRedirect: true, + followAllRedirects: true + }, (error: any, response: any, body: any) => { + if (!error && response.statusCode == 200) { + res(body); + } else { + res([]); + } + }); + } else { + // Get 'limit' parameter + const [limit = 10, limitErr] = $.num.optional.range(1, 100).get(params.limit); + if (limitErr) return rej('invalid limit param'); + + // Get 'offset' parameter + const [offset = 0, offsetErr] = $.num.optional.min(0).get(params.offset); + if (offsetErr) return rej('invalid offset param'); - // ID list of the user itself and other users who the user follows - const followingIds = await getFriendIds(me._id); + // ID list of the user itself and other users who the user follows + const followingIds = await getFriendIds(me._id); - // ミュートしているユーザーを取得 - const mutedUserIds = (await Mute.find({ - muterId: me._id - })).map(m => m.muteeId); + // ミュートしているユーザーを取得 + const mutedUserIds = (await Mute.find({ + muterId: me._id + })).map(m => m.muteeId); - const users = await User - .find({ - _id: { - $nin: followingIds.concat(mutedUserIds) - }, - isLocked: false, - $or: [{ + const users = await User + .find({ + _id: { + $nin: followingIds.concat(mutedUserIds) + }, + isLocked: { $ne: true }, lastUsedAt: { $gte: new Date(Date.now() - ms('7days')) - } - }, { + }, host: null - }] - }, { - limit: limit, - skip: offset, - sort: { - followersCount: -1 - } - }); + }, { + limit: limit, + skip: offset, + sort: { + followersCount: -1 + } + }); - // Serialize - res(await Promise.all(users.map(async user => - await pack(user, me, { detail: true })))); + // Serialize + res(await Promise.all(users.map(async user => + await pack(user, me, { detail: true })))); + } }); diff --git a/src/server/api/index.ts b/src/server/api/index.ts index a8f6455d9a..33e98f650a 100644 --- a/src/server/api/index.ts +++ b/src/server/api/index.ts @@ -46,6 +46,8 @@ router.post('/signin', require('./private/signin').default); router.use(require('./service/github').routes()); router.use(require('./service/twitter').routes()); +router.use(require('./mastodon').routes()); + // Return 404 for unknown API router.all('*', async ctx => { ctx.status = 404; @@ -54,4 +56,4 @@ router.all('*', async ctx => { // Register router app.use(router.routes()); -module.exports = app; +export default app; diff --git a/src/server/api/limitter.ts b/src/server/api/limitter.ts index 20a18a7098..abf7627ab8 100644 --- a/src/server/api/limitter.ts +++ b/src/server/api/limitter.ts @@ -8,6 +8,12 @@ import { IUser } from '../../models/user'; const log = debug('misskey:limitter'); export default (endpoint: IEndpoint, user: IUser) => new Promise((ok, reject) => { + // Redisがインストールされてない場合は常に許可 + if (limiterDB == null) { + ok(); + return; + } + const limitation = endpoint.meta.limit; const key = limitation.hasOwnProperty('key') diff --git a/src/server/api/mastodon.ts b/src/server/api/mastodon.ts new file mode 100644 index 0000000000..f2ce1c384f --- /dev/null +++ b/src/server/api/mastodon.ts @@ -0,0 +1,14 @@ +import * as Router from 'koa-router'; +import User from '../../models/user'; +import { toASCII } from 'punycode'; + +// Init router +const router = new Router(); + +router.get('/v1/instance/peers', async ctx => { + const peers = await User.distinct('host', { host: { $ne: null } }) as any as string[]; + const punyCodes = peers.map(peer => toASCII(peer)); + ctx.body = punyCodes; +}); + +module.exports = router; diff --git a/src/server/api/private/signin.ts b/src/server/api/private/signin.ts index 0e44c2ddd6..0a0f9ae6f9 100644 --- a/src/server/api/private/signin.ts +++ b/src/server/api/private/signin.ts @@ -12,9 +12,8 @@ export default async (ctx: Koa.Context) => { ctx.set('Access-Control-Allow-Credentials', 'true'); const body = ctx.request.body as any; - // See: https://github.com/syuilo/misskey/issues/2384 - const username = body['username'] || body['x']; - const password = body['password'] || body['y']; + const username = body['username']; + const password = body['password']; const token = body['token']; if (typeof username != 'string') { diff --git a/src/server/api/private/signup.ts b/src/server/api/private/signup.ts index e3e8f044b5..d6eba69817 100644 --- a/src/server/api/private/signup.ts +++ b/src/server/api/private/signup.ts @@ -7,7 +7,7 @@ import generateUserToken from '../common/generate-native-user-token'; import config from '../../../config'; import Meta from '../../../models/meta'; import RegistrationTicket from '../../../models/registration-tickets'; -import { updateUserStats } from '../../../services/update-chart'; +import usersChart from '../../../chart/users'; if (config.recaptcha) { recaptcha.init({ @@ -130,8 +130,14 @@ export default async (ctx: Koa.Context) => { }, { upsert: true }); //#endregion - updateUserStats(account, true); + usersChart.update(account, true); - // Response - ctx.body = await pack(account); + const res = await pack(account, account, { + detail: true, + includeSecrets: true + }); + + res.token = secret; + + ctx.body = res; }; diff --git a/src/server/api/service/github.ts b/src/server/api/service/github.ts index c8d588eaaf..ac18cf90ae 100644 --- a/src/server/api/service/github.ts +++ b/src/server/api/service/github.ts @@ -63,6 +63,7 @@ handler.on('status', event => { // Fetch parent status request({ url: `${parent.url}/statuses`, + proxy: config.proxy, headers: { 'User-Agent': 'misskey' } diff --git a/src/server/api/service/twitter.ts b/src/server/api/service/twitter.ts index f71e588628..6c3cdaa138 100644 --- a/src/server/api/service/twitter.ts +++ b/src/server/api/service/twitter.ts @@ -55,7 +55,7 @@ router.get('/disconnect/twitter', async ctx => { })); }); -if (config.twitter == null) { +if (config.twitter == null || redis == null) { router.get('/connect/twitter', ctx => { ctx.body = '現在Twitterへ接続できません (このインスタンスではTwitterはサポートされていません)'; }); diff --git a/src/server/api/stream/channel.ts b/src/server/api/stream/channel.ts index e2726060dc..75914964cb 100644 --- a/src/server/api/stream/channel.ts +++ b/src/server/api/stream/channel.ts @@ -7,6 +7,8 @@ import Connection from '.'; export default abstract class Channel { protected connection: Connection; public id: string; + public abstract readonly chName: string; + public static readonly shouldShare: boolean; protected get user() { return this.connection.user; diff --git a/src/server/api/stream/channels/drive.ts b/src/server/api/stream/channels/drive.ts index 807fc93cd0..7425a620ff 100644 --- a/src/server/api/stream/channels/drive.ts +++ b/src/server/api/stream/channels/drive.ts @@ -2,6 +2,9 @@ import autobind from 'autobind-decorator'; import Channel from '../channel'; export default class extends Channel { + public readonly chName = 'drive'; + public static shouldShare = true; + @autobind public async init(params: any) { // Subscribe drive stream diff --git a/src/server/api/stream/channels/games/reversi-game.ts b/src/server/api/stream/channels/games/reversi-game.ts index 11f1fb1feb..5dc9ca0608 100644 --- a/src/server/api/stream/channels/games/reversi-game.ts +++ b/src/server/api/stream/channels/games/reversi-game.ts @@ -1,5 +1,6 @@ import autobind from 'autobind-decorator'; import * as CRC32 from 'crc-32'; +import * as mongo from 'mongodb'; import ReversiGame, { pack } from '../../../../../models/games/reversi/game'; import { publishReversiGameStream } from '../../../../../stream'; import Reversi from '../../../../../games/reversi/core'; @@ -7,11 +8,14 @@ import * as maps from '../../../../../games/reversi/maps'; import Channel from '../../channel'; export default class extends Channel { - private gameId: string; + public readonly chName = 'gamesReversiGame'; + public static shouldShare = false; + + private gameId: mongo.ObjectID; @autobind public async init(params: any) { - this.gameId = params.gameId as string; + this.gameId = new mongo.ObjectID(params.gameId as string); // Subscribe game stream this.subscriber.on(`reversiGameStream:${this.gameId}`, data => { @@ -23,10 +27,10 @@ export default class extends Channel { 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 'cancelAccept': this.accept(false); break; + case 'updateSettings': this.updateSettings(body.settings); break; + case 'initForm': this.initForm(body); break; + case 'updateForm': 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; diff --git a/src/server/api/stream/channels/games/reversi.ts b/src/server/api/stream/channels/games/reversi.ts index d75025c944..51cb264d98 100644 --- a/src/server/api/stream/channels/games/reversi.ts +++ b/src/server/api/stream/channels/games/reversi.ts @@ -5,6 +5,9 @@ import { publishMainStream } from '../../../../../stream'; import Channel from '../../channel'; export default class extends Channel { + public readonly chName = 'gamesReversi'; + public static shouldShare = true; + @autobind public async init(params: any) { // Subscribe reversi stream diff --git a/src/server/api/stream/channels/global-timeline.ts b/src/server/api/stream/channels/global-timeline.ts index ab0fe5d094..e39ea269a6 100644 --- a/src/server/api/stream/channels/global-timeline.ts +++ b/src/server/api/stream/channels/global-timeline.ts @@ -5,6 +5,9 @@ import shouldMuteThisNote from '../../../../misc/should-mute-this-note'; import Channel from '../channel'; export default class extends Channel { + public readonly chName = 'globalTimeline'; + public static shouldShare = true; + private mutedUserIds: string[] = []; @autobind diff --git a/src/server/api/stream/channels/hashtag.ts b/src/server/api/stream/channels/hashtag.ts index 652b0caa5b..1f99aa3539 100644 --- a/src/server/api/stream/channels/hashtag.ts +++ b/src/server/api/stream/channels/hashtag.ts @@ -5,6 +5,9 @@ import shouldMuteThisNote from '../../../../misc/should-mute-this-note'; import Channel from '../channel'; export default class extends Channel { + public readonly chName = 'hashtag'; + public static shouldShare = false; + @autobind public async init(params: any) { const mute = this.user ? await Mute.find({ muterId: this.user._id }) : null; @@ -12,6 +15,8 @@ export default class extends Channel { const q: Array<string[]> = params.q; + if (q == null) return; + // 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()))); diff --git a/src/server/api/stream/channels/home-timeline.ts b/src/server/api/stream/channels/home-timeline.ts index 4c674e75ef..3fa887f1e5 100644 --- a/src/server/api/stream/channels/home-timeline.ts +++ b/src/server/api/stream/channels/home-timeline.ts @@ -5,6 +5,9 @@ import shouldMuteThisNote from '../../../../misc/should-mute-this-note'; import Channel from '../channel'; export default class extends Channel { + public readonly chName = 'homeTimeline'; + public static shouldShare = true; + private mutedUserIds: string[] = []; @autobind diff --git a/src/server/api/stream/channels/hybrid-timeline.ts b/src/server/api/stream/channels/hybrid-timeline.ts index 0b12ab3a8f..d72545e4c8 100644 --- a/src/server/api/stream/channels/hybrid-timeline.ts +++ b/src/server/api/stream/channels/hybrid-timeline.ts @@ -5,6 +5,9 @@ import shouldMuteThisNote from '../../../../misc/should-mute-this-note'; import Channel from '../channel'; export default class extends Channel { + public readonly chName = 'hybridTimeline'; + public static shouldShare = true; + private mutedUserIds: string[] = []; @autobind diff --git a/src/server/api/stream/channels/local-timeline.ts b/src/server/api/stream/channels/local-timeline.ts index 769ec6392f..0ba0b1b195 100644 --- a/src/server/api/stream/channels/local-timeline.ts +++ b/src/server/api/stream/channels/local-timeline.ts @@ -5,6 +5,9 @@ import shouldMuteThisNote from '../../../../misc/should-mute-this-note'; import Channel from '../channel'; export default class extends Channel { + public readonly chName = 'localTimeline'; + public static shouldShare = true; + private mutedUserIds: string[] = []; @autobind diff --git a/src/server/api/stream/channels/main.ts b/src/server/api/stream/channels/main.ts index fd0984c833..7d5462c092 100644 --- a/src/server/api/stream/channels/main.ts +++ b/src/server/api/stream/channels/main.ts @@ -3,6 +3,9 @@ import Mute from '../../../../models/mute'; import Channel from '../channel'; export default class extends Channel { + public readonly chName = 'main'; + public static shouldShare = true; + @autobind public async init(params: any) { const mute = await Mute.find({ muterId: this.user._id }); diff --git a/src/server/api/stream/channels/messaging-index.ts b/src/server/api/stream/channels/messaging-index.ts index 6e87cca7f4..0211d702cf 100644 --- a/src/server/api/stream/channels/messaging-index.ts +++ b/src/server/api/stream/channels/messaging-index.ts @@ -2,6 +2,9 @@ import autobind from 'autobind-decorator'; import Channel from '../channel'; export default class extends Channel { + public readonly chName = 'messagingIndex'; + public static shouldShare = true; + @autobind public async init(params: any) { // Subscribe messaging index stream diff --git a/src/server/api/stream/channels/messaging.ts b/src/server/api/stream/channels/messaging.ts index e1a78c8678..ab04a332cf 100644 --- a/src/server/api/stream/channels/messaging.ts +++ b/src/server/api/stream/channels/messaging.ts @@ -3,6 +3,9 @@ import read from '../../common/read-messaging-message'; import Channel from '../channel'; export default class extends Channel { + public readonly chName = 'messaging'; + public static shouldShare = false; + private otherpartyId: string; @autobind diff --git a/src/server/api/stream/channels/notes-stats.ts b/src/server/api/stream/channels/notes-stats.ts index cc68d9886d..2282f8bc70 100644 --- a/src/server/api/stream/channels/notes-stats.ts +++ b/src/server/api/stream/channels/notes-stats.ts @@ -5,6 +5,9 @@ import Channel from '../channel'; const ev = new Xev(); export default class extends Channel { + public readonly chName = 'notesStats'; + public static shouldShare = true; + @autobind public async init(params: any) { ev.addListener('notesStats', this.onStats); diff --git a/src/server/api/stream/channels/server-stats.ts b/src/server/api/stream/channels/server-stats.ts index 28a566e8ae..912dcf5305 100644 --- a/src/server/api/stream/channels/server-stats.ts +++ b/src/server/api/stream/channels/server-stats.ts @@ -5,6 +5,9 @@ import Channel from '../channel'; const ev = new Xev(); export default class extends Channel { + public readonly chName = 'serverStats'; + public static shouldShare = true; + @autobind public async init(params: any) { ev.addListener('serverStats', this.onStats); diff --git a/src/server/api/stream/channels/user-list.ts b/src/server/api/stream/channels/user-list.ts index 4ace308923..b526a5f986 100644 --- a/src/server/api/stream/channels/user-list.ts +++ b/src/server/api/stream/channels/user-list.ts @@ -2,6 +2,9 @@ import autobind from 'autobind-decorator'; import Channel from '../channel'; export default class extends Channel { + public readonly chName = 'userList'; + public static shouldShare = false; + @autobind public async init(params: any) { const listId = params.listId as string; diff --git a/src/server/api/stream/index.ts b/src/server/api/stream/index.ts index ef6397fcd9..96a4c7add6 100644 --- a/src/server/api/stream/index.ts +++ b/src/server/api/stream/index.ts @@ -1,7 +1,5 @@ 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'; @@ -11,8 +9,7 @@ import readNote from '../../../services/note/read'; import Channel from './channel'; import channels from './channels'; - -const log = debug('misskey'); +import { EventEmitter } from 'events'; /** * Main stream connection @@ -21,14 +18,14 @@ export default class Connection { public user?: IUser; public app: IApp; private wsConnection: websocket.connection; - public subscriber: Xev; + public subscriber: EventEmitter; private channels: Channel[] = []; private subscribingNotes: any = {}; public sendMessageToWsOverride: any = null; // 後方互換性のため constructor( wsConnection: websocket.connection, - subscriber: Xev, + subscriber: EventEmitter, user: IUser, app: IApp ) { @@ -58,6 +55,7 @@ export default class Connection { case 'connect': this.onChannelConnectRequested(body); break; case 'disconnect': this.onChannelDisconnectRequested(body); break; case 'channel': this.onChannelMessageRequested(body); break; + case 'ch': this.onChannelMessageRequested(body); break; // alias } } @@ -145,9 +143,8 @@ export default class Connection { */ @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]); + const { channel, id, params, pong } = payload; + this.connectChannel(id, params, channel, pong); } /** @@ -156,7 +153,6 @@ export default class Connection { @autobind private onChannelDisconnectRequested(payload: any) { const { id } = payload; - log(`CH DISCONNECT: ${id} by @${this.user.username}`); this.disconnectChannel(id); } @@ -176,10 +172,21 @@ export default class Connection { * チャンネルに接続 */ @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); + public connectChannel(id: string, params: any, channel: string, pong = false) { + // 共有可能チャンネルに接続しようとしていて、かつそのチャンネルに既に接続していたら無意味なので無視 + if ((channels as any)[channel].shouldShare && this.channels.some(c => c.chName === channel)) { + return; + } + + const ch: Channel = new (channels as any)[channel](id, this); + this.channels.push(ch); + ch.init(params); + + if (pong) { + this.sendMessageToWs('connected', { + id: id + }); + } } /** diff --git a/src/server/api/streaming.ts b/src/server/api/streaming.ts index c8c4a8a294..8c0e6f6372 100644 --- a/src/server/api/streaming.ts +++ b/src/server/api/streaming.ts @@ -1,11 +1,13 @@ import * as http from 'http'; import * as websocket from 'websocket'; +import * as redis from 'redis'; import Xev from 'xev'; import MainStreamConnection from './stream'; import { ParsedUrlQuery } from 'querystring'; import authenticate from './authenticate'; -import channels from './stream/channels'; +import { EventEmitter } from 'events'; +import config from '../../config'; module.exports = (server: http.Server) => { // Init websocket server @@ -16,11 +18,34 @@ module.exports = (server: http.Server) => { ws.on('request', async (request) => { const connection = request.accept(); - const ev = new Xev(); - const q = request.resourceURL.query as ParsedUrlQuery; const [user, app] = await authenticate(q.i as string); + let ev: EventEmitter; + + if (config.redis) { + // Connect to Redis + const subscriber = redis.createClient( + config.redis.port, config.redis.host); + + subscriber.subscribe('misskey'); + + ev = new EventEmitter(); + + subscriber.on('message', async (_, data) => { + const obj = JSON.parse(data); + + ev.emit(obj.channel, obj.message); + }); + + connection.once('close', () => { + subscriber.unsubscribe(); + subscriber.quit(); + }); + } else { + ev = new Xev(); + } + const main = new MainStreamConnection(connection, ev, user, app); // 後方互換性のため @@ -39,11 +64,15 @@ module.exports = (server: http.Server) => { })); }; - 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); + main.connectChannel(Math.random().toString().substr(2, 8), null, + request.resourceURL.pathname === '/' ? 'homeTimeline' : + request.resourceURL.pathname === '/local-timeline' ? 'localTimeline' : + request.resourceURL.pathname === '/hybrid-timeline' ? 'hybridTimeline' : + request.resourceURL.pathname === '/global-timeline' ? 'globalTimeline' : null); + + if (request.resourceURL.pathname === '/') { + main.connectChannel(Math.random().toString().substr(2, 8), null, 'main'); + } } connection.once('close', () => { diff --git a/src/server/index.ts b/src/server/index.ts index dc60b0d9ec..f1933dc405 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -17,7 +17,8 @@ const requestStats = require('request-stats'); import activityPub from './activitypub'; import webFinger from './webfinger'; import config from '../config'; -import { updateNetworkStats } from '../services/update-chart'; +import networkChart from '../chart/network'; +import apiServer from './api'; // Init app const app = new Koa(); @@ -40,14 +41,14 @@ app.use(compress({ // HSTS // 6months (15552000sec) -if (config.url.startsWith('https')) { +if (config.url.startsWith('https') && !config.disableHsts) { app.use(async (ctx, next) => { ctx.set('strict-transport-security', 'max-age=15552000; preload'); await next(); }); } -app.use(mount('/api', require('./api'))); +app.use(mount('/api', apiServer)); app.use(mount('/files', require('./file'))); // Init router @@ -103,7 +104,7 @@ export default () => new Promise(resolve => { const outgoingBytes = queue.reduce((a, b) => a + b.res.bytes, 0); queue = []; - updateNetworkStats(requests, time, incomingBytes, outgoingBytes); + networkChart.update(requests, time, incomingBytes, outgoingBytes); }, 5000); //#endregion }); diff --git a/src/server/web/url-preview.ts b/src/server/web/url-preview.ts index 41ca6bad8b..eb835b05ac 100644 --- a/src/server/web/url-preview.ts +++ b/src/server/web/url-preview.ts @@ -7,6 +7,7 @@ module.exports = async (ctx: Koa.Context) => { try { const summary = config.summalyProxy ? await request.get({ url: config.summalyProxy, + proxy: config.proxy, qs: { url: ctx.query.url }, diff --git a/src/server/web/views/user.pug b/src/server/web/views/user.pug index 506a889d98..22c76c143b 100644 --- a/src/server/web/views/user.pug +++ b/src/server/web/views/user.pug @@ -3,7 +3,7 @@ extends ../../../../src/client/app/base block vars - const title = user.name ? `${user.name} (@${user.username})` : `@${user.username}`; - const url = `${config.url}/@${(user.host ? `${user.username}@${user.host}` : user.username)}`; - - const img = user.avatarId ? `${config.drive_url}/${user.avatarId}` : null; + - const img = user.avatarUrl || null; block title = `${title} | ${config.name}` diff --git a/src/services/drive/add-file.ts b/src/services/drive/add-file.ts index f8c54b2af4..4a06b62ae2 100644 --- a/src/services/drive/add-file.ts +++ b/src/services/drive/add-file.ts @@ -17,7 +17,8 @@ import { isLocalUser, IUser, IRemoteUser } from '../../models/user'; import delFile from './delete-file'; import config from '../../config'; import { getDriveFileThumbnailBucket } from '../../models/drive-file-thumbnail'; -import { updateDriveStats } from '../update-chart'; +import driveChart from '../../chart/drive'; +import perUserDriveChart from '../../chart/per-user-drive'; const log = debug('misskey:drive:add-file'); @@ -37,10 +38,16 @@ 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 keyDir = `${config.drive.prefix}/${uuid.v4()}`; - const key = `${keyDir}/${name}`; - const thumbnailKeyDir = `${config.drive.prefix}/${uuid.v4()}`; - const thumbnailKey = `${thumbnailKeyDir}/${name}.thumbnail.jpg`; + let [ext] = (name.match(/\.([a-zA-Z0-9_-]+)$/) || ['']); + + if (ext === '') { + if (type === 'image/jpeg') ext = '.jpg'; + if (type === 'image/png') ext = '.png'; + if (type === 'image/webp') ext = '.webp'; + } + + const key = `${config.drive.prefix}/${uuid.v4()}${ext}`; + const thumbnailKey = `${config.drive.prefix}/${uuid.v4()}.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 }`; @@ -64,8 +71,8 @@ async function save(path: string, name: string, type: string, hash: string, size key: key, thumbnailKey: thumbnailKey }, - url: `${ baseUrl }/${ keyDir }/${ encodeURIComponent(name) }`, - thumbnailUrl: thumbnail ? `${ baseUrl }/${ thumbnailKeyDir }/${ encodeURIComponent(name) }.thumbnail.jpg` : null + url: `${ baseUrl }/${ key }`, + thumbnailUrl: thumbnail ? `${ baseUrl }/${ thumbnailKey }` : null }); const file = await DriveFile.insert({ @@ -185,6 +192,10 @@ export default async function( // 種類が同定できなかったら application/octet-stream にする res(['application/octet-stream', null]); } + }) + .on('end', () => { + // maybe 0 bytes + res(['application/octet-stream', null]); }); }); @@ -385,11 +396,12 @@ export default async function( pack(driveFile).then(packedFile => { // Publish driveFileCreated event publishMainStream(user._id, 'driveFileCreated', packedFile); - publishDriveStream(user._id, 'file_created', packedFile); + publishDriveStream(user._id, 'fileCreated', packedFile); }); // 統計を更新 - updateDriveStats(driveFile, true); + driveChart.update(driveFile, true); + perUserDriveChart.update(driveFile, true); return driveFile; } diff --git a/src/services/drive/delete-file.ts b/src/services/drive/delete-file.ts index 73532a2953..3e2f42003b 100644 --- a/src/services/drive/delete-file.ts +++ b/src/services/drive/delete-file.ts @@ -2,7 +2,8 @@ import * as Minio from 'minio'; import DriveFile, { DriveFileChunk, IDriveFile } from '../../models/drive-file'; import DriveFileThumbnail, { DriveFileThumbnailChunk } from '../../models/drive-file-thumbnail'; import config from '../../config'; -import { updateDriveStats } from '../update-chart'; +import driveChart from '../../chart/drive'; +import perUserDriveChart from '../../chart/per-user-drive'; export default async function(file: IDriveFile, isExpired = false) { if (file.metadata.storage == 'minio') { @@ -48,5 +49,6 @@ export default async function(file: IDriveFile, isExpired = false) { //#endregion // 統計を更新 - updateDriveStats(file, false); + driveChart.update(file, false); + perUserDriveChart.update(file, false); } diff --git a/src/services/drive/upload-from-url.ts b/src/services/drive/upload-from-url.ts index 35d4ec9883..2184edf005 100644 --- a/src/services/drive/upload-from-url.ts +++ b/src/services/drive/upload-from-url.ts @@ -37,6 +37,7 @@ export default async (url: string, user: IUser, folderId: mongodb.ObjectID = nul const requestUrl = URL.parse(url).pathname.match(/[^\u0021-\u00ff]/) ? encodeURI(url) : url; request({ url: requestUrl, + proxy: config.proxy, headers: { 'User-Agent': config.user_agent } diff --git a/src/services/following/create.ts b/src/services/following/create.ts index 637e3e8093..87d13c444b 100644 --- a/src/services/following/create.ts +++ b/src/services/following/create.ts @@ -1,7 +1,5 @@ import User, { isLocalUser, isRemoteUser, pack as packUser, IUser } from '../../models/user'; import Following from '../../models/following'; -import FollowingLog from '../../models/following-log'; -import FollowedLog from '../../models/followed-log'; import { publishMainStream } from '../../stream'; import notify from '../../notify'; import pack from '../../remote/activitypub/renderer'; @@ -9,72 +7,69 @@ import renderFollow from '../../remote/activitypub/renderer/follow'; import renderAccept from '../../remote/activitypub/renderer/accept'; import { deliver } from '../../queue'; import createFollowRequest from './requests/create'; +import perUserFollowingChart from '../../chart/per-user-following'; -export default async function(follower: IUser, followee: IUser) { - if (followee.isLocked || isLocalUser(follower) && isRemoteUser(followee)) { - await createFollowRequest(follower, followee); - } else { - const following = await Following.insert({ - createdAt: new Date(), - followerId: follower._id, - followeeId: followee._id, - - // 非正規化 - _follower: { - host: follower.host, - inbox: isRemoteUser(follower) ? follower.inbox : undefined, - sharedInbox: isRemoteUser(follower) ? follower.sharedInbox : undefined - }, - _followee: { - host: followee.host, - inbox: isRemoteUser(followee) ? followee.inbox : undefined, - sharedInbox: isRemoteUser(followee) ? followee.sharedInbox : undefined - } - }); +export default async function(follower: IUser, followee: IUser, requestId?: string) { + // フォロー対象が鍵アカウントである or + // フォロワーがBotであり、フォロー対象がBotからのフォローに慎重である or + // フォロワーがローカルユーザーであり、フォロー対象がリモートユーザーである + // 上記のいずれかに当てはまる場合はすぐフォローせずにフォローリクエストを発行しておく + if (followee.isLocked || (followee.carefulBot && follower.isBot) || (isLocalUser(follower) && isRemoteUser(followee))) { + await createFollowRequest(follower, followee, requestId); + return; + } - //#region Increment following count - User.update({ _id: follower._id }, { - $inc: { - followingCount: 1 - } - }); + await Following.insert({ + createdAt: new Date(), + followerId: follower._id, + followeeId: followee._id, - FollowingLog.insert({ - createdAt: following.createdAt, - userId: follower._id, - count: follower.followingCount + 1 - }); - //#endregion + // 非正規化 + _follower: { + host: follower.host, + inbox: isRemoteUser(follower) ? follower.inbox : undefined, + sharedInbox: isRemoteUser(follower) ? follower.sharedInbox : undefined + }, + _followee: { + host: followee.host, + inbox: isRemoteUser(followee) ? followee.inbox : undefined, + sharedInbox: isRemoteUser(followee) ? followee.sharedInbox : undefined + } + }); - //#region Increment followers count - User.update({ _id: followee._id }, { - $inc: { - followersCount: 1 - } - }); - FollowedLog.insert({ - createdAt: following.createdAt, - userId: followee._id, - count: followee.followersCount + 1 - }); - //#endregion + //#region Increment following count + User.update({ _id: follower._id }, { + $inc: { + followingCount: 1 + } + }); + //#endregion - // Publish follow event - if (isLocalUser(follower)) { - packUser(followee, follower).then(packed => publishMainStream(follower._id, 'follow', packed)); + //#region Increment followers count + User.update({ _id: followee._id }, { + $inc: { + followersCount: 1 } + }); + //#endregion - // Publish followed event - if (isLocalUser(followee)) { - packUser(follower, followee).then(packed => publishMainStream(followee._id, 'followed', packed)), + perUserFollowingChart.update(follower, followee, true); - // 通知を作成 - notify(followee._id, follower._id, 'follow'); - } + // Publish follow event + if (isLocalUser(follower)) { + packUser(followee, follower).then(packed => publishMainStream(follower._id, 'follow', packed)); + } - if (isRemoteUser(follower) && isLocalUser(followee)) { - const content = pack(renderAccept(renderFollow(follower, followee))); - deliver(followee, content, follower.inbox); - } + // Publish followed event + if (isLocalUser(followee)) { + packUser(follower, followee).then(packed => publishMainStream(followee._id, 'followed', packed)), + + // 通知を作成 + notify(followee._id, follower._id, 'follow'); + } + + if (isRemoteUser(follower) && isLocalUser(followee)) { + const content = pack(renderAccept(renderFollow(follower, followee, requestId), followee)); + deliver(followee, content, follower.inbox); } } diff --git a/src/services/following/delete.ts b/src/services/following/delete.ts index 2a67acbf05..9f82af2bf4 100644 --- a/src/services/following/delete.ts +++ b/src/services/following/delete.ts @@ -1,12 +1,11 @@ import User, { isLocalUser, isRemoteUser, pack as packUser, IUser } from '../../models/user'; import Following from '../../models/following'; -import FollowingLog from '../../models/following-log'; -import FollowedLog from '../../models/followed-log'; import { publishMainStream } from '../../stream'; 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 perUserFollowingChart from '../../chart/per-user-following'; export default async function(follower: IUser, followee: IUser) { const following = await Following.findOne({ @@ -29,12 +28,6 @@ export default async function(follower: IUser, followee: IUser) { followingCount: -1 } }); - - FollowingLog.insert({ - createdAt: following.createdAt, - userId: follower._id, - count: follower.followingCount - 1 - }); //#endregion //#region Decrement followers count @@ -43,13 +36,10 @@ export default async function(follower: IUser, followee: IUser) { followersCount: -1 } }); - FollowedLog.insert({ - createdAt: following.createdAt, - userId: followee._id, - count: followee.followersCount - 1 - }); //#endregion + perUserFollowingChart.update(follower, followee, false); + // Publish unfollow event if (isLocalUser(follower)) { packUser(followee, follower).then(packed => publishMainStream(follower._id, 'unfollow', packed)); diff --git a/src/services/following/requests/accept.ts b/src/services/following/requests/accept.ts index e7c8df844a..32453c74dc 100644 --- a/src/services/following/requests/accept.ts +++ b/src/services/following/requests/accept.ts @@ -5,12 +5,11 @@ import renderFollow from '../../../remote/activitypub/renderer/follow'; import renderAccept from '../../../remote/activitypub/renderer/accept'; import { deliver } from '../../../queue'; import Following from '../../../models/following'; -import FollowingLog from '../../../models/following-log'; -import FollowedLog from '../../../models/followed-log'; import { publishMainStream } from '../../../stream'; +import perUserFollowingChart from '../../../chart/per-user-following'; export default async function(followee: IUser, follower: IUser) { - const following = await Following.insert({ + await Following.insert({ createdAt: new Date(), followerId: follower._id, followeeId: followee._id, @@ -29,7 +28,12 @@ export default async function(followee: IUser, follower: IUser) { }); if (isRemoteUser(follower)) { - const content = pack(renderAccept(renderFollow(follower, followee))); + const request = await FollowRequest.findOne({ + followeeId: followee._id, + followerId: follower._id + }); + + const content = pack(renderAccept(renderFollow(follower, followee, request.requestId), followee as ILocalUser)); deliver(followee as ILocalUser, content, follower.inbox); } @@ -44,12 +48,6 @@ export default async function(followee: IUser, follower: IUser) { followingCount: 1 } }); - - FollowingLog.insert({ - createdAt: following.createdAt, - userId: follower._id, - count: follower.followingCount + 1 - }); //#endregion //#region Increment followers count @@ -58,14 +56,10 @@ export default async function(followee: IUser, follower: IUser) { followersCount: 1 } }); - - FollowedLog.insert({ - createdAt: following.createdAt, - userId: followee._id, - count: followee.followersCount + 1 - }); //#endregion + perUserFollowingChart.update(follower, followee, true); + await User.update({ _id: followee._id }, { $inc: { pendingReceivedFollowRequestsCount: -1 diff --git a/src/services/following/requests/create.ts b/src/services/following/requests/create.ts index 5e613fd053..d28c93929a 100644 --- a/src/services/following/requests/create.ts +++ b/src/services/following/requests/create.ts @@ -6,11 +6,12 @@ import renderFollow from '../../../remote/activitypub/renderer/follow'; import { deliver } from '../../../queue'; import FollowRequest from '../../../models/follow-request'; -export default async function(follower: IUser, followee: IUser) { +export default async function(follower: IUser, followee: IUser, requestId?: string) { await FollowRequest.insert({ createdAt: new Date(), followerId: follower._id, followeeId: followee._id, + requestId, // 非正規化 _follower: { diff --git a/src/services/following/requests/reject.ts b/src/services/following/requests/reject.ts index 91a49db997..73dbbb92e0 100644 --- a/src/services/following/requests/reject.ts +++ b/src/services/following/requests/reject.ts @@ -8,7 +8,12 @@ import { publishMainStream } from '../../../stream'; export default async function(followee: IUser, follower: IUser) { if (isRemoteUser(follower)) { - const content = pack(renderReject(renderFollow(follower, followee))); + const request = await FollowRequest.findOne({ + followeeId: followee._id, + followerId: follower._id + }); + + const content = pack(renderReject(renderFollow(follower, followee, request.requestId), followee as ILocalUser)); deliver(followee as ILocalUser, content, follower.inbox); } diff --git a/src/services/i/pin.ts b/src/services/i/pin.ts index ff390eb781..1544b0fdc7 100644 --- a/src/services/i/pin.ts +++ b/src/services/i/pin.ts @@ -1,7 +1,7 @@ import config from '../../config'; import * as mongo from 'mongodb'; import User, { isLocalUser, isRemoteUser, ILocalUser, IUser } from '../../models/user'; -import Note from '../../models/note'; +import Note, { packMany } from '../../models/note'; import Following from '../../models/following'; import renderAdd from '../../remote/activitypub/renderer/add'; import renderRemove from '../../remote/activitypub/renderer/remove'; @@ -27,11 +27,11 @@ export async function addPinned(user: IUser, noteId: mongo.ObjectID) { let pinnedNoteIds = user.pinnedNoteIds || []; //#region 現在ピン留め投稿している投稿が実際にデータベースに存在しているのかチェック - // データベースの欠損などで存在していない場合があるので。 + // データベースの欠損などで存在していない(または破損している)場合があるので。 // 存在していなかったらピン留め投稿から外す - const pinnedNotes = (await Promise.all(pinnedNoteIds.map(id => Note.findOne({ _id: id })))).filter(x => x != null); + const pinnedNotes = await packMany(pinnedNoteIds, null, { detail: true }); - pinnedNoteIds = pinnedNoteIds.filter(id => pinnedNotes.some(n => n._id.equals(id))); + pinnedNoteIds = pinnedNoteIds.filter(id => pinnedNotes.some(n => n.id.toString() === id.toHexString())); //#endregion if (pinnedNoteIds.length >= 5) { diff --git a/src/services/note/create.ts b/src/services/note/create.ts index 3dc411d434..6d371e370f 100644 --- a/src/services/note/create.ts +++ b/src/services/note/create.ts @@ -8,7 +8,7 @@ import renderNote from '../../remote/activitypub/renderer/note'; import renderCreate from '../../remote/activitypub/renderer/create'; import renderAnnounce from '../../remote/activitypub/renderer/announce'; import packAp from '../../remote/activitypub/renderer'; -import { IDriveFile } from '../../models/drive-file'; +import DriveFile, { IDriveFile } from '../../models/drive-file'; import notify from '../../notify'; import NoteWatching from '../../models/note-watching'; import watch from './watch'; @@ -23,9 +23,13 @@ import registerHashtag from '../register-hashtag'; 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 notesChart from '../../chart/notes'; +import perUserNotesChart from '../../chart/per-user-notes'; + import { erase, unique } from '../../prelude/array'; import insertNoteUnread from './unread'; +import registerInstance from '../register-instance'; +import Instance from '../../models/instance'; type NotificationType = 'reply' | 'renote' | 'quote' | 'mention'; @@ -165,11 +169,37 @@ export default async (user: IUser, data: Option, silent = false) => new Promise< } // 統計を更新 - updateNoteStats(note, true); + notesChart.update(note, true); + perUserNotesChart.update(user, note, true); + + // Register host + if (isRemoteUser(user)) { + registerInstance(user.host).then(i => { + Instance.update({ _id: i._id }, { + $inc: { + notesCount: 1 + } + }); + + // TODO + //perInstanceChart.newNote(); + }); + } // ハッシュタグ登録 tags.map(tag => registerHashtag(user, tag)); + // ファイルが添付されていた場合ドライブのファイルの「このファイルが添付された投稿一覧」プロパティにこの投稿を追加 + if (data.files) { + data.files.forEach(file => { + DriveFile.update({ _id: file._id }, { + $push: { + 'metadata.attachedNoteIds': note._id + } + }); + }); + } + // Increment notes count incNotesCount(user); @@ -284,7 +314,8 @@ async function renderActivity(data: Option, note: INote) { function incRenoteCount(renote: INote) { Note.update({ _id: renote._id }, { $inc: { - renoteCount: 1 + renoteCount: 1, + score: 1 } }); } @@ -537,8 +568,7 @@ function saveQuote(renote: INote, note: INote) { Note.update({ _id: renote._id }, { $push: { _quoteIds: note._id - }, - + } }); } diff --git a/src/services/note/delete.ts b/src/services/note/delete.ts index 2b99b4b85e..599525ac8c 100644 --- a/src/services/note/delete.ts +++ b/src/services/note/delete.ts @@ -6,8 +6,12 @@ import pack from '../../remote/activitypub/renderer'; import { deliver } from '../../queue'; import Following from '../../models/following'; import renderTombstone from '../../remote/activitypub/renderer/tombstone'; -import { updateNoteStats } from '../update-chart'; +import notesChart from '../../chart/notes'; +import perUserNotesChart from '../../chart/per-user-notes'; import config from '../../config'; +import NoteUnread from '../../models/note-unread'; +import read from './read'; +import DriveFile from '../../models/drive-file'; /** * 投稿を削除します。 @@ -36,6 +40,26 @@ export default async function(user: IUser, note: INote) { deletedAt: deletedAt }); + // この投稿が関わる未読通知を削除 + NoteUnread.find({ + noteId: note._id + }).then(unreads => { + unreads.forEach(unread => { + read(unread.userId, unread.noteId); + }); + }); + + // ファイルが添付されていた場合ドライブのファイルの「このファイルが添付された投稿一覧」プロパティからこの投稿を削除 + if (note.fileIds) { + note.fileIds.forEach(fileId => { + DriveFile.update({ _id: fileId }, { + $pull: { + 'metadata.attachedNoteIds': note._id + } + }); + }); + } + //#region ローカルの投稿なら削除アクティビティを配送 if (isLocalUser(user)) { const content = pack(renderDelete(renderTombstone(`${config.url}/notes/${note._id}`), user)); @@ -52,5 +76,6 @@ export default async function(user: IUser, note: INote) { //#endregion // 統計を更新 - updateNoteStats(note, false); + notesChart.update(note, false); + perUserNotesChart.update(user, note, false); } diff --git a/src/services/note/reaction/create.ts b/src/services/note/reaction/create.ts index 6884014e33..4a09cf535f 100644 --- a/src/services/note/reaction/create.ts +++ b/src/services/note/reaction/create.ts @@ -8,6 +8,7 @@ import watch from '../watch'; import renderLike from '../../../remote/activitypub/renderer/like'; import { deliver } from '../../../queue'; import pack from '../../../remote/activitypub/renderer'; +import perUserReactionsChart from '../../../chart/per-user-reactions'; export default async (user: IUser, note: INote, reaction: string) => new Promise(async (res, rej) => { // Myself @@ -26,7 +27,7 @@ export default async (user: IUser, note: INote, reaction: string) => new Promise } catch (e) { // duplicate key error if (e.code === 11000) { - return res(null); + return rej('already reacted'); } console.error(e); @@ -35,16 +36,19 @@ export default async (user: IUser, note: INote, reaction: string) => new Promise res(); - const inc: {[key: string]: number} = {}; - inc[`reactionCounts.${reaction}`] = 1; - // Increment reactions count await Note.update({ _id: note._id }, { - $inc: inc + $inc: { + [`reactionCounts.${reaction}`]: 1, + score: 1 + } }); + perUserReactionsChart.update(user, note); + publishNoteStream(note._id, 'reacted', { - reaction: reaction + reaction: reaction, + userId: user._id }); // リアクションされたユーザーがローカルユーザーなら通知を作成 diff --git a/src/services/note/read.ts b/src/services/note/read.ts index caf5cf318f..f2c1213363 100644 --- a/src/services/note/read.ts +++ b/src/services/note/read.ts @@ -1,4 +1,5 @@ import * as mongo from 'mongodb'; +import isObjectId from '../../misc/is-objectid'; import { publishMainStream } from '../../stream'; import User from '../../models/user'; import NoteUnread from '../../models/note-unread'; @@ -11,11 +12,11 @@ export default ( note: string | mongo.ObjectID ) => new Promise<any>(async (resolve, reject) => { - const userId: mongo.ObjectID = mongo.ObjectID.prototype.isPrototypeOf(user) + const userId: mongo.ObjectID = isObjectId(user) ? user as mongo.ObjectID : new mongo.ObjectID(user); - const noteId: mongo.ObjectID = mongo.ObjectID.prototype.isPrototypeOf(note) + const noteId: mongo.ObjectID = isObjectId(note) ? note as mongo.ObjectID : new mongo.ObjectID(note); diff --git a/src/services/register-hashtag.ts b/src/services/register-hashtag.ts index ca6b74783b..57ba2080f2 100644 --- a/src/services/register-hashtag.ts +++ b/src/services/register-hashtag.ts @@ -1,5 +1,6 @@ import { IUser } from '../models/user'; import Hashtag from '../models/hashtag'; +import hashtagChart from '../chart/hashtag'; export default async function(user: IUser, tag: string) { tag = tag.toLowerCase(); @@ -25,4 +26,6 @@ export default async function(user: IUser, tag: string) { mentionedUserIdsCount: 1 }); } + + hashtagChart.update(tag, user); } diff --git a/src/services/register-instance.ts b/src/services/register-instance.ts new file mode 100644 index 0000000000..6576a9ed06 --- /dev/null +++ b/src/services/register-instance.ts @@ -0,0 +1,22 @@ +import Instance, { IInstance } from '../models/instance'; +import federationChart from '../chart/federation'; + +export default async function(host: string): Promise<IInstance> { + if (host == null) return null; + + const index = await Instance.findOne({ host }); + + if (index == null) { + const i = await Instance.insert({ + host, + caughtAt: new Date(), + system: null // TODO + }); + + federationChart.update(true); + + return i; + } else { + return index; + } +} diff --git a/src/services/update-chart.ts b/src/services/update-chart.ts deleted file mode 100644 index 78834ba601..0000000000 --- a/src/services/update-chart.ts +++ /dev/null @@ -1,267 +0,0 @@ -import { INote } from '../models/note'; -import Stats, { IStats } from '../models/stats'; -import { isLocalUser, IUser } from '../models/user'; -import { IDriveFile } from '../models/drive-file'; - -type Omit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>; - -async function getCurrentStats(span: 'day' | 'hour'): Promise<IStats> { - const now = new Date(); - const y = now.getFullYear(); - const m = now.getMonth(); - const d = now.getDate(); - const h = now.getHours(); - - const current = - span == 'day' ? new Date(y, m, d) : - span == 'hour' ? new Date(y, m, d, h) : - null; - - // 現在(今日または今のHour)の統計 - const currentStats = await Stats.findOne({ - span: span, - date: current - }); - - if (currentStats) { - return currentStats; - } else { - // 集計期間が変わってから、初めてのチャート更新なら - // 最も最近の統計を持ってくる - // * 例えば集計期間が「日」である場合で考えると、 - // * 昨日何もチャートを更新するような出来事がなかった場合は、 - // * 統計がそもそも作られずドキュメントが存在しないということがあり得るため、 - // * 「昨日の」と決め打ちせずに「もっとも最近の」とします - const mostRecentStats = await Stats.findOne({ - span: span - }, { - sort: { - date: -1 - } - }); - - if (mostRecentStats) { - // 現在の統計を初期挿入 - const data: Omit<IStats, '_id'> = { - span: span, - date: current, - users: { - local: { - total: mostRecentStats.users.local.total, - inc: 0, - dec: 0 - }, - remote: { - total: mostRecentStats.users.remote.total, - inc: 0, - dec: 0 - } - }, - notes: { - local: { - total: mostRecentStats.notes.local.total, - inc: 0, - dec: 0, - diffs: { - normal: 0, - reply: 0, - renote: 0 - } - }, - remote: { - total: mostRecentStats.notes.remote.total, - inc: 0, - dec: 0, - diffs: { - normal: 0, - reply: 0, - renote: 0 - } - } - }, - drive: { - local: { - totalCount: mostRecentStats.drive.local.totalCount, - totalSize: mostRecentStats.drive.local.totalSize, - incCount: 0, - incSize: 0, - decCount: 0, - decSize: 0 - }, - remote: { - totalCount: mostRecentStats.drive.remote.totalCount, - totalSize: mostRecentStats.drive.remote.totalSize, - incCount: 0, - incSize: 0, - decCount: 0, - decSize: 0 - } - }, - network: { - requests: 0, - totalTime: 0, - incomingBytes: 0, - outgoingBytes: 0 - } - }; - - const stats = await Stats.insert(data); - - return stats; - } else { - // 統計が存在しなかったら - // * Misskeyインスタンスを建てて初めてのチャート更新時など - - // 空の統計を作成 - const emptyStat: Omit<IStats, '_id'> = { - span: span, - date: current, - users: { - local: { - total: 0, - inc: 0, - dec: 0 - }, - remote: { - total: 0, - inc: 0, - dec: 0 - } - }, - notes: { - local: { - total: 0, - inc: 0, - dec: 0, - diffs: { - normal: 0, - reply: 0, - renote: 0 - } - }, - remote: { - total: 0, - inc: 0, - dec: 0, - diffs: { - normal: 0, - reply: 0, - renote: 0 - } - } - }, - drive: { - local: { - totalCount: 0, - totalSize: 0, - incCount: 0, - incSize: 0, - decCount: 0, - decSize: 0 - }, - remote: { - totalCount: 0, - totalSize: 0, - incCount: 0, - incSize: 0, - decCount: 0, - decSize: 0 - } - }, - network: { - requests: 0, - totalTime: 0, - incomingBytes: 0, - outgoingBytes: 0 - } - }; - - const stats = await Stats.insert(emptyStat); - - return stats; - } - } -} - -function update(inc: any) { - getCurrentStats('day').then(stats => { - Stats.findOneAndUpdate({ - _id: stats._id - }, { - $inc: inc - }); - }); - - getCurrentStats('hour').then(stats => { - Stats.findOneAndUpdate({ - _id: stats._id - }, { - $inc: inc - }); - }); -} - -export async function updateUserStats(user: IUser, isAdditional: boolean) { - const origin = isLocalUser(user) ? 'local' : 'remote'; - - const inc = {} as any; - inc[`users.${origin}.total`] = isAdditional ? 1 : -1; - if (isAdditional) { - inc[`users.${origin}.inc`] = 1; - } else { - inc[`users.${origin}.dec`] = 1; - } - - await update(inc); -} - -export async function updateNoteStats(note: INote, isAdditional: boolean) { - const origin = isLocalUser(note._user) ? 'local' : 'remote'; - - const inc = {} as any; - - inc[`notes.${origin}.total`] = isAdditional ? 1 : -1; - - if (isAdditional) { - inc[`notes.${origin}.inc`] = 1; - } else { - inc[`notes.${origin}.dec`] = 1; - } - - if (note.replyId != null) { - inc[`notes.${origin}.diffs.reply`] = isAdditional ? 1 : -1; - } else if (note.renoteId != null) { - inc[`notes.${origin}.diffs.renote`] = isAdditional ? 1 : -1; - } else { - inc[`notes.${origin}.diffs.normal`] = isAdditional ? 1 : -1; - } - - await update(inc); -} - -export async function updateDriveStats(file: IDriveFile, isAdditional: boolean) { - const origin = isLocalUser(file.metadata._user) ? 'local' : 'remote'; - - const inc = {} as any; - inc[`drive.${origin}.totalCount`] = isAdditional ? 1 : -1; - inc[`drive.${origin}.totalSize`] = isAdditional ? file.length : -file.length; - if (isAdditional) { - inc[`drive.${origin}.incCount`] = 1; - inc[`drive.${origin}.incSize`] = file.length; - } else { - inc[`drive.${origin}.decCount`] = 1; - inc[`drive.${origin}.decSize`] = file.length; - } - - 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 45b353d904..b222a45ca9 100644 --- a/src/stream.ts +++ b/src/stream.ts @@ -1,4 +1,5 @@ import * as mongo from 'mongodb'; +import redis from './db/redis'; import Xev from 'xev'; import Meta, { IMeta } from './models/meta'; @@ -9,7 +10,10 @@ class Publisher { private meta: IMeta; constructor() { - this.ev = new Xev(); + // Redisがインストールされてないときはプロセス間通信を使う + if (redis == null) { + this.ev = new Xev(); + } setInterval(async () => { this.meta = await Meta.findOne({}); @@ -28,7 +32,14 @@ class Publisher { { type: type, body: null } : { type: type, body: value }; - this.ev.emit(channel, message); + if (this.ev) { + this.ev.emit(channel, message); + } else { + redis.publish('misskey', JSON.stringify({ + channel: channel, + message: message + })); + } } public publishMainStream = (userId: ID, type: string, value?: any): void => { diff --git a/src/tools/clean-remote-files.ts b/src/tools/clean-remote-files.ts new file mode 100644 index 0000000000..28c76345c7 --- /dev/null +++ b/src/tools/clean-remote-files.ts @@ -0,0 +1,30 @@ +import * as promiseLimit from 'promise-limit'; +import DriveFile, { IDriveFile } from '../models/drive-file'; +import del from '../services/drive/delete-file'; + +const limit = promiseLimit(16); + +DriveFile.find({ + 'metadata._user.host': { + $ne: null + }, + 'metadata.deletedAt': { $exists: false } +}, { + fields: { + _id: true + } +}).then(async files => { + console.log(`there is ${files.length} files`); + + await Promise.all(files.map(file => limit(() => job(file)))); + + console.log('ALL DONE'); +}); + +async function job(file: IDriveFile): Promise<any> { + file = await DriveFile.findOne({ _id: file._id }); + + await del(file, true); + + console.log('done', file._id); +} diff --git a/src/tools/move-drive-files.ts b/src/tools/move-drive-files.ts new file mode 100644 index 0000000000..8a1e944503 --- /dev/null +++ b/src/tools/move-drive-files.ts @@ -0,0 +1,83 @@ +import * as Minio from 'minio'; +import * as uuid from 'uuid'; +import * as promiseLimit from 'promise-limit'; +import DriveFile, { DriveFileChunk, getDriveFileBucket, IDriveFile } from '../models/drive-file'; +import DriveFileThumbnail, { DriveFileThumbnailChunk } from '../models/drive-file-thumbnail'; +import config from '../config'; + +const limit = promiseLimit(16); + +DriveFile.find({ + $or: [{ + 'metadata.withoutChunks': { $exists: false } + }, { + 'metadata.withoutChunks': false + }], + 'metadata.deletedAt': { $exists: false } +}, { + fields: { + _id: true + } +}).then(async files => { + console.log(`there is ${files.length} files`); + + await Promise.all(files.map(file => limit(() => job(file)))); + + console.log('ALL DONE'); +}); + +async function job(file: IDriveFile): Promise<any> { + file = await DriveFile.findOne({ _id: file._id }); + + const minio = new Minio.Client(config.drive.config); + + const name = file.filename.substr(0, 50); + 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 }`; + + const bucket = await getDriveFileBucket(); + const readable = bucket.openDownloadStream(file._id); + + await minio.putObject(config.drive.bucket, key, readable, file.length, { + 'Content-Type': file.contentType, + 'Cache-Control': 'max-age=31536000, immutable' + }); + + await DriveFile.findOneAndUpdate({ _id: file._id }, { + $set: { + 'metadata.withoutChunks': true, + 'metadata.storage': 'minio', + 'metadata.storageProps': { + key: key, + thumbnailKey: thumbnailKey + }, + 'metadata.url': `${ baseUrl }/${ keyDir }/${ encodeURIComponent(name) }`, + } + }); + + // チャンクをすべて削除 + await DriveFileChunk.remove({ + files_id: file._id + }); + + //#region サムネイルもあれば削除 + const thumbnail = await DriveFileThumbnail.findOne({ + 'metadata.originalId': file._id + }); + + if (thumbnail) { + await DriveFileThumbnailChunk.remove({ + files_id: thumbnail._id + }); + + await DriveFileThumbnail.remove({ _id: thumbnail._id }); + } + //#endregion + + console.log('done', file._id); +} |