diff --git a/CHANGELOG.md b/CHANGELOG.md index ca41d016c1..f8018e4e25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,50 @@ ChangeLog (Release Notes) ========================= 主に notable な changes を書いていきます +2807 (2017/11/02) +----------------- +* いい感じに + +2805 (2017/11/02) +----------------- +* いい感じに + +2801 (2017/11/01) +----------------- +* チャンネルのWatch実装 + +2799 (2017/11/01) +----------------- +* いい感じに + +2795 (2017/11/01) +----------------- +* いい感じに + +2793 (2017/11/01) +----------------- +* なんか + +2783 (2017/11/01) +----------------- +* なんか + +2777 (2017/11/01) +----------------- +* 細かいブラッシュアップ + +2775 (2017/11/01) +----------------- +* Fix: バグ修正 + +2769 (2017/11/01) +----------------- +* New: チャンネルシステム + +2752 (2017/10/30) +----------------- +* New: 未読の通知がある場合アイコンを表示するように + 2747 (2017/10/25) ----------------- * Fix: 非ログイン状態ですべてのページが致命的な問題を発生させる (#89) diff --git a/docs/setup.en.md b/docs/setup.en.md index 3e48935346..dbc0599b5a 100644 --- a/docs/setup.en.md +++ b/docs/setup.en.md @@ -25,6 +25,7 @@ Note that Misskey uses following subdomains: * **api**.*{primary domain}* * **auth**.*{primary domain}* * **about**.*{primary domain}* +* **ch**.*{primary domain}* * **stats**.*{primary domain}* * **status**.*{primary domain}* * **dev**.*{primary domain}* diff --git a/docs/setup.ja.md b/docs/setup.ja.md index 4f48a08088..602fd9b6a1 100644 --- a/docs/setup.ja.md +++ b/docs/setup.ja.md @@ -26,6 +26,7 @@ Misskeyは以下のサブドメインを使います: * **api**.*{primary domain}* * **auth**.*{primary domain}* * **about**.*{primary domain}* +* **ch**.*{primary domain}* * **stats**.*{primary domain}* * **status**.*{primary domain}* * **dev**.*{primary domain}* diff --git a/locales/en.yml b/locales/en.yml index 03d5306d3e..52e8dfdb4b 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -164,6 +164,19 @@ common: mk-uploader: waiting: "Waiting" +ch: + tags: + mk-index: + new: "Create new channel" + channel-title: "Channel title" + + mk-channel-form: + textarea: "Write here" + upload: "Upload" + drive: "Drive" + post: "Do" + posting: "Doing" + desktop: tags: mk-api-info: @@ -241,6 +254,7 @@ desktop: mk-ui-header-nav: home: "Home" messaging: "Messages" + ch: "Channels" info: "News" mk-ui-header-search: @@ -353,6 +367,9 @@ desktop: mobile: tags: + mk-selectdrive-page: + select-file: "Select file(s)" + mk-drive-file-viewer: download: "Download" rename: "Rename" @@ -389,6 +406,7 @@ mobile: mk-notifications-page: notifications: "Notifications" + read-all: "Are you sure you want to mark all unread notifications as read?" mk-post-page: title: "Post" @@ -490,6 +508,7 @@ mobile: home: "Home" notifications: "Notifications" messaging: "Messages" + ch: "Channels" drive: "Drive" settings: "Settings" about: "About Misskey" diff --git a/locales/ja.yml b/locales/ja.yml index b640f0f248..dcd012bb89 100644 --- a/locales/ja.yml +++ b/locales/ja.yml @@ -84,7 +84,7 @@ common: no-internet: "インターネットに接続されていません" no-internet-desc: "ネットワークには接続されていますが、インターネットには接続されていないようです。お使いのPCのインターネット接続が正常か確認してください。" no-server: "Misskeyのサーバーに接続できません" - no-server-desc: "お使いのPCのネットワーク接続は正常ですが、Misskeyのサーバーには接続できませんでした。サーバーがダウンまたはメンテナンスしている可能性があるので、しばらくしてから再度御アクセスください。" + no-server-desc: "お使いのPCのインターネット接続は正常ですが、Misskeyのサーバーには接続できませんでした。サーバーがダウンまたはメンテナンスしている可能性があるので、しばらくしてから再度御アクセスください。" success: "Misskeyのサーバーに接続できました" success-desc: "正常に接続できるようです。ページを再度読み込みしてください。" @@ -164,6 +164,19 @@ common: mk-uploader: waiting: "待機中" +ch: + tags: + mk-index: + new: "チャンネルを作成" + channel-title: "チャンネルのタイトル" + + mk-channel-form: + textarea: "書いて" + upload: "アップロード" + drive: "ドライブ" + post: "やる" + posting: "やってます" + desktop: tags: mk-api-info: @@ -241,6 +254,7 @@ desktop: mk-ui-header-nav: home: "ホーム" messaging: "メッセージ" + ch: "チャンネル" info: "お知らせ" mk-ui-header-search: @@ -353,6 +367,9 @@ desktop: mobile: tags: + mk-selectdrive-page: + select-file: "ファイルを選択" + mk-drive-file-viewer: download: "ダウンロード" rename: "名前を変更" @@ -389,6 +406,7 @@ mobile: mk-notifications-page: notifications: "通知" + read-all: "すべての通知を既読にしますか?" mk-post-page: title: "投稿" @@ -490,6 +508,7 @@ mobile: home: "ホーム" notifications: "通知" messaging: "メッセージ" + ch: "チャンネル" search: "検索" drive: "ドライブ" settings: "設定" diff --git a/package.json b/package.json index 8b32db9a87..dd0da40d70 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "misskey", "author": "syuilo <i@syuilo.com>", - "version": "0.0.2747", + "version": "0.0.2807", "license": "MIT", "description": "A miniblog-based SNS", "bugs": "https://github.com/syuilo/misskey/issues", @@ -18,22 +18,22 @@ "clean": "gulp clean", "cleanall": "gulp cleanall", "lint": "gulp lint", - "test": "gulp test" + "test": "gulp test" }, "devDependencies": { - "@types/bcryptjs": "2.4.0", + "@types/bcryptjs": "2.4.1", "@types/body-parser": "1.16.7", "@types/chai": "4.0.4", "@types/chai-http": "3.0.3", - "@types/chalk": "0.4.31", + "@types/chalk": "2.2.0", "@types/compression": "0.0.34", "@types/cors": "2.8.1", "@types/debug": "0.0.30", "@types/deep-equal": "1.0.1", - "@types/elasticsearch": "5.0.14", + "@types/elasticsearch": "5.0.17", "@types/event-stream": "3.3.32", "@types/express": "4.0.37", - "@types/gm": "1.17.32", + "@types/gm": "1.17.33", "@types/gulp": "4.0.3", "@types/gulp-htmlmin": "1.3.30", "@types/gulp-mocha": "0.0.30", @@ -41,30 +41,32 @@ "@types/gulp-replace": "0.0.30", "@types/gulp-tslint": "3.6.31", "@types/gulp-typescript": "2.13.0", - "@types/gulp-uglify": "0.0.30", - "@types/gulp-util": "3.0.31", + "@types/gulp-uglify": "3.0.3", + "@types/gulp-util": "3.0.33", "@types/inquirer": "0.0.34", "@types/is-root": "1.0.0", "@types/is-url": "1.2.28", - "@types/js-yaml": "3.9.0", - "@types/mocha": "2.2.43", + "@types/js-yaml": "3.9.1", + "@types/mocha": "2.2.44", "@types/mongodb": "2.2.13", "@types/monk": "1.0.6", - "@types/morgan": "1.7.33", + "@types/morgan": "1.7.35", "@types/ms": "0.7.30", - "@types/multer": "1.3.2", - "@types/node": "8.0.33", + "@types/multer": "1.3.5", + "@types/node": "8.0.47", "@types/ratelimiter": "2.1.28", - "@types/redis": "2.6.0", - "@types/request": "2.0.4", - "@types/rimraf": "2.0.0", - "@types/riot": "3.6.0", - "@types/serve-favicon": "2.2.28", - "@types/uuid": "3.4.2", + "@types/redis": "2.8.1", + "@types/request": "2.0.7", + "@types/rimraf": "2.0.2", + "@types/riot": "3.6.1", + "@types/serve-favicon": "2.2.29", + "@types/uuid": "3.4.3", + "@types/webpack": "3.0.14", + "@types/uuid": "3.4.3", "@types/webpack": "3.0.13", - "@types/webpack-stream": "3.2.7", + "@types/webpack-stream": "3.2.8", "@types/websocket": "0.0.34", - "awesome-typescript-loader": "3.2.3", + "awesome-typescript-loader": "3.3.0", "chai": "4.1.2", "chai-http": "3.0.0", "css-loader": "0.28.7", @@ -78,30 +80,31 @@ "gulp-rename": "1.2.2", "gulp-replace": "0.6.1", "gulp-tslint": "8.1.2", - "gulp-typescript": "3.2.2", + "gulp-typescript": "3.2.3", "gulp-uglify": "3.0.0", "gulp-util": "3.0.8", - "mocha": "3.5.3", + "mocha": "4.0.1", "riot-tag-loader": "1.0.0", "string-replace-webpack-plugin": "0.1.3", "style-loader": "0.19.0", "stylus": "0.54.5", "stylus-loader": "3.0.1", "swagger-jsdoc": "1.9.7", - "tslint": "5.7.0", + "tslint": "5.8.0", "uglify-es": "3.0.27", "uglify-js": "git+https://github.com/mishoo/UglifyJS2.git#harmony", - "uglifyjs-webpack-plugin": "1.0.0-beta.2", + "uglifyjs-webpack-plugin": "1.0.1", "webpack": "3.8.1" }, "dependencies": { + "@prezzemolo/rap": "0.1.2", "accesses": "2.5.0", "animejs": "2.2.0", "autwh": "0.0.1", "bcryptjs": "2.4.3", "body-parser": "1.18.2", "cafy": "3.0.0", - "chalk": "2.1.0", + "chalk": "2.3.0", "compression": "1.7.1", "cors": "2.8.4", "cropperjs": "1.1.3", @@ -114,7 +117,7 @@ "elasticsearch": "13.3.1", "escape-regexp": "0.0.1", "express": "4.15.4", - "file-type": "6.2.0", + "file-type": "7.2.0", "fuckadblock": "3.2.1", "gm": "1.23.0", "inquirer": "3.3.0", @@ -140,7 +143,7 @@ "redis": "2.8.0", "request": "2.83.0", "rimraf": "2.6.2", - "riot": "3.7.3", + "riot": "3.7.4", "rndstr": "1.0.0", "s-age": "1.1.0", "serve-favicon": "2.4.5", @@ -149,7 +152,7 @@ "tcp-port-used": "0.1.2", "textarea-caret": "3.0.2", "ts-node": "3.3.0", - "typescript": "2.5.3", + "typescript": "2.6.1", "uuid": "3.1.0", "vhost": "3.0.2", "websocket": "1.0.25", diff --git a/src/api/common/add-file-to-drive.ts b/src/api/common/add-file-to-drive.ts index 714eeb520d..f9c22ccacd 100644 --- a/src/api/common/add-file-to-drive.ts +++ b/src/api/common/add-file-to-drive.ts @@ -4,14 +4,27 @@ import * as gm from 'gm'; import * as debug from 'debug'; import fileType = require('file-type'); import prominence = require('prominence'); -import DriveFile from '../models/drive-file'; +import DriveFile, { getGridFSBucket } from '../models/drive-file'; import DriveFolder from '../models/drive-folder'; import serialize from '../serializers/drive-file'; import event from '../event'; import config from '../../conf'; +import { Duplex } from 'stream'; const log = debug('misskey:register-drive-file'); +const addToGridFS = (name, binary, metadata): Promise<any> => new Promise(async (resolve, reject) => { + const dataStream = new Duplex(); + dataStream.push(binary); + dataStream.push(null); + + const bucket = await getGridFSBucket(); + const writeStream = bucket.openUploadStream(name, { metadata }); + writeStream.once('finish', (doc) => { resolve(doc); }); + writeStream.on('error', reject); + dataStream.pipe(writeStream); +}); + /** * Add file to drive * @@ -58,7 +71,7 @@ export default ( // Generate hash const hash = crypto - .createHash('sha256') + .createHash('md5') .update(data) .digest('hex') as string; @@ -67,8 +80,8 @@ export default ( if (!force) { // Check if there is a file with the same hash const much = await DriveFile.findOne({ - user_id: user._id, - hash: hash + md5: hash, + 'metadata.user_id': user._id }); if (much !== null) { @@ -82,13 +95,13 @@ export default ( // Calculate drive usage const usage = ((await DriveFile .aggregate([ - { $match: { user_id: user._id } }, + { $match: { 'metadata.user_id': user._id } }, { $project: { - datasize: true + length: true }}, { $group: { _id: null, - usage: { $sum: '$datasize' } + usage: { $sum: '$length' } }} ]))[0] || { usage: 0 @@ -131,21 +144,15 @@ export default ( } // Create DriveFile document - const file = await DriveFile.insert({ - created_at: new Date(), + const file = await addToGridFS(`${user._id}/${name}`, data, { user_id: user._id, folder_id: folder !== null ? folder._id : null, - data: data, - datasize: size, type: mime, name: name, comment: comment, - hash: hash, properties: properties }); - delete file.data; - log(`drive file has been created ${file._id}`); resolve(file); diff --git a/src/api/common/read-notification.ts b/src/api/common/read-notification.ts new file mode 100644 index 0000000000..3009cc5d08 --- /dev/null +++ b/src/api/common/read-notification.ts @@ -0,0 +1,52 @@ +import * as mongo from 'mongodb'; +import { default as Notification, INotification } from '../models/notification'; +import publishUserStream from '../event'; + +/** + * Mark as read notification(s) + */ +export default ( + user: string | mongo.ObjectID, + message: string | string[] | INotification | INotification[] | mongo.ObjectID | mongo.ObjectID[] +) => new Promise<any>(async (resolve, reject) => { + + const userId = mongo.ObjectID.prototype.isPrototypeOf(user) + ? user + : new mongo.ObjectID(user); + + const ids: mongo.ObjectID[] = Array.isArray(message) + ? mongo.ObjectID.prototype.isPrototypeOf(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) + ? [(message as mongo.ObjectID)] + : typeof message === 'string' + ? [new mongo.ObjectID(message)] + : [(message as INotification)._id]; + + // Update documents + await Notification.update({ + _id: { $in: ids }, + is_read: false + }, { + $set: { + is_read: true + } + }, { + multi: true + }); + + // Calc count of my unread notifications + const count = await Notification + .count({ + notifiee_id: userId, + is_read: false + }); + + if (count == 0) { + // 全ての(いままで未読だった)通知を(これで)読みましたよというイベントを発行 + publishUserStream(userId, 'read_all_notifications'); + } +}); diff --git a/src/api/endpoints.ts b/src/api/endpoints.ts index f05762340c..afefce39e5 100644 --- a/src/api/endpoints.ts +++ b/src/api/endpoints.ts @@ -195,6 +195,11 @@ const endpoints: Endpoint[] = [ withCredential: true, kind: 'notification-read' }, + { + name: 'notifications/get_unread_count', + withCredential: true, + kind: 'notification-read' + }, { name: 'notifications/delete', withCredential: true, @@ -205,11 +210,6 @@ const endpoints: Endpoint[] = [ withCredential: true, kind: 'notification-write' }, - { - name: 'notifications/mark_as_read', - withCredential: true, - kind: 'notification-write' - }, { name: 'notifications/mark_as_read_all', withCredential: true, @@ -474,8 +474,33 @@ const endpoints: Endpoint[] = [ name: 'messaging/messages/create', withCredential: true, kind: 'messaging-write' - } - + }, + { + name: 'channels/create', + withCredential: true, + limit: { + duration: ms('1hour'), + max: 3, + minInterval: ms('10seconds') + } + }, + { + name: 'channels/show' + }, + { + name: 'channels/posts' + }, + { + name: 'channels/watch', + withCredential: true + }, + { + name: 'channels/unwatch', + withCredential: true + }, + { + name: 'channels' + }, ]; export default endpoints; diff --git a/src/api/endpoints/aggregation/posts.ts b/src/api/endpoints/aggregation/posts.ts index 48ee225129..9d8bccbdb2 100644 --- a/src/api/endpoints/aggregation/posts.ts +++ b/src/api/endpoints/aggregation/posts.ts @@ -19,7 +19,7 @@ module.exports = params => new Promise(async (res, rej) => { .aggregate([ { $project: { repost_id: '$repost_id', - reply_to_id: '$reply_to_id', + reply_id: '$reply_id', created_at: { $add: ['$created_at', 9 * 60 * 60 * 1000] } // Convert into JST }}, { $project: { @@ -34,7 +34,7 @@ module.exports = params => new Promise(async (res, rej) => { then: 'repost', else: { $cond: { - if: { $ne: ['$reply_to_id', null] }, + if: { $ne: ['$reply_id', null] }, then: 'reply', else: 'post' } diff --git a/src/api/endpoints/aggregation/posts/reply.ts b/src/api/endpoints/aggregation/posts/reply.ts index 02a60c8969..b114c34e1e 100644 --- a/src/api/endpoints/aggregation/posts/reply.ts +++ b/src/api/endpoints/aggregation/posts/reply.ts @@ -26,7 +26,7 @@ module.exports = (params) => new Promise(async (res, rej) => { const datas = await Post .aggregate([ - { $match: { reply_to: post._id } }, + { $match: { reply: post._id } }, { $project: { created_at: { $add: ['$created_at', 9 * 60 * 60 * 1000] } // Convert into JST }}, diff --git a/src/api/endpoints/aggregation/users/activity.ts b/src/api/endpoints/aggregation/users/activity.ts index 5a3e78c441..102a71d7cb 100644 --- a/src/api/endpoints/aggregation/users/activity.ts +++ b/src/api/endpoints/aggregation/users/activity.ts @@ -40,7 +40,7 @@ module.exports = (params) => new Promise(async (res, rej) => { { $match: { user_id: user._id } }, { $project: { repost_id: '$repost_id', - reply_to_id: '$reply_to_id', + reply_id: '$reply_id', created_at: { $add: ['$created_at', 9 * 60 * 60 * 1000] } // Convert into JST }}, { $project: { @@ -55,7 +55,7 @@ module.exports = (params) => new Promise(async (res, rej) => { then: 'repost', else: { $cond: { - if: { $ne: ['$reply_to_id', null] }, + if: { $ne: ['$reply_id', null] }, then: 'reply', else: 'post' } diff --git a/src/api/endpoints/aggregation/users/post.ts b/src/api/endpoints/aggregation/users/post.ts index c964815a0c..c6a75eee39 100644 --- a/src/api/endpoints/aggregation/users/post.ts +++ b/src/api/endpoints/aggregation/users/post.ts @@ -34,7 +34,7 @@ module.exports = (params) => new Promise(async (res, rej) => { { $match: { user_id: user._id } }, { $project: { repost_id: '$repost_id', - reply_to_id: '$reply_to_id', + reply_id: '$reply_id', created_at: { $add: ['$created_at', 9 * 60 * 60 * 1000] } // Convert into JST }}, { $project: { @@ -49,7 +49,7 @@ module.exports = (params) => new Promise(async (res, rej) => { then: 'repost', else: { $cond: { - if: { $ne: ['$reply_to_id', null] }, + if: { $ne: ['$reply_id', null] }, then: 'reply', else: 'post' } diff --git a/src/api/endpoints/channels.ts b/src/api/endpoints/channels.ts new file mode 100644 index 0000000000..e10c943896 --- /dev/null +++ b/src/api/endpoints/channels.ts @@ -0,0 +1,59 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import Channel from '../models/channel'; +import serialize from '../serializers/channel'; + +/** + * Get all channels + * + * @param {any} params + * @param {any} me + * @return {Promise<any>} + */ +module.exports = (params, me) => new Promise(async (res, rej) => { + // Get 'limit' parameter + const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$; + if (limitErr) return rej('invalid limit param'); + + // Get 'since_id' parameter + const [sinceId, sinceIdErr] = $(params.since_id).optional.id().$; + if (sinceIdErr) return rej('invalid since_id param'); + + // Get 'max_id' parameter + const [maxId, maxIdErr] = $(params.max_id).optional.id().$; + if (maxIdErr) return rej('invalid max_id param'); + + // Check if both of since_id and max_id is specified + if (sinceId && maxId) { + return rej('cannot set since_id and max_id'); + } + + // Construct query + const sort = { + _id: -1 + }; + const query = {} as any; + if (sinceId) { + sort._id = 1; + query._id = { + $gt: sinceId + }; + } else if (maxId) { + query._id = { + $lt: maxId + }; + } + + // Issue query + const channels = await Channel + .find(query, { + limit: limit, + sort: sort + }); + + // Serialize + res(await Promise.all(channels.map(async channel => + await serialize(channel, me)))); +}); diff --git a/src/api/endpoints/channels/create.ts b/src/api/endpoints/channels/create.ts new file mode 100644 index 0000000000..a8d7c29dc1 --- /dev/null +++ b/src/api/endpoints/channels/create.ts @@ -0,0 +1,39 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import Channel from '../../models/channel'; +import Watching from '../../models/channel-watching'; +import serialize from '../../serializers/channel'; + +/** + * Create a channel + * + * @param {any} params + * @param {any} user + * @return {Promise<any>} + */ +module.exports = async (params, user) => new Promise(async (res, rej) => { + // Get 'title' parameter + const [title, titleErr] = $(params.title).string().range(1, 100).$; + if (titleErr) return rej('invalid title param'); + + // Create a channel + const channel = await Channel.insert({ + created_at: new Date(), + user_id: user._id, + title: title, + index: 0, + watching_count: 1 + }); + + // Response + res(await serialize(channel)); + + // Create Watching + await Watching.insert({ + created_at: new Date(), + user_id: user._id, + channel_id: channel._id + }); +}); diff --git a/src/api/endpoints/channels/posts.ts b/src/api/endpoints/channels/posts.ts new file mode 100644 index 0000000000..fa91fb93ee --- /dev/null +++ b/src/api/endpoints/channels/posts.ts @@ -0,0 +1,79 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import { default as Channel, IChannel } from '../../models/channel'; +import { default as Post, IPost } from '../../models/post'; +import serialize from '../../serializers/post'; + +/** + * Show a posts of a channel + * + * @param {any} params + * @param {any} user + * @return {Promise<any>} + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + // Get 'limit' parameter + const [limit = 1000, limitErr] = $(params.limit).optional.number().range(1, 1000).$; + if (limitErr) return rej('invalid limit param'); + + // Get 'since_id' parameter + const [sinceId, sinceIdErr] = $(params.since_id).optional.id().$; + if (sinceIdErr) return rej('invalid since_id param'); + + // Get 'max_id' parameter + const [maxId, maxIdErr] = $(params.max_id).optional.id().$; + if (maxIdErr) return rej('invalid max_id param'); + + // Check if both of since_id and max_id is specified + if (sinceId && maxId) { + return rej('cannot set since_id and max_id'); + } + + // Get 'channel_id' parameter + const [channelId, channelIdErr] = $(params.channel_id).id().$; + if (channelIdErr) return rej('invalid channel_id param'); + + // Fetch channel + const channel: IChannel = await Channel.findOne({ + _id: channelId + }); + + if (channel === null) { + return rej('channel not found'); + } + + //#region Construct query + const sort = { + _id: -1 + }; + + const query = { + channel_id: channel._id + } as any; + + if (sinceId) { + sort._id = 1; + query._id = { + $gt: sinceId + }; + } else if (maxId) { + query._id = { + $lt: maxId + }; + } + //#endregion Construct query + + // Issue query + const posts = await Post + .find(query, { + limit: limit, + sort: sort + }); + + // Serialize + res(await Promise.all(posts.map(async (post) => + await serialize(post, user) + ))); +}); diff --git a/src/api/endpoints/channels/show.ts b/src/api/endpoints/channels/show.ts new file mode 100644 index 0000000000..8861e54594 --- /dev/null +++ b/src/api/endpoints/channels/show.ts @@ -0,0 +1,31 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import { default as Channel, IChannel } from '../../models/channel'; +import serialize from '../../serializers/channel'; + +/** + * Show a channel + * + * @param {any} params + * @param {any} user + * @return {Promise<any>} + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + // Get 'channel_id' parameter + const [channelId, channelIdErr] = $(params.channel_id).id().$; + if (channelIdErr) return rej('invalid channel_id param'); + + // Fetch channel + const channel: IChannel = await Channel.findOne({ + _id: channelId + }); + + if (channel === null) { + return rej('channel not found'); + } + + // Serialize + res(await serialize(channel, user)); +}); diff --git a/src/api/endpoints/channels/unwatch.ts b/src/api/endpoints/channels/unwatch.ts new file mode 100644 index 0000000000..19d3be118a --- /dev/null +++ b/src/api/endpoints/channels/unwatch.ts @@ -0,0 +1,60 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import Channel from '../../models/channel'; +import Watching from '../../models/channel-watching'; + +/** + * Unwatch a channel + * + * @param {any} params + * @param {any} user + * @return {Promise<any>} + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + // Get 'channel_id' parameter + const [channelId, channelIdErr] = $(params.channel_id).id().$; + if (channelIdErr) return rej('invalid channel_id param'); + + //#region Fetch channel + const channel = await Channel.findOne({ + _id: channelId + }); + + if (channel === null) { + return rej('channel not found'); + } + //#endregion + + //#region Check whether not watching + const exist = await Watching.findOne({ + user_id: user._id, + channel_id: channel._id, + deleted_at: { $exists: false } + }); + + if (exist === null) { + return rej('already not watching'); + } + //#endregion + + // Delete watching + await Watching.update({ + _id: exist._id + }, { + $set: { + deleted_at: new Date() + } + }); + + // Send response + res(); + + // Decrement watching count + Channel.update(channel._id, { + $inc: { + watching_count: -1 + } + }); +}); diff --git a/src/api/endpoints/channels/watch.ts b/src/api/endpoints/channels/watch.ts new file mode 100644 index 0000000000..030e0dd411 --- /dev/null +++ b/src/api/endpoints/channels/watch.ts @@ -0,0 +1,58 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import Channel from '../../models/channel'; +import Watching from '../../models/channel-watching'; + +/** + * Watch a channel + * + * @param {any} params + * @param {any} user + * @return {Promise<any>} + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + // Get 'channel_id' parameter + const [channelId, channelIdErr] = $(params.channel_id).id().$; + if (channelIdErr) return rej('invalid channel_id param'); + + //#region Fetch channel + const channel = await Channel.findOne({ + _id: channelId + }); + + if (channel === null) { + return rej('channel not found'); + } + //#endregion + + //#region Check whether already watching + const exist = await Watching.findOne({ + user_id: user._id, + channel_id: channel._id, + deleted_at: { $exists: false } + }); + + if (exist !== null) { + return rej('already watching'); + } + //#endregion + + // Create Watching + await Watching.insert({ + created_at: new Date(), + user_id: user._id, + channel_id: channel._id + }); + + // Send response + res(); + + // Increment watching count + Channel.update(channel._id, { + $inc: { + watching_count: 1 + } + }); +}); diff --git a/src/api/endpoints/drive.ts b/src/api/endpoints/drive.ts index 41ad6301d7..d92473633a 100644 --- a/src/api/endpoints/drive.ts +++ b/src/api/endpoints/drive.ts @@ -14,16 +14,16 @@ module.exports = (params, user) => new Promise(async (res, rej) => { // Calculate drive usage const usage = ((await DriveFile .aggregate([ - { $match: { user_id: user._id } }, + { $match: { 'metadata.user_id': user._id } }, { $project: { - datasize: true + length: true } }, { $group: { _id: null, - usage: { $sum: '$datasize' } + usage: { $sum: '$length' } } } ]))[0] || { diff --git a/src/api/endpoints/drive/files.ts b/src/api/endpoints/drive/files.ts index a68ae34817..53b48a8bec 100644 --- a/src/api/endpoints/drive/files.ts +++ b/src/api/endpoints/drive/files.ts @@ -13,35 +13,35 @@ import serialize from '../../serializers/drive-file'; * @param {any} app * @return {Promise<any>} */ -module.exports = (params, user, app) => new Promise(async (res, rej) => { +module.exports = async (params, user, app) => { // Get 'limit' parameter const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$; - if (limitErr) return rej('invalid limit param'); + if (limitErr) throw 'invalid limit param'; // Get 'since_id' parameter const [sinceId, sinceIdErr] = $(params.since_id).optional.id().$; - if (sinceIdErr) return rej('invalid since_id param'); + if (sinceIdErr) throw 'invalid since_id param'; // Get 'max_id' parameter const [maxId, maxIdErr] = $(params.max_id).optional.id().$; - if (maxIdErr) return rej('invalid max_id param'); + if (maxIdErr) throw 'invalid max_id param'; // Check if both of since_id and max_id is specified if (sinceId && maxId) { - return rej('cannot set since_id and max_id'); + throw 'cannot set since_id and max_id'; } // Get 'folder_id' parameter const [folderId = null, folderIdErr] = $(params.folder_id).optional.nullable.id().$; - if (folderIdErr) return rej('invalid folder_id param'); + if (folderIdErr) throw 'invalid folder_id param'; // Construct query const sort = { _id: -1 }; const query = { - user_id: user._id, - folder_id: folderId + 'metadata.user_id': user._id, + 'metadata.folder_id': folderId } as any; if (sinceId) { sort._id = 1; @@ -57,14 +57,11 @@ module.exports = (params, user, app) => new Promise(async (res, rej) => { // Issue query const files = await DriveFile .find(query, { - fields: { - data: false - }, limit: limit, sort: sort }); // Serialize - res(await Promise.all(files.map(async file => - await serialize(file)))); -}); + const _files = await Promise.all(files.map(file => serialize(file))); + return _files; +}; diff --git a/src/api/endpoints/drive/files/find.ts b/src/api/endpoints/drive/files/find.ts index cd0b33f2ca..1c818131d7 100644 --- a/src/api/endpoints/drive/files/find.ts +++ b/src/api/endpoints/drive/files/find.ts @@ -24,13 +24,9 @@ module.exports = (params, user) => new Promise(async (res, rej) => { // Issue query const files = await DriveFile .find({ - name: name, - user_id: user._id, - folder_id: folderId - }, { - fields: { - data: false - } + 'metadata.name': name, + 'metadata.user_id': user._id, + 'metadata.folder_id': folderId }); // Serialize diff --git a/src/api/endpoints/drive/files/show.ts b/src/api/endpoints/drive/files/show.ts index 8dbc297e4f..3c7cf774f9 100644 --- a/src/api/endpoints/drive/files/show.ts +++ b/src/api/endpoints/drive/files/show.ts @@ -12,28 +12,26 @@ import serialize from '../../../serializers/drive-file'; * @param {any} user * @return {Promise<any>} */ -module.exports = (params, user) => new Promise(async (res, rej) => { +module.exports = async (params, user) => { // Get 'file_id' parameter const [fileId, fileIdErr] = $(params.file_id).id().$; - if (fileIdErr) return rej('invalid file_id param'); + if (fileIdErr) throw 'invalid file_id param'; // Fetch file const file = await DriveFile .findOne({ _id: fileId, - user_id: user._id - }, { - fields: { - data: false - } + 'metadata.user_id': user._id }); if (file === null) { - return rej('file-not-found'); + throw 'file-not-found'; } // Serialize - res(await serialize(file, { + const _file = await serialize(file, { detail: true - })); -}); + }); + + return _file; +}; diff --git a/src/api/endpoints/drive/files/update.ts b/src/api/endpoints/drive/files/update.ts index 1cfbdd8f0b..d7b858c2ba 100644 --- a/src/api/endpoints/drive/files/update.ts +++ b/src/api/endpoints/drive/files/update.ts @@ -24,11 +24,7 @@ module.exports = (params, user) => new Promise(async (res, rej) => { const file = await DriveFile .findOne({ _id: fileId, - user_id: user._id - }, { - fields: { - data: false - } + 'metadata.user_id': user._id }); if (file === null) { @@ -38,7 +34,7 @@ module.exports = (params, user) => new Promise(async (res, rej) => { // Get 'name' parameter const [name, nameErr] = $(params.name).optional.string().pipe(validateFileName).$; if (nameErr) return rej('invalid name param'); - if (name) file.name = name; + if (name) file.metadata.name = name; // Get 'folder_id' parameter const [folderId, folderIdErr] = $(params.folder_id).optional.nullable.id().$; @@ -46,7 +42,7 @@ module.exports = (params, user) => new Promise(async (res, rej) => { if (folderId !== undefined) { if (folderId === null) { - file.folder_id = null; + file.metadata.folder_id = null; } else { // Fetch folder const folder = await DriveFolder @@ -59,14 +55,14 @@ module.exports = (params, user) => new Promise(async (res, rej) => { return rej('folder-not-found'); } - file.folder_id = folder._id; + file.metadata.folder_id = folder._id; } } - DriveFile.update(file._id, { + await DriveFile.update(file._id, { $set: { - name: file.name, - folder_id: file.folder_id + 'metadata.name': file.metadata.name, + 'metadata.folder_id': file.metadata.folder_id } }); diff --git a/src/api/endpoints/drive/folders/find.ts b/src/api/endpoints/drive/folders/find.ts index cdf055839a..a5eb8e015d 100644 --- a/src/api/endpoints/drive/folders/find.ts +++ b/src/api/endpoints/drive/folders/find.ts @@ -30,6 +30,5 @@ module.exports = (params, user) => new Promise(async (res, rej) => { }); // Serialize - res(await Promise.all(folders.map(async folder => - await serialize(folder)))); + res(await Promise.all(folders.map(folder => serialize(folder)))); }); diff --git a/src/api/endpoints/drive/folders/update.ts b/src/api/endpoints/drive/folders/update.ts index eec2757878..4f2e3d2a7a 100644 --- a/src/api/endpoints/drive/folders/update.ts +++ b/src/api/endpoints/drive/folders/update.ts @@ -4,7 +4,7 @@ import $ from 'cafy'; import DriveFolder from '../../../models/drive-folder'; import { isValidFolderName } from '../../../models/drive-folder'; -import serialize from '../../../serializers/drive-file'; +import serialize from '../../../serializers/drive-folder'; import event from '../../../event'; /** diff --git a/src/api/endpoints/i/notifications.ts b/src/api/endpoints/i/notifications.ts index 5575fb7412..607e0768a4 100644 --- a/src/api/endpoints/i/notifications.ts +++ b/src/api/endpoints/i/notifications.ts @@ -5,6 +5,7 @@ import $ from 'cafy'; import Notification from '../../models/notification'; import serialize from '../../serializers/notification'; import getFriends from '../../common/get-friends'; +import read from '../../common/read-notification'; /** * Get notifications @@ -91,17 +92,6 @@ module.exports = (params, user) => new Promise(async (res, rej) => { // Mark as read all if (notifications.length > 0 && markAsRead) { - const ids = notifications - .filter(x => x.is_read == false) - .map(x => x._id); - - // Update documents - await Notification.update({ - _id: { $in: ids } - }, { - $set: { is_read: true } - }, { - multi: true - }); + read(user._id, notifications); } }); diff --git a/src/api/endpoints/messaging/messages/create.ts b/src/api/endpoints/messaging/messages/create.ts index 8af55d850c..149852c093 100644 --- a/src/api/endpoints/messaging/messages/create.ts +++ b/src/api/endpoints/messaging/messages/create.ts @@ -54,9 +54,7 @@ module.exports = (params, user) => new Promise(async (res, rej) => { if (fileId !== undefined) { file = await DriveFile.findOne({ _id: fileId, - user_id: user._id - }, { - data: false + 'metadata.user_id': user._id }); if (file === null) { diff --git a/src/api/endpoints/notifications/get_unread_count.ts b/src/api/endpoints/notifications/get_unread_count.ts new file mode 100644 index 0000000000..9514e78713 --- /dev/null +++ b/src/api/endpoints/notifications/get_unread_count.ts @@ -0,0 +1,23 @@ +/** + * Module dependencies + */ +import Notification from '../../models/notification'; + +/** + * Get count of unread notifications + * + * @param {any} params + * @param {any} user + * @return {Promise<any>} + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + const count = await Notification + .count({ + notifiee_id: user._id, + is_read: false + }); + + res({ + count: count + }); +}); diff --git a/src/api/endpoints/notifications/mark_as_read.ts b/src/api/endpoints/notifications/mark_as_read.ts deleted file mode 100644 index 5cce33e850..0000000000 --- a/src/api/endpoints/notifications/mark_as_read.ts +++ /dev/null @@ -1,47 +0,0 @@ -/** - * Module dependencies - */ -import $ from 'cafy'; -import Notification from '../../models/notification'; -import serialize from '../../serializers/notification'; -import event from '../../event'; - -/** - * Mark as read a notification - * - * @param {any} params - * @param {any} user - * @return {Promise<any>} - */ -module.exports = (params, user) => new Promise(async (res, rej) => { - const [notificationId, notificationIdErr] = $(params.notification_id).id().$; - if (notificationIdErr) return rej('invalid notification_id param'); - - // Get notification - const notification = await Notification - .findOne({ - _id: notificationId, - i: user._id - }); - - if (notification === null) { - return rej('notification-not-found'); - } - - // Update - notification.is_read = true; - Notification.update({ _id: notification._id }, { - $set: { - is_read: true - } - }); - - // Response - res(); - - // Serialize - const notificationObj = await serialize(notification); - - // Publish read_notification event - event(user._id, 'read_notification', notificationObj); -}); diff --git a/src/api/endpoints/notifications/mark_as_read_all.ts b/src/api/endpoints/notifications/mark_as_read_all.ts new file mode 100644 index 0000000000..3550e344c4 --- /dev/null +++ b/src/api/endpoints/notifications/mark_as_read_all.ts @@ -0,0 +1,32 @@ +/** + * Module dependencies + */ +import Notification from '../../models/notification'; +import event from '../../event'; + +/** + * Mark as read all notifications + * + * @param {any} params + * @param {any} user + * @return {Promise<any>} + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + // Update documents + await Notification.update({ + notifiee_id: user._id, + is_read: false + }, { + $set: { + is_read: true + } + }, { + multi: true + }); + + // Response + res(); + + // 全ての通知を読みましたよというイベントを発行 + event(user._id, 'read_all_notifications'); +}); diff --git a/src/api/endpoints/posts.ts b/src/api/endpoints/posts.ts index 23b9bd0b66..f6efcc108d 100644 --- a/src/api/endpoints/posts.ts +++ b/src/api/endpoints/posts.ts @@ -62,7 +62,7 @@ module.exports = (params) => new Promise(async (res, rej) => { } if (reply != undefined) { - query.reply_to_id = reply ? { $exists: true, $ne: null } : null; + query.reply_id = reply ? { $exists: true, $ne: null } : null; } if (repost != undefined) { diff --git a/src/api/endpoints/posts/context.ts b/src/api/endpoints/posts/context.ts index cd5f15f481..bad59a6bee 100644 --- a/src/api/endpoints/posts/context.ts +++ b/src/api/endpoints/posts/context.ts @@ -49,13 +49,13 @@ module.exports = (params, user) => new Promise(async (res, rej) => { return; } - if (p.reply_to_id) { - await get(p.reply_to_id); + if (p.reply_id) { + await get(p.reply_id); } } - if (post.reply_to_id) { - await get(post.reply_to_id); + if (post.reply_id) { + await get(post.reply_id); } // Serialize diff --git a/src/api/endpoints/posts/create.ts b/src/api/endpoints/posts/create.ts index 805dba7f83..4f4b7e2e83 100644 --- a/src/api/endpoints/posts/create.ts +++ b/src/api/endpoints/posts/create.ts @@ -4,16 +4,17 @@ import $ from 'cafy'; import deepEqual = require('deep-equal'); import parse from '../../common/text'; -import Post from '../../models/post'; -import { isValidText } from '../../models/post'; +import { default as Post, IPost, isValidText } from '../../models/post'; import { default as User, IUser } from '../../models/user'; +import { default as Channel, IChannel } from '../../models/channel'; import Following from '../../models/following'; import DriveFile from '../../models/drive-file'; import Watching from '../../models/post-watching'; +import ChannelWatching from '../../models/channel-watching'; import serialize from '../../serializers/post'; import notify from '../../common/notify'; import watch from '../../common/watch-post'; -import event from '../../event'; +import { default as event, publishChannelStream } from '../../event'; import config from '../../../conf'; /** @@ -43,9 +44,7 @@ module.exports = (params, user: IUser, app) => new Promise(async (res, rej) => { // SELECT _id const entity = await DriveFile.findOne({ _id: mediaId, - user_id: user._id - }, { - _id: true + 'metadata.user_id': user._id }); if (entity === null) { @@ -62,7 +61,8 @@ module.exports = (params, user: IUser, app) => new Promise(async (res, rej) => { const [repostId, repostIdErr] = $(params.repost_id).optional.id().$; if (repostIdErr) return rej('invalid repost_id'); - let repost = null; + let repost: IPost = null; + let isQuote = false; if (repostId !== undefined) { // Fetch repost to post repost = await Post.findOne({ @@ -84,43 +84,86 @@ module.exports = (params, user: IUser, app) => new Promise(async (res, rej) => { } }); + isQuote = text != null || files != null; + // 直近と同じRepost対象かつ引用じゃなかったらエラー if (latestPost && latestPost.repost_id && latestPost.repost_id.equals(repost._id) && - text === undefined && files === null) { + !isQuote) { return rej('cannot repost same post that already reposted in your latest post'); } // 直近がRepost対象かつ引用じゃなかったらエラー if (latestPost && latestPost._id.equals(repost._id) && - text === undefined && files === null) { + !isQuote) { return rej('cannot repost your latest post'); } } - // Get 'in_reply_to_post_id' parameter - const [inReplyToPostId, inReplyToPostIdErr] = $(params.reply_to_id).optional.id().$; - if (inReplyToPostIdErr) return rej('invalid in_reply_to_post_id'); + // Get 'reply_id' parameter + const [replyId, replyIdErr] = $(params.reply_id).optional.id().$; + if (replyIdErr) return rej('invalid reply_id'); - let inReplyToPost = null; - if (inReplyToPostId !== undefined) { + let reply: IPost = null; + if (replyId !== undefined) { // Fetch reply - inReplyToPost = await Post.findOne({ - _id: inReplyToPostId + reply = await Post.findOne({ + _id: replyId }); - if (inReplyToPost === null) { + if (reply === null) { return rej('in reply to post is not found'); } // 返信対象が引用でないRepostだったらエラー - if (inReplyToPost.repost_id && !inReplyToPost.text && !inReplyToPost.media_ids) { + if (reply.repost_id && !reply.text && !reply.media_ids) { return rej('cannot reply to repost'); } } + // Get 'channel_id' parameter + const [channelId, channelIdErr] = $(params.channel_id).optional.id().$; + if (channelIdErr) return rej('invalid channel_id'); + + let channel: IChannel = null; + if (channelId !== undefined) { + // Fetch channel + channel = await Channel.findOne({ + _id: channelId + }); + + if (channel === null) { + return rej('channel not found'); + } + + // 返信対象の投稿がこのチャンネルじゃなかったらダメ + if (reply && !channelId.equals(reply.channel_id)) { + return rej('チャンネル内部からチャンネル外部の投稿に返信することはできません'); + } + + // Repost対象の投稿がこのチャンネルじゃなかったらダメ + if (repost && !channelId.equals(repost.channel_id)) { + return rej('チャンネル内部からチャンネル外部の投稿をRepostすることはできません'); + } + + // 引用ではないRepostはダメ + if (repost && !isQuote) { + return rej('チャンネル内部では引用ではないRepostをすることはできません'); + } + } else { + // 返信対象の投稿がチャンネルへの投稿だったらダメ + if (reply && reply.channel_id != null) { + return rej('チャンネル外部からチャンネル内部の投稿に返信することはできません'); + } + + // Repost対象の投稿がチャンネルへの投稿だったらダメ + if (repost && repost.channel_id != null) { + return rej('チャンネル外部からチャンネル内部の投稿をRepostすることはできません'); + } + } + // Get 'poll' parameter const [poll, pollErr] = $(params.poll).optional.strict.object() .have('choices', $().array('string') @@ -148,15 +191,15 @@ module.exports = (params, user: IUser, app) => new Promise(async (res, rej) => { if (user.latest_post) { if (deepEqual({ text: user.latest_post.text, - reply: user.latest_post.reply_to_id ? user.latest_post.reply_to_id.toString() : null, + reply: user.latest_post.reply_id ? user.latest_post.reply_id.toString() : null, repost: user.latest_post.repost_id ? user.latest_post.repost_id.toString() : null, media_ids: (user.latest_post.media_ids || []).map(id => id.toString()) }, { - text: text, - reply: inReplyToPost ? inReplyToPost._id.toString() : null, - repost: repost ? repost._id.toString() : null, - media_ids: (files || []).map(file => file._id.toString()) - })) { + text: text, + reply: reply ? reply._id.toString() : null, + repost: repost ? repost._id.toString() : null, + media_ids: (files || []).map(file => file._id.toString()) + })) { return rej('duplicate'); } } @@ -164,8 +207,10 @@ module.exports = (params, user: IUser, app) => new Promise(async (res, rej) => { // 投稿を作成 const post = await Post.insert({ created_at: new Date(), + channel_id: channel ? channel._id : undefined, + index: channel ? channel.index + 1 : undefined, media_ids: files ? files.map(file => file._id) : undefined, - reply_to_id: inReplyToPost ? inReplyToPost._id : undefined, + reply_id: reply ? reply._id : undefined, repost_id: repost ? repost._id : undefined, poll: poll, text: text, @@ -179,8 +224,7 @@ module.exports = (params, user: IUser, app) => new Promise(async (res, rej) => { // Reponse res(postObj); - // ----------------------------------------------------------- - // Post processes + //#region Post processes User.update({ _id: user._id }, { $set: { @@ -203,23 +247,51 @@ module.exports = (params, user: IUser, app) => new Promise(async (res, rej) => { } } - // Publish event to myself's stream - event(user._id, 'post', postObj); + // タイムラインへの投稿 + if (!channel) { + // Publish event to myself's stream + event(user._id, 'post', postObj); - // Fetch all followers - const followers = await Following - .find({ - followee_id: user._id, - // 削除されたドキュメントは除く - deleted_at: { $exists: false } - }, { - follower_id: true, - _id: false + // Fetch all followers + const followers = await Following + .find({ + followee_id: user._id, + // 削除されたドキュメントは除く + deleted_at: { $exists: false } + }, { + follower_id: true, + _id: false + }); + + // Publish event to followers stream + followers.forEach(following => + event(following.follower_id, 'post', postObj)); + } + + // チャンネルへの投稿 + if (channel) { + // Increment channel index(posts count) + Channel.update({ _id: channel._id }, { + $inc: { + index: 1 + } }); - // Publish event to followers stream - followers.forEach(following => - event(following.follower_id, 'post', postObj)); + // Publish event to channel + publishChannelStream(channel._id, 'post', postObj); + + // Get channel watchers + const watches = await ChannelWatching.find({ + channel_id: channel._id, + // 削除されたドキュメントは除く + deleted_at: { $exists: false } + }); + + // チャンネルの視聴者(のタイムライン)に配信 + watches.forEach(w => { + event(w.user_id, 'post', postObj); + }); + } // Increment my posts count User.update({ _id: user._id }, { @@ -229,23 +301,23 @@ module.exports = (params, user: IUser, app) => new Promise(async (res, rej) => { }); // If has in reply to post - if (inReplyToPost) { + if (reply) { // Increment replies count - Post.update({ _id: inReplyToPost._id }, { + Post.update({ _id: reply._id }, { $inc: { replies_count: 1 } }); // 自分自身へのリプライでない限りは通知を作成 - notify(inReplyToPost.user_id, user._id, 'reply', { + notify(reply.user_id, user._id, 'reply', { post_id: post._id }); // Fetch watchers Watching .find({ - post_id: inReplyToPost._id, + post_id: reply._id, user_id: { $ne: user._id }, // 削除されたドキュメントは除く deleted_at: { $exists: false } @@ -265,10 +337,10 @@ module.exports = (params, user: IUser, app) => new Promise(async (res, rej) => { // この投稿をWatchする // TODO: ユーザーが「返信したときに自動でWatchする」設定を // オフにしていた場合はしない - watch(user._id, inReplyToPost); + watch(user._id, reply); // Add mention - addMention(inReplyToPost.user_id, 'reply'); + addMention(reply.user_id, 'reply'); } // If it is repost @@ -369,7 +441,7 @@ module.exports = (params, user: IUser, app) => new Promise(async (res, rej) => { if (mentionee == null) return; // 既に言及されたユーザーに対する返信や引用repostの場合も無視 - if (inReplyToPost && inReplyToPost.user_id.equals(mentionee._id)) return; + if (reply && reply.user_id.equals(mentionee._id)) return; if (repost && repost.user_id.equals(mentionee._id)) return; // Add mention @@ -406,4 +478,6 @@ module.exports = (params, user: IUser, app) => new Promise(async (res, rej) => { } }); } + + //#endregion }); diff --git a/src/api/endpoints/posts/replies.ts b/src/api/endpoints/posts/replies.ts index 89f4d99841..3fd6a46769 100644 --- a/src/api/endpoints/posts/replies.ts +++ b/src/api/endpoints/posts/replies.ts @@ -40,7 +40,7 @@ module.exports = (params, user) => new Promise(async (res, rej) => { // Issue query const replies = await Post - .find({ reply_to_id: post._id }, { + .find({ reply_id: post._id }, { limit: limit, skip: offset, sort: { diff --git a/src/api/endpoints/posts/timeline.ts b/src/api/endpoints/posts/timeline.ts index 314e992344..203413e23a 100644 --- a/src/api/endpoints/posts/timeline.ts +++ b/src/api/endpoints/posts/timeline.ts @@ -2,7 +2,9 @@ * Module dependencies */ import $ from 'cafy'; +import rap from '@prezzemolo/rap'; import Post from '../../models/post'; +import ChannelWatching from '../../models/channel-watching'; import getFriends from '../../common/get-friends'; import serialize from '../../serializers/post'; @@ -14,36 +16,62 @@ import serialize from '../../serializers/post'; * @param {any} app * @return {Promise<any>} */ -module.exports = (params, user, app) => new Promise(async (res, rej) => { +module.exports = async (params, user, app) => { // Get 'limit' parameter const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$; - if (limitErr) return rej('invalid limit param'); + if (limitErr) throw 'invalid limit param'; // Get 'since_id' parameter const [sinceId, sinceIdErr] = $(params.since_id).optional.id().$; - if (sinceIdErr) return rej('invalid since_id param'); + if (sinceIdErr) throw 'invalid since_id param'; // Get 'max_id' parameter const [maxId, maxIdErr] = $(params.max_id).optional.id().$; - if (maxIdErr) return rej('invalid max_id param'); + if (maxIdErr) throw 'invalid max_id param'; // Check if both of since_id and max_id is specified if (sinceId && maxId) { - return rej('cannot set since_id and max_id'); + throw 'cannot set since_id and max_id'; } - // ID list of the user $self and other users who the user follows - const followingIds = await getFriends(user._id); + const { followingIds, watchChannelIds } = await rap({ + // ID list of the user itself and other users who the user follows + followingIds: getFriends(user._id), + // Watchしているチャンネルを取得 + watchChannelIds: ChannelWatching.find({ + user_id: user._id, + // 削除されたドキュメントは除く + deleted_at: { $exists: false } + }).then(watches => watches.map(w => w.channel_id)) + }); - // Construct query + //#region Construct query const sort = { _id: -1 }; + const query = { - user_id: { - $in: followingIds - } + $or: [{ + // フォローしている人のタイムラインへの投稿 + user_id: { + $in: followingIds + }, + // 「タイムラインへの」投稿に限定するためにチャンネルが指定されていないもののみに限る + $or: [{ + channel_id: { + $exists: false + } + }, { + channel_id: null + }] + }, { + // Watchしているチャンネルへの投稿 + channel_id: { + $in: watchChannelIds + } + }] } as any; + if (sinceId) { sort._id = 1; query._id = { @@ -54,6 +82,7 @@ module.exports = (params, user, app) => new Promise(async (res, rej) => { $lt: maxId }; } + //#endregion // Issue query const timeline = await Post @@ -63,7 +92,6 @@ module.exports = (params, user, app) => new Promise(async (res, rej) => { }); // Serialize - res(await Promise.all(timeline.map(async post => - await serialize(post, user) - ))); -}); + const _timeline = await Promise.all(timeline.map(post => serialize(post, user))); + return _timeline; +}; diff --git a/src/api/endpoints/posts/trend.ts b/src/api/endpoints/posts/trend.ts index 3277206d26..64a195dff1 100644 --- a/src/api/endpoints/posts/trend.ts +++ b/src/api/endpoints/posts/trend.ts @@ -48,7 +48,7 @@ module.exports = (params, user) => new Promise(async (res, rej) => { } as any; if (reply != undefined) { - query.reply_to_id = reply ? { $exists: true, $ne: null } : null; + query.reply_id = reply ? { $exists: true, $ne: null } : null; } if (repost != undefined) { diff --git a/src/api/endpoints/users/get_frequently_replied_users.ts b/src/api/endpoints/users/get_frequently_replied_users.ts index 2e0e2e40a7..bb0f3b4cea 100644 --- a/src/api/endpoints/users/get_frequently_replied_users.ts +++ b/src/api/endpoints/users/get_frequently_replied_users.ts @@ -27,7 +27,7 @@ module.exports = (params, me) => new Promise(async (res, rej) => { // Fetch recent posts const recentPosts = await Post.find({ user_id: user._id, - reply_to_id: { + reply_id: { $exists: true, $ne: null } @@ -38,7 +38,7 @@ module.exports = (params, me) => new Promise(async (res, rej) => { limit: 1000, fields: { _id: false, - reply_to_id: true + reply_id: true } }); @@ -49,7 +49,7 @@ module.exports = (params, me) => new Promise(async (res, rej) => { const replyTargetPosts = await Post.find({ _id: { - $in: recentPosts.map(p => p.reply_to_id) + $in: recentPosts.map(p => p.reply_id) }, user_id: { $ne: user._id diff --git a/src/api/endpoints/users/posts.ts b/src/api/endpoints/users/posts.ts index e37b660773..d8204b8b80 100644 --- a/src/api/endpoints/users/posts.ts +++ b/src/api/endpoints/users/posts.ts @@ -85,7 +85,7 @@ module.exports = (params, me) => new Promise(async (res, rej) => { } if (!includeReplies) { - query.reply_to_id = null; + query.reply_id = null; } if (withMedia) { diff --git a/src/api/event.ts b/src/api/event.ts index 9613a9f7cc..909b0d2556 100644 --- a/src/api/event.ts +++ b/src/api/event.ts @@ -25,6 +25,10 @@ class MisskeyEvent { this.publish(`messaging-stream:${userId}-${otherpartyId}`, type, typeof value === 'undefined' ? null : value); } + public publishChannelStream(channelId: ID, type: string, value?: any): void { + this.publish(`channel-stream:${channelId}`, type, typeof value === 'undefined' ? null : value); + } + private publish(channel: string, type: string, value?: any): void { const message = value == null ? { type: type } : @@ -41,3 +45,5 @@ export default ev.publishUserStream.bind(ev); export const publishPostStream = ev.publishPostStream.bind(ev); export const publishMessagingStream = ev.publishMessagingStream.bind(ev); + +export const publishChannelStream = ev.publishChannelStream.bind(ev); diff --git a/src/api/models/channel-watching.ts b/src/api/models/channel-watching.ts new file mode 100644 index 0000000000..6184ae408d --- /dev/null +++ b/src/api/models/channel-watching.ts @@ -0,0 +1,3 @@ +import db from '../../db/mongodb'; + +export default db.get('channel_watching') as any; // fuck type definition diff --git a/src/api/models/channel.ts b/src/api/models/channel.ts new file mode 100644 index 0000000000..c80e84dbc8 --- /dev/null +++ b/src/api/models/channel.ts @@ -0,0 +1,14 @@ +import * as mongo from 'mongodb'; +import db from '../../db/mongodb'; + +const collection = db.get('channels'); + +export default collection as any; // fuck type definition + +export type IChannel = { + _id: mongo.ObjectID; + created_at: Date; + title: string; + user_id: mongo.ObjectID; + index: number; +}; diff --git a/src/api/models/drive-file.ts b/src/api/models/drive-file.ts index 8d158cf563..8968d065cd 100644 --- a/src/api/models/drive-file.ts +++ b/src/api/models/drive-file.ts @@ -1,11 +1,22 @@ -import db from '../../db/mongodb'; +import * as mongodb from 'mongodb'; +import monkDb, { nativeDbConn } from '../../db/mongodb'; -const collection = db.get('drive_files'); +const collection = monkDb.get('drive_files.files'); (collection as any).createIndex('hash'); // fuck type definition export default collection as any; // fuck type definition +const getGridFSBucket = async (): Promise<mongodb.GridFSBucket> => { + const db = await nativeDbConn(); + const bucket = new mongodb.GridFSBucket(db, { + bucketName: 'drive_files' + }); + return bucket; +}; + +export { getGridFSBucket }; + export function validateFileName(name: string): boolean { return ( (name.trim().length > 0) && diff --git a/src/api/models/notification.ts b/src/api/models/notification.ts index 1c1f429a0d..1065e8baaa 100644 --- a/src/api/models/notification.ts +++ b/src/api/models/notification.ts @@ -1,3 +1,8 @@ +import * as mongo from 'mongodb'; import db from '../../db/mongodb'; export default db.get('notifications') as any; // fuck type definition + +export interface INotification { + _id: mongo.ObjectID; +} diff --git a/src/api/models/post.ts b/src/api/models/post.ts index 8b9f7f5ef6..7584ce182d 100644 --- a/src/api/models/post.ts +++ b/src/api/models/post.ts @@ -10,9 +10,10 @@ export function isValidText(text: string): boolean { export type IPost = { _id: mongo.ObjectID; + channel_id: mongo.ObjectID; created_at: Date; media_ids: mongo.ObjectID[]; - reply_to_id: mongo.ObjectID; + reply_id: mongo.ObjectID; repost_id: mongo.ObjectID; poll: {}; // todo text: string; diff --git a/src/api/serializers/channel.ts b/src/api/serializers/channel.ts new file mode 100644 index 0000000000..3cba39aa16 --- /dev/null +++ b/src/api/serializers/channel.ts @@ -0,0 +1,66 @@ +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import deepcopy = require('deepcopy'); +import { IUser } from '../models/user'; +import { default as Channel, IChannel } from '../models/channel'; +import Watching from '../models/channel-watching'; + +/** + * Serialize a channel + * + * @param channel target + * @param me? serializee + * @return response + */ +export default ( + channel: string | mongo.ObjectID | IChannel, + me?: string | mongo.ObjectID | IUser +) => new Promise<any>(async (resolve, reject) => { + + let _channel: any; + + // Populate the channel if 'channel' is ID + if (mongo.ObjectID.prototype.isPrototypeOf(channel)) { + _channel = await Channel.findOne({ + _id: channel + }); + } else if (typeof channel === 'string') { + _channel = await Channel.findOne({ + _id: new mongo.ObjectID(channel) + }); + } else { + _channel = deepcopy(channel); + } + + // Rename _id to id + _channel.id = _channel._id; + delete _channel._id; + + // Remove needless properties + delete _channel.user_id; + + // Me + const meId: mongo.ObjectID = me + ? mongo.ObjectID.prototype.isPrototypeOf(me) + ? me as mongo.ObjectID + : typeof me === 'string' + ? new mongo.ObjectID(me) + : (me as IUser)._id + : null; + + if (me) { + //#region Watchしているかどうか + const watch = await Watching.findOne({ + user_id: meId, + channel_id: _channel.id, + deleted_at: { $exists: false } + }); + + _channel.is_watching = watch !== null; + //#endregion + } + + resolve(_channel); +}); diff --git a/src/api/serializers/drive-file.ts b/src/api/serializers/drive-file.ts index b4e2ab064a..2af7db5726 100644 --- a/src/api/serializers/drive-file.ts +++ b/src/api/serializers/drive-file.ts @@ -31,44 +31,40 @@ export default ( if (mongo.ObjectID.prototype.isPrototypeOf(file)) { _file = await DriveFile.findOne({ _id: file - }, { - fields: { - data: false - } - }); + }); } else if (typeof file === 'string') { _file = await DriveFile.findOne({ _id: new mongo.ObjectID(file) - }, { - fields: { - data: false - } - }); + }); } else { _file = deepcopy(file); } - // Rename _id to id - _file.id = _file._id; - delete _file._id; + if (!_file) return reject('invalid file arg.'); - delete _file.data; + // rendered target + let _target: any = {}; - _file.url = `${config.drive_url}/${_file.id}/${encodeURIComponent(_file.name)}`; + _target.id = _file._id; + _target.created_at = _file.uploadDate; - if (opts.detail && _file.folder_id) { + _target = Object.assign(_target, _file.metadata); + + _target.url = `${config.drive_url}/${_target.id}/${encodeURIComponent(_target.name)}`; + + if (opts.detail && _target.folder_id) { // Populate folder - _file.folder = await serializeDriveFolder(_file.folder_id, { + _target.folder = await serializeDriveFolder(_target.folder_id, { detail: true }); } - if (opts.detail && _file.tags) { + if (opts.detail && _target.tags) { // Populate tags - _file.tags = await _file.tags.map(async (tag: any) => + _target.tags = await _target.tags.map(async (tag: any) => await serializeDriveTag(tag) ); } - resolve(_file); + resolve(_target); }); diff --git a/src/api/serializers/drive-folder.ts b/src/api/serializers/drive-folder.ts index a428464108..6ebf454a28 100644 --- a/src/api/serializers/drive-folder.ts +++ b/src/api/serializers/drive-folder.ts @@ -44,7 +44,7 @@ const self = ( }); const childFilesCount = await DriveFile.count({ - folder_id: _folder.id + 'metadata.folder_id': _folder.id }); _folder.folders_count = childFoldersCount; diff --git a/src/api/serializers/post.ts b/src/api/serializers/post.ts index df917a8595..03fd120772 100644 --- a/src/api/serializers/post.ts +++ b/src/api/serializers/post.ts @@ -8,9 +8,11 @@ import Reaction from '../models/post-reaction'; import { IUser } from '../models/user'; import Vote from '../models/poll-vote'; import serializeApp from './app'; +import serializeChannel from './channel'; import serializeUser from './user'; import serializeDriveFile from './drive-file'; import parse from '../common/text'; +import rap from '@prezzemolo/rap'; /** * Serialize a post @@ -20,13 +22,13 @@ import parse from '../common/text'; * @param options? serialize options * @return response */ -const self = ( +const self = async ( post: string | mongo.ObjectID | IPost, me?: string | mongo.ObjectID | IUser, options?: { detail: boolean } -) => new Promise<any>(async (resolve, reject) => { +) => { const opts = options || { detail: true, }; @@ -55,6 +57,8 @@ const self = ( _post = deepcopy(post); } + if (!_post) throw 'invalid post arg.'; + const id = _post._id; // Rename _id to id @@ -69,100 +73,120 @@ const self = ( } // Populate user - _post.user = await serializeUser(_post.user_id, meId); + _post.user = serializeUser(_post.user_id, meId); // Populate app if (_post.app_id) { - _post.app = await serializeApp(_post.app_id); + _post.app = serializeApp(_post.app_id); } + // Populate channel + if (_post.channel_id) { + _post.channel = serializeChannel(_post.channel_id); + } + + // Populate media if (_post.media_ids) { - // Populate media - _post.media = await Promise.all(_post.media_ids.map(async fileId => - await serializeDriveFile(fileId) + _post.media = Promise.all(_post.media_ids.map(fileId => + serializeDriveFile(fileId) )); } // When requested a detailed post data if (opts.detail) { // Get previous post info - const prev = await Post.findOne({ - user_id: _post.user_id, - _id: { - $lt: id - } - }, { - fields: { - _id: true - }, - sort: { - _id: -1 - } - }); - _post.prev = prev ? prev._id : null; + _post.prev = (async () => { + const prev = await Post.findOne({ + user_id: _post.user_id, + _id: { + $lt: id + } + }, { + fields: { + _id: true + }, + sort: { + _id: -1 + } + }); + return prev ? prev._id : null; + })(); // Get next post info - const next = await Post.findOne({ - user_id: _post.user_id, - _id: { - $gt: id - } - }, { - fields: { - _id: true - }, - sort: { - _id: 1 - } - }); - _post.next = next ? next._id : null; + _post.next = (async () => { + const next = await Post.findOne({ + user_id: _post.user_id, + _id: { + $gt: id + } + }, { + fields: { + _id: true + }, + sort: { + _id: 1 + } + }); + return next ? next._id : null; + })(); - if (_post.reply_to_id) { + if (_post.reply_id) { // Populate reply to post - _post.reply_to = await self(_post.reply_to_id, meId, { + _post.reply = self(_post.reply_id, meId, { detail: false }); } if (_post.repost_id) { // Populate repost - _post.repost = await self(_post.repost_id, meId, { + _post.repost = self(_post.repost_id, meId, { detail: _post.text == null }); } // Poll if (meId && _post.poll) { - const vote = await Vote - .findOne({ - user_id: meId, - post_id: id - }); + _post.poll = (async (poll) => { + const vote = await Vote + .findOne({ + user_id: meId, + post_id: id + }); - if (vote != null) { - const myChoice = _post.poll.choices - .filter(c => c.id == vote.choice)[0]; + if (vote != null) { + const myChoice = poll.choices + .filter(c => c.id == vote.choice)[0]; - myChoice.is_voted = true; - } + myChoice.is_voted = true; + } + + return poll; + })(_post.poll); } // Fetch my reaction if (meId) { - const reaction = await Reaction - .findOne({ - user_id: meId, - post_id: id, - deleted_at: { $exists: false } - }); + _post.my_reaction = (async () => { + const reaction = await Reaction + .findOne({ + user_id: meId, + post_id: id, + deleted_at: { $exists: false } + }); - if (reaction) { - _post.my_reaction = reaction.reaction; - } + if (reaction) { + return reaction.reaction; + } + + return null; + })(); } } - resolve(_post); -}); + // resolve promises in _post object + _post = await rap(_post); + + return _post; +}; export default self; diff --git a/src/api/serializers/user.ts b/src/api/serializers/user.ts index 3deff2d003..0d24d6cc04 100644 --- a/src/api/serializers/user.ts +++ b/src/api/serializers/user.ts @@ -8,6 +8,7 @@ import serializePost from './post'; import Following from '../models/following'; import getFriends from '../common/get-friends'; import config from '../../conf'; +import rap from '@prezzemolo/rap'; /** * Serialize a user @@ -55,6 +56,8 @@ export default ( _user = deepcopy(user); } + if (!_user) return reject('invalid user arg.'); + // Me const meId: mongo.ObjectID = me ? mongo.ObjectID.prototype.isPrototypeOf(me) @@ -104,26 +107,30 @@ export default ( if (meId && !meId.equals(_user.id)) { // If the user is following - const follow = await Following.findOne({ - follower_id: meId, - followee_id: _user.id, - deleted_at: { $exists: false } - }); - _user.is_following = follow !== null; + _user.is_following = (async () => { + const follow = await Following.findOne({ + follower_id: meId, + followee_id: _user.id, + deleted_at: { $exists: false } + }); + return follow !== null; + })(); // If the user is followed - const follow2 = await Following.findOne({ - follower_id: _user.id, - followee_id: meId, - deleted_at: { $exists: false } - }); - _user.is_followed = follow2 !== null; + _user.is_followed = (async () => { + const follow2 = await Following.findOne({ + follower_id: _user.id, + followee_id: meId, + deleted_at: { $exists: false } + }); + return follow2 !== null; + })(); } if (opts.detail) { if (_user.pinned_post_id) { // Populate pinned post - _user.pinned_post = await serializePost(_user.pinned_post_id, meId, { + _user.pinned_post = serializePost(_user.pinned_post_id, meId, { detail: true }); } @@ -132,23 +139,24 @@ export default ( const myFollowingIds = await getFriends(meId); // Get following you know count - const followingYouKnowCount = await Following.count({ + _user.following_you_know_count = Following.count({ followee_id: { $in: myFollowingIds }, follower_id: _user.id, deleted_at: { $exists: false } }); - _user.following_you_know_count = followingYouKnowCount; // Get followers you know count - const followersYouKnowCount = await Following.count({ + _user.followers_you_know_count = Following.count({ followee_id: _user.id, follower_id: { $in: myFollowingIds }, deleted_at: { $exists: false } }); - _user.followers_you_know_count = followersYouKnowCount; } } + // resolve promises in _user object + _user = await rap(_user); + resolve(_user); }); /* diff --git a/src/api/stream/channel.ts b/src/api/stream/channel.ts new file mode 100644 index 0000000000..d67d77cbf4 --- /dev/null +++ b/src/api/stream/channel.ts @@ -0,0 +1,12 @@ +import * as websocket from 'websocket'; +import * as redis from 'redis'; + +export default function(request: websocket.request, connection: websocket.connection, subscriber: redis.RedisClient): void { + const channel = request.resourceURL.query.channel; + + // Subscribe channel stream + subscriber.subscribe(`misskey:channel-stream:${channel}`); + subscriber.on('message', (_, data) => { + connection.send(data); + }); +} diff --git a/src/api/stream/home.ts b/src/api/stream/home.ts index d5fe01c261..7c8f3bfec8 100644 --- a/src/api/stream/home.ts +++ b/src/api/stream/home.ts @@ -4,6 +4,7 @@ import * as debug from 'debug'; import User from '../models/user'; import serializePost from '../serializers/post'; +import readNotification from '../common/read-notification'; const log = debug('misskey'); @@ -45,6 +46,11 @@ export default function homeStream(request: websocket.request, connection: webso }); break; + case 'read_notification': + if (!msg.id) return; + readNotification(user._id, msg.id); + break; + case 'capture': if (!msg.id) return; const postId = msg.id; diff --git a/src/api/streaming.ts b/src/api/streaming.ts index db600013b9..0e512fb210 100644 --- a/src/api/streaming.ts +++ b/src/api/streaming.ts @@ -9,6 +9,7 @@ import isNativeToken from './common/is-native-token'; import homeStream from './stream/home'; import messagingStream from './stream/messaging'; import serverStream from './stream/server'; +import channelStream from './stream/channel'; module.exports = (server: http.Server) => { /** @@ -26,14 +27,6 @@ module.exports = (server: http.Server) => { return; } - const user = await authenticate(request.resourceURL.query.i); - - if (user == null) { - connection.send('authentication-failed'); - connection.close(); - return; - } - // Connect to Redis const subscriber = redis.createClient( config.redis.port, config.redis.host); @@ -43,6 +36,19 @@ module.exports = (server: http.Server) => { subscriber.quit(); }); + if (request.resourceURL.pathname === '/channel') { + channelStream(request, connection, subscriber); + return; + } + + const user = await authenticate(request.resourceURL.query.i); + + if (user == null) { + connection.send('authentication-failed'); + connection.close(); + return; + } + const channel = request.resourceURL.pathname === '/' ? homeStream : request.resourceURL.pathname === '/messaging' ? messagingStream : diff --git a/src/common/get-post-summary.ts b/src/common/get-post-summary.ts index f628a32b41..6e8f65708e 100644 --- a/src/common/get-post-summary.ts +++ b/src/common/get-post-summary.ts @@ -3,7 +3,13 @@ * @param {*} post 投稿 */ const summarize = (post: any): string => { - let summary = post.text ? post.text : ''; + let summary = ''; + + // チャンネル + summary += post.channel ? `${post.channel.title}:` : ''; + + // 本文 + summary += post.text ? post.text : ''; // メディアが添付されているとき if (post.media) { @@ -16,9 +22,9 @@ const summarize = (post: any): string => { } // 返信のとき - if (post.reply_to_id) { - if (post.reply_to) { - summary += ` RE: ${summarize(post.reply_to)}`; + if (post.reply_id) { + if (post.reply) { + summary += ` RE: ${summarize(post.reply)}`; } else { summary += ' RE: ...'; } diff --git a/src/config.ts b/src/config.ts index 46a93f5fef..d37d227a41 100644 --- a/src/config.ts +++ b/src/config.ts @@ -88,6 +88,7 @@ type Mixin = { api_url: string; auth_url: string; about_url: string; + ch_url: string; stats_url: string; status_url: string; dev_url: string; @@ -122,6 +123,7 @@ export default function load() { mixin.secondary_scheme = config.secondary_url.substr(0, config.secondary_url.indexOf('://')); mixin.api_url = `${mixin.scheme}://api.${mixin.host}`; mixin.auth_url = `${mixin.scheme}://auth.${mixin.host}`; + mixin.ch_url = `${mixin.scheme}://ch.${mixin.host}`; mixin.dev_url = `${mixin.scheme}://dev.${mixin.host}`; mixin.about_url = `${mixin.scheme}://about.${mixin.host}`; mixin.stats_url = `${mixin.scheme}://stats.${mixin.host}`; diff --git a/src/db/mongodb.ts b/src/db/mongodb.ts index 6ee7f4534f..c978e6460f 100644 --- a/src/db/mongodb.ts +++ b/src/db/mongodb.ts @@ -1,11 +1,38 @@ -import * as mongo from 'monk'; - import config from '../conf'; const uri = config.mongodb.user && config.mongodb.pass - ? `mongodb://${config.mongodb.user}:${config.mongodb.pass}@${config.mongodb.host}:${config.mongodb.port}/${config.mongodb.db}` - : `mongodb://${config.mongodb.host}:${config.mongodb.port}/${config.mongodb.db}`; +? `mongodb://${config.mongodb.user}:${config.mongodb.pass}@${config.mongodb.host}:${config.mongodb.port}/${config.mongodb.db}` +: `mongodb://${config.mongodb.host}:${config.mongodb.port}/${config.mongodb.db}`; + +/** + * monk + */ +import * as mongo from 'monk'; const db = mongo(uri); export default db; + +/** + * MongoDB native module (officialy) + */ +import * as mongodb from 'mongodb'; + +let mdb: mongodb.Db; + +const nativeDbConn = async (): Promise<mongodb.Db> => { + if (mdb) return mdb; + + const db = await ((): Promise<mongodb.Db> => new Promise((resolve, reject) => { + mongodb.MongoClient.connect(uri, (e, db) => { + if (e) return reject(e); + resolve(db); + }); + }))(); + + mdb = db; + + return db; +}; + +export { nativeDbConn }; diff --git a/src/docs/api/entities/post.pug b/src/docs/api/entities/post.pug index e505d3fcb6..954f172717 100644 --- a/src/docs/api/entities/post.pug +++ b/src/docs/api/entities/post.pug @@ -52,11 +52,11 @@ block content td Number td 返信数 tr.optional - td reply_to + td reply td: a(href='./post', target='_blank') Post td 返信先の投稿 tr.nullable - td reply_to_id + td reply_id td ID td 返信先の投稿のID tr.optional @@ -90,7 +90,7 @@ block content { "created_at": "2016-12-10T00:28:50.114Z", "media_ids": null, - "reply_to_id": "584a16b15860fc52320137e3", + "reply_id": "584a16b15860fc52320137e3", "repost_id": null, "text": "小日向美穂だぞ!", "user_id": "5848bf7764e572683f4402f8", @@ -117,10 +117,10 @@ block content "is_following": true, "is_followed": true }, - "reply_to": { + "reply": { "created_at": "2016-12-09T02:28:01.563Z", "media_ids": null, - "reply_to_id": "5849d35e547e4249be329884", + "reply_id": "5849d35e547e4249be329884", "repost_id": null, "text": "アイコン小日向美穂?", "user_id": "57d01a501fdf2d07be417afe", diff --git a/src/file/server.ts b/src/file/server.ts index ee67cf7860..375f29487d 100644 --- a/src/file/server.ts +++ b/src/file/server.ts @@ -9,7 +9,7 @@ import * as cors from 'cors'; import * as mongodb from 'mongodb'; import * as gm from 'gm'; -import File from '../api/models/drive-file'; +import DriveFile, { getGridFSBucket } from '../api/models/drive-file'; /** * Init app @@ -97,17 +97,28 @@ app.get('/:id', async (req, res) => { return; } - const file = await File.findOne({ _id: new mongodb.ObjectID(req.params.id) }); + const fileId = new mongodb.ObjectID(req.params.id); + const file = await DriveFile.findOne({ _id: fileId }); if (file == null) { - res.status(404).sendFile(`${__dirname} / assets / dummy.png`); - return; - } else if (file.data == null) { - res.sendStatus(400); + res.status(404).sendFile(`${__dirname}/assets/dummy.png`); return; } - send(file.data.buffer, file.type, req, res); + const bucket = await getGridFSBucket(); + + const buffer = await ((id): Promise<Buffer> => new Promise((resolve, reject) => { + const chunks = []; + const readableStream = bucket.openDownloadStream(id); + readableStream.on('data', chunk => { + chunks.push(chunk); + }); + readableStream.on('end', () => { + resolve(Buffer.concat(chunks)); + }); + }))(fileId); + + send(buffer, file.metadata.type, req, res); }); app.get('/:id/:name', async (req, res) => { @@ -117,17 +128,28 @@ app.get('/:id/:name', async (req, res) => { return; } - const file = await File.findOne({ _id: new mongodb.ObjectID(req.params.id) }); + const fileId = new mongodb.ObjectID(req.params.id); + const file = await DriveFile.findOne({ _id: fileId }); if (file == null) { res.status(404).sendFile(`${__dirname}/assets/dummy.png`); return; - } else if (file.data == null) { - res.sendStatus(400); - return; } - send(file.data.buffer, file.type, req, res); + const bucket = await getGridFSBucket(); + + const buffer = await ((id): Promise<Buffer> => new Promise((resolve, reject) => { + const chunks = []; + const readableStream = bucket.openDownloadStream(id); + readableStream.on('data', chunk => { + chunks.push(chunk); + }); + readableStream.on('end', () => { + resolve(Buffer.concat(chunks)); + }); + }))(fileId); + + send(buffer, file.metadata.type, req, res); }); module.exports = app; diff --git a/src/web/app/base.styl b/src/web/app/app.styl similarity index 94% rename from src/web/app/base.styl rename to src/web/app/app.styl index 81c039f0a3..94faba73d4 100644 --- a/src/web/app/base.styl +++ b/src/web/app/app.styl @@ -5,8 +5,6 @@ json('../../const.json') $theme-color = themeColor $theme-color-foreground = themeColorForeground -@import './reset' - /* ::selection background $theme-color @@ -14,6 +12,9 @@ $theme-color-foreground = themeColorForeground */ * + position relative + box-sizing border-box + background-clip padding-box !important tap-highlight-color rgba($theme-color, 0.7) -webkit-tap-highlight-color rgba($theme-color, 0.7) @@ -29,6 +30,9 @@ html &, * cursor progress !important +body + overflow-wrap break-word + #error padding 32px color #fff diff --git a/src/web/app/auth/style.styl b/src/web/app/auth/style.styl index 046a5ff6ee..bd25e1b572 100644 --- a/src/web/app/auth/style.styl +++ b/src/web/app/auth/style.styl @@ -1,4 +1,5 @@ -@import "../base" +@import "../app" +@import "../reset" html background #eee diff --git a/src/web/app/ch/router.js b/src/web/app/ch/router.js new file mode 100644 index 0000000000..424158f403 --- /dev/null +++ b/src/web/app/ch/router.js @@ -0,0 +1,32 @@ +import * as riot from 'riot'; +const route = require('page'); +let page = null; + +export default me => { + route('/', index); + route('/:channel', channel); + route('*', notFound); + + function index() { + mount(document.createElement('mk-index')); + } + + function channel(ctx) { + const el = document.createElement('mk-channel'); + el.setAttribute('id', ctx.params.channel); + mount(el); + } + + function notFound() { + mount(document.createElement('mk-not-found')); + } + + // EXEC + route(); +}; + +function mount(content) { + if (page) page.unmount(); + const body = document.getElementById('app'); + page = riot.mount(body.appendChild(content))[0]; +} diff --git a/src/web/app/ch/script.js b/src/web/app/ch/script.js new file mode 100644 index 0000000000..760d405c52 --- /dev/null +++ b/src/web/app/ch/script.js @@ -0,0 +1,18 @@ +/** + * Channels + */ + +// Style +import './style.styl'; + +require('./tags'); +import init from '../init'; +import route from './router'; + +/** + * init + */ +init(me => { + // Start routing + route(me); +}); diff --git a/src/web/app/ch/style.styl b/src/web/app/ch/style.styl new file mode 100644 index 0000000000..21ca648cbe --- /dev/null +++ b/src/web/app/ch/style.styl @@ -0,0 +1,10 @@ +@import "../app" + +html + padding 8px + background #efefef + +#wait + top auto + bottom 15px + left 15px diff --git a/src/web/app/ch/tags/channel.tag b/src/web/app/ch/tags/channel.tag new file mode 100644 index 0000000000..4ae62e7b39 --- /dev/null +++ b/src/web/app/ch/tags/channel.tag @@ -0,0 +1,403 @@ +<mk-channel> + <mk-header/> + <hr> + <main if={ !fetching }> + <h1>{ channel.title }</h1> + + <div if={ SIGNIN }> + <p if={ channel.is_watching }>このチャンネルをウォッチしています <a onclick={ unwatch }>ウォッチ解除</a></p> + <p if={ !channel.is_watching }><a onclick={ watch }>このチャンネルをウォッチする</a></p> + </div> + + <div class="share"> + <mk-twitter-button/> + <mk-line-button/> + </div> + + <div class="body"> + <p if={ postsFetching }>読み込み中<mk-ellipsis/></p> + <div if={ !postsFetching }> + <p if={ posts == null || posts.length == 0 }>まだ投稿がありません</p> + <virtual if={ posts != null }> + <mk-channel-post each={ post in posts.slice().reverse() } post={ post } form={ parent.refs.form }/> + </virtual> + </div> + </div> + <hr> + <mk-channel-form if={ SIGNIN } channel={ channel } ref="form"/> + <div if={ !SIGNIN }> + <p>参加するには<a href={ CONFIG.url }>ログインまたは新規登録</a>してください</p> + </div> + <hr> + <footer> + <small><a href={ CONFIG.url }>Misskey</a> ver { version } (葵 aoi)</small> + </footer> + </main> + <style> + :scope + display block + + > main + > h1 + font-size 1.5em + color #f00 + + > .share + > * + margin-right 4px + + > .body + margin 8px 0 0 0 + + > mk-channel-form + max-width 500px + + </style> + <script> + import Progress from '../../common/scripts/loading'; + import ChannelStream from '../../common/scripts/channel-stream'; + + this.mixin('i'); + this.mixin('api'); + + this.id = this.opts.id; + this.fetching = true; + this.postsFetching = true; + this.channel = null; + this.posts = null; + this.connection = new ChannelStream(this.id); + this.version = VERSION; + this.unreadCount = 0; + + this.on('mount', () => { + document.documentElement.style.background = '#efefef'; + + Progress.start(); + + let fetched = false; + + // チャンネル概要読み込み + this.api('channels/show', { + channel_id: this.id + }).then(channel => { + if (fetched) { + Progress.done(); + } else { + Progress.set(0.5); + fetched = true; + } + + this.update({ + fetching: false, + channel: channel + }); + + document.title = channel.title + ' | Misskey' + }); + + // 投稿読み込み + this.api('channels/posts', { + channel_id: this.id + }).then(posts => { + if (fetched) { + Progress.done(); + } else { + Progress.set(0.5); + fetched = true; + } + + this.update({ + postsFetching: false, + posts: posts + }); + }); + + this.connection.on('post', this.onPost); + document.addEventListener('visibilitychange', this.onVisibilitychange, false); + }); + + this.on('unmount', () => { + this.connection.off('post', this.onPost); + this.connection.close(); + document.removeEventListener('visibilitychange', this.onVisibilitychange); + }); + + this.onPost = post => { + this.posts.unshift(post); + this.update(); + + if (document.hidden && this.SIGNIN && post.user_id !== this.I.id) { + this.unreadCount++; + document.title = `(${this.unreadCount}) ${this.channel.title} | Misskey`; + } + }; + + this.onVisibilitychange = () => { + if (!document.hidden) { + this.unreadCount = 0; + document.title = this.channel.title + ' | Misskey' + } + }; + + this.watch = () => { + this.api('channels/watch', { + channel_id: this.id + }).then(() => { + this.channel.is_watching = true; + this.update(); + }, e => { + alert('error'); + }); + }; + + this.unwatch = () => { + this.api('channels/unwatch', { + channel_id: this.id + }).then(() => { + this.channel.is_watching = false; + this.update(); + }, e => { + alert('error'); + }); + }; + </script> +</mk-channel> + +<mk-channel-post> + <header> + <a class="index" onclick={ reply }>{ post.index }:</a> + <a class="name" href={ CONFIG.url + '/' + post.user.username }><b>{ post.user.name }</b></a> + <mk-time time={ post.created_at }/> + <mk-time time={ post.created_at } mode="detail"/> + <span>ID:<i>{ post.user.username }</i></span> + </header> + <div> + <a if={ post.reply }>>>{ post.reply.index }</a> + { post.text } + <div class="media" if={ post.media }> + <virtual each={ file in post.media }> + <a href={ file.url } target="_blank"> + <img src={ file.url + '?thumbnail&size=512' } alt={ file.name } title={ file.name }/> + </a> + </virtual> + </div> + </div> + <style> + :scope + display block + margin 0 + padding 0 + + > header + position -webkit-sticky + position sticky + z-index 1 + top 0 + background rgba(239, 239, 239, 0.9) + + > .index + margin-right 0.25em + color #000 + + > .name + margin-right 0.5em + color #008000 + + > mk-time + margin-right 0.5em + + &:first-of-type + display none + + @media (max-width 600px) + > mk-time + &:first-of-type + display initial + + &:last-of-type + display none + + > div + padding 0 0 1em 2em + + > .media + > a + display inline-block + + > img + max-width 100% + vertical-align bottom + + </style> + <script> + this.post = this.opts.post; + this.form = this.opts.form; + + this.reply = () => { + this.form.update({ + reply: this.post + }); + }; + </script> +</mk-channel-post> + +<mk-channel-form> + <p if={ reply }><b>>>{ reply.index }</b> ({ reply.user.name }): <a onclick={ clearReply }>[x]</a></p> + <textarea ref="text" disabled={ wait } oninput={ update } onkeydown={ onkeydown } onpaste={ onpaste } placeholder="%i18n:ch.tags.mk-channel-form.textarea%"></textarea> + <div class="actions"> + <button onclick={ selectFile }><i class="fa fa-upload"></i>%i18n:ch.tags.mk-channel-form.upload%</button> + <button onclick={ drive }><i class="fa fa-cloud"></i>%i18n:ch.tags.mk-channel-form.drive%</button> + <button class={ wait: wait } ref="submit" disabled={ wait || (refs.text.value.length == 0) } onclick={ post }> + <i class="fa fa-paper-plane" if={ !wait }></i>{ wait ? '%i18n:ch.tags.mk-channel-form.posting%' : '%i18n:ch.tags.mk-channel-form.post%' }<mk-ellipsis if={ wait }/> + </button> + </div> + <mk-uploader ref="uploader"/> + <ol if={ files }> + <li each={ files }>{ name }</li> + </ol> + <input ref="file" type="file" accept="image/*" multiple="multiple" onchange={ changeFile }/> + <style> + :scope + display block + + > textarea + width 100% + max-width 100% + min-width 100% + min-height 5em + + > .actions + display flex + + > button + > i + margin-right 0.25em + + &:last-child + margin-left auto + + &.wait + cursor wait + + > input[type='file'] + display none + + </style> + <script> + import CONFIG from '../../common/scripts/config'; + + this.mixin('api'); + + this.channel = this.opts.channel; + this.files = null; + + this.on('mount', () => { + this.refs.uploader.on('uploaded', file => { + this.update({ + files: [file] + }); + }); + }); + + this.upload = file => { + this.refs.uploader.upload(file); + }; + + this.clearReply = () => { + this.update({ + reply: null + }); + }; + + this.clear = () => { + this.clearReply(); + this.update({ + files: null + }); + this.refs.text.value = ''; + }; + + this.post = () => { + this.update({ + wait: true + }); + + const files = this.files && this.files.length > 0 + ? this.files.map(f => f.id) + : undefined; + + this.api('posts/create', { + text: this.refs.text.value == '' ? undefined : this.refs.text.value, + media_ids: files, + reply_id: this.reply ? this.reply.id : undefined, + channel_id: this.channel.id + }).then(data => { + this.clear(); + }).catch(err => { + alert('失敗した'); + }).then(() => { + this.update({ + wait: false + }); + }); + }; + + this.changeFile = () => { + this.refs.file.files.forEach(this.upload); + }; + + this.selectFile = () => { + this.refs.file.click(); + }; + + this.drive = () => { + window['cb'] = files => { + this.update({ + files: files + }); + }; + + window.open(CONFIG.url + '/selectdrive?multiple=true', + 'drive_window', + 'height=500,width=800'); + }; + + this.onkeydown = e => { + if ((e.which == 10 || e.which == 13) && (e.ctrlKey || e.metaKey)) this.post(); + }; + + this.onpaste = e => { + e.clipboardData.items.forEach(item => { + if (item.kind == 'file') { + this.upload(item.getAsFile()); + } + }); + }; + </script> +</mk-channel-form> + +<mk-twitter-button> + <a href="https://twitter.com/share?ref_src=twsrc%5Etfw" class="twitter-share-button" data-show-count="false">Tweet</a> + <script> + this.on('mount', () => { + const head = document.getElementsByTagName('head')[0]; + const script = document.createElement('script'); + script.setAttribute('src', 'https://platform.twitter.com/widgets.js'); + script.setAttribute('async', 'async'); + head.appendChild(script); + }); + </script> +</mk-twitter-button> + +<mk-line-button> + <div class="line-it-button" data-lang="ja" data-type="share-a" data-url={ CONFIG.chUrl } style="display: none;"></div> + <script> + this.on('mount', () => { + const head = document.getElementsByTagName('head')[0]; + const script = document.createElement('script'); + script.setAttribute('src', 'https://d.line-scdn.net/r/web/social-plugin/js/thirdparty/loader.min.js'); + script.setAttribute('async', 'async'); + head.appendChild(script); + }); + </script> +</mk-line-button> diff --git a/src/web/app/ch/tags/header.tag b/src/web/app/ch/tags/header.tag new file mode 100644 index 0000000000..5cdcbd09cc --- /dev/null +++ b/src/web/app/ch/tags/header.tag @@ -0,0 +1,20 @@ +<mk-header> + <div> + <a href={ CONFIG.chUrl }>Index</a> | <a href={ CONFIG.url }>Misskey</a> + </div> + <div> + <a if={ !SIGNIN } href={ CONFIG.url }>ログイン(新規登録)</a> + <a if={ SIGNIN } href={ CONFIG.url + '/' + I.username }>{ I.username }</a> + </div> + <style> + :scope + display flex + + > div:last-child + margin-left auto + + </style> + <script> + this.mixin('i'); + </script> +</mk-header> diff --git a/src/web/app/ch/tags/index.js b/src/web/app/ch/tags/index.js new file mode 100644 index 0000000000..12ffdaeb84 --- /dev/null +++ b/src/web/app/ch/tags/index.js @@ -0,0 +1,3 @@ +require('./index.tag'); +require('./channel.tag'); +require('./header.tag'); diff --git a/src/web/app/ch/tags/index.tag b/src/web/app/ch/tags/index.tag new file mode 100644 index 0000000000..50ccc0d91c --- /dev/null +++ b/src/web/app/ch/tags/index.tag @@ -0,0 +1,35 @@ +<mk-index> + <mk-header/> + <hr> + <button onclick={ n }>%i18n:ch.tags.mk-index.new%</button> + <hr> + <ul if={ channels }> + <li each={ channels }><a href={ '/' + this.id }>{ this.title }</a></li> + </ul> + <style> + :scope + display block + + </style> + <script> + this.mixin('api'); + + this.on('mount', () => { + this.api('channels').then(channels => { + this.update({ + channels: channels + }); + }); + }); + + this.n = () => { + const title = window.prompt('%i18n:ch.tags.mk-index.channel-title%'); + + this.api('channels/create', { + title: title + }).then(channel => { + location.href = '/' + channel.id; + }); + }; + </script> +</mk-index> diff --git a/src/web/app/common/scripts/channel-stream.js b/src/web/app/common/scripts/channel-stream.js new file mode 100644 index 0000000000..17944dbe45 --- /dev/null +++ b/src/web/app/common/scripts/channel-stream.js @@ -0,0 +1,16 @@ +'use strict'; + +import Stream from './stream'; + +/** + * Channel stream connection + */ +class Connection extends Stream { + constructor(channelId) { + super('channel', { + channel: channelId + }); + } +} + +export default Connection; diff --git a/src/web/app/common/scripts/config.js b/src/web/app/common/scripts/config.js index 75a7abba29..c5015622f0 100644 --- a/src/web/app/common/scripts/config.js +++ b/src/web/app/common/scripts/config.js @@ -6,6 +6,7 @@ const host = isRoot ? Url.host : Url.host.substring(Url.host.indexOf('.') + 1, U const scheme = Url.protocol; const url = `${scheme}//${host}`; const apiUrl = `${scheme}//api.${host}`; +const chUrl = `${scheme}//ch.${host}`; const devUrl = `${scheme}//dev.${host}`; const aboutUrl = `${scheme}//about.${host}`; const statsUrl = `${scheme}//stats.${host}`; @@ -16,6 +17,7 @@ export default { scheme, url, apiUrl, + chUrl, devUrl, aboutUrl, statsUrl, diff --git a/src/web/app/desktop/router.js b/src/web/app/desktop/router.js index afa8a2dce3..977e3fa9a6 100644 --- a/src/web/app/desktop/router.js +++ b/src/web/app/desktop/router.js @@ -7,14 +7,15 @@ const route = require('page'); let page = null; export default me => { - route('/', index); - route('/i>mentions', mentions); - route('/post::post', post); - route('/search::query', search); - route('/:user', user.bind(null, 'home')); - route('/:user/graphs', user.bind(null, 'graphs')); - route('/:user/:post', post); - route('*', notFound); + route('/', index); + route('/selectdrive', selectDrive); + route('/i>mentions', mentions); + route('/post::post', post); + route('/search::query', search); + route('/:user', user.bind(null, 'home')); + route('/:user/graphs', user.bind(null, 'graphs')); + route('/:user/:post', post); + route('*', notFound); function index() { me ? home() : entrance(); @@ -54,6 +55,10 @@ export default me => { mount(el); } + function selectDrive() { + mount(document.createElement('mk-selectdrive-page')); + } + function notFound() { mount(document.createElement('mk-not-found')); } @@ -67,6 +72,7 @@ export default me => { }; function mount(content) { + document.documentElement.style.background = '#313a42'; document.documentElement.removeAttribute('data-page'); if (page) page.unmount(); const body = document.getElementById('app'); diff --git a/src/web/app/desktop/style.styl b/src/web/app/desktop/style.styl index 88adb68b2b..4597dffdb3 100644 --- a/src/web/app/desktop/style.styl +++ b/src/web/app/desktop/style.styl @@ -1,4 +1,5 @@ -@import "../base" +@import "../app" +@import "../reset" @import "../../../../node_modules/cropperjs/dist/cropper.css" *::input-placeholder diff --git a/src/web/app/desktop/tags/home-widgets/rss-reader.tag b/src/web/app/desktop/tags/home-widgets/rss-reader.tag index 550d7e76de..e9b740762e 100644 --- a/src/web/app/desktop/tags/home-widgets/rss-reader.tag +++ b/src/web/app/desktop/tags/home-widgets/rss-reader.tag @@ -4,7 +4,7 @@ <div class="feed" if={ !initializing }> <virtual each={ item in items }><a href={ item.link } target="_blank">{ item.title }</a></virtual> </div> - <p class="initializing" if={ initializing }><i class="fa fa-spinner fa-pulse fa-fw"></i>読み込んでいます<mk-ellipsis/></p> + <p class="initializing" if={ initializing }><i class="fa fa-spinner fa-pulse fa-fw"></i>%i18n:common.loading%<mk-ellipsis/></p> <style> :scope display block diff --git a/src/web/app/desktop/tags/index.js b/src/web/app/desktop/tags/index.js index 4e286013a1..37fdfe37e4 100644 --- a/src/web/app/desktop/tags/index.js +++ b/src/web/app/desktop/tags/index.js @@ -61,6 +61,7 @@ require('./pages/user.tag'); require('./pages/post.tag'); require('./pages/search.tag'); require('./pages/not-found.tag'); +require('./pages/selectdrive.tag'); require('./autocomplete-suggestion.tag'); require('./progress-dialog.tag'); require('./user-preview.tag'); diff --git a/src/web/app/desktop/tags/notifications.tag b/src/web/app/desktop/tags/notifications.tag index 1046358ce9..a4f66105a8 100644 --- a/src/web/app/desktop/tags/notifications.tag +++ b/src/web/app/desktop/tags/notifications.tag @@ -252,6 +252,12 @@ }); this.onNotification = notification => { + // TODO: ユーザーが画面を見てないと思われるとき(ブラウザやタブがアクティブじゃないなど)は送信しない + this.stream.send({ + type: 'read_notification', + id: notification.id + }); + this.notifications.unshift(notification); this.update(); }; diff --git a/src/web/app/desktop/tags/pages/selectdrive.tag b/src/web/app/desktop/tags/pages/selectdrive.tag new file mode 100644 index 0000000000..63fc588fac --- /dev/null +++ b/src/web/app/desktop/tags/pages/selectdrive.tag @@ -0,0 +1,160 @@ +<mk-selectdrive-page> + <mk-drive-browser ref="browser" multiple={ multiple }/> + <div> + <button class="upload" title="PCからドライブにファイルをアップロード" onclick={ upload }><i class="fa fa-upload"></i></button> + <button class="cancel" onclick={ close }>キャンセル</button> + <button class="ok" onclick={ ok }>決定</button> + </div> + + <style> + :scope + display block + position fixed + height 100% + background #fff + + > mk-drive-browser + height calc(100% - 72px) + + > div + position fixed + bottom 0 + left 0 + width 100% + height 72px + background lighten($theme-color, 95%) + + .upload + display inline-block + position absolute + top 8px + left 16px + cursor pointer + padding 0 + margin 8px 4px 0 0 + width 40px + height 40px + font-size 1em + color rgba($theme-color, 0.5) + background transparent + outline none + border solid 1px transparent + border-radius 4px + + &:hover + background transparent + border-color rgba($theme-color, 0.3) + + &:active + color rgba($theme-color, 0.6) + background transparent + border-color rgba($theme-color, 0.5) + box-shadow 0 2px 4px rgba(darken($theme-color, 50%), 0.15) inset + + &:focus + &:after + content "" + pointer-events none + position absolute + top -5px + right -5px + bottom -5px + left -5px + border 2px solid rgba($theme-color, 0.3) + border-radius 8px + + .ok + .cancel + display block + position absolute + bottom 16px + cursor pointer + padding 0 + margin 0 + width 120px + height 40px + font-size 1em + outline none + border-radius 4px + + &:focus + &:after + content "" + pointer-events none + position absolute + top -5px + right -5px + bottom -5px + left -5px + border 2px solid rgba($theme-color, 0.3) + border-radius 8px + + &:disabled + opacity 0.7 + cursor default + + .ok + right 16px + color $theme-color-foreground + background linear-gradient(to bottom, lighten($theme-color, 25%) 0%, lighten($theme-color, 10%) 100%) + border solid 1px lighten($theme-color, 15%) + + &:not(:disabled) + font-weight bold + + &:hover:not(:disabled) + background linear-gradient(to bottom, lighten($theme-color, 8%) 0%, darken($theme-color, 8%) 100%) + border-color $theme-color + + &:active:not(:disabled) + background $theme-color + border-color $theme-color + + .cancel + right 148px + color #888 + background linear-gradient(to bottom, #ffffff 0%, #f5f5f5 100%) + border solid 1px #e2e2e2 + + &:hover + background linear-gradient(to bottom, #f9f9f9 0%, #ececec 100%) + border-color #dcdcdc + + &:active + background #ececec + border-color #dcdcdc + + </style> + <script> + const q = (new URL(location)).searchParams; + this.multiple = q.get('multiple') == 'true' ? true : false; + + this.on('mount', () => { + document.documentElement.style.background = '#fff'; + + this.refs.browser.on('selected', file => { + this.files = [file]; + this.ok(); + }); + + this.refs.browser.on('change-selection', files => { + this.update({ + files: files + }); + }); + }); + + this.upload = () => { + this.refs.browser.selectLocalFile(); + }; + + this.close = () => { + window.close(); + }; + + this.ok = () => { + window.opener.cb(this.multiple ? this.files : this.files[0]); + window.close(); + }; + </script> +</mk-selectdrive-page> diff --git a/src/web/app/desktop/tags/pages/user.tag b/src/web/app/desktop/tags/pages/user.tag index 864fe22735..811ca5c0fd 100644 --- a/src/web/app/desktop/tags/pages/user.tag +++ b/src/web/app/desktop/tags/pages/user.tag @@ -16,7 +16,7 @@ this.refs.ui.refs.user.on('user-fetched', user => { Progress.set(0.5); - document.title = user.name + ' | Misskey' + document.title = user.name + ' | Misskey'; }); this.refs.ui.refs.user.on('loaded', () => { diff --git a/src/web/app/desktop/tags/post-detail.tag b/src/web/app/desktop/tags/post-detail.tag index 58343482d0..ce7f81e32c 100644 --- a/src/web/app/desktop/tags/post-detail.tag +++ b/src/web/app/desktop/tags/post-detail.tag @@ -1,6 +1,6 @@ <mk-post-detail title={ title }> <div class="main"> - <button class="read-more" if={ p.reply_to && p.reply_to.reply_to_id && context == null } title="会話をもっと読み込む" onclick={ loadContext } disabled={ contextFetching }> + <button class="read-more" if={ p.reply && p.reply.reply_id && context == null } title="会話をもっと読み込む" onclick={ loadContext } disabled={ contextFetching }> <i class="fa fa-ellipsis-v" if={ !contextFetching }></i> <i class="fa fa-spinner fa-pulse" if={ contextFetching }></i> </button> @@ -9,8 +9,8 @@ <mk-post-detail-sub post={ post }/> </virtual> </div> - <div class="reply-to" if={ p.reply_to }> - <mk-post-detail-sub post={ p.reply_to }/> + <div class="reply-to" if={ p.reply }> + <mk-post-detail-sub post={ p.reply }/> </div> <div class="repost" if={ isRepost }> <p> @@ -329,7 +329,7 @@ // Fetch context this.api('posts/context', { - post_id: this.p.reply_to_id + post_id: this.p.reply_id }).then(context => { this.update({ contextFetching: false, diff --git a/src/web/app/desktop/tags/post-form.tag b/src/web/app/desktop/tags/post-form.tag index 6a363d67cd..5041078bee 100644 --- a/src/web/app/desktop/tags/post-form.tag +++ b/src/web/app/desktop/tags/post-form.tag @@ -475,7 +475,7 @@ this.api('posts/create', { text: this.refs.text.value == '' ? undefined : this.refs.text.value, media_ids: files, - reply_to_id: this.inReplyToPost ? this.inReplyToPost.id : undefined, + reply_id: this.inReplyToPost ? this.inReplyToPost.id : undefined, repost_id: this.repost ? this.repost.id : undefined, poll: this.poll ? this.refs.poll.get() : undefined }).then(data => { diff --git a/src/web/app/desktop/tags/sub-post-content.tag b/src/web/app/desktop/tags/sub-post-content.tag index 02cb5251b2..c75ae2911c 100644 --- a/src/web/app/desktop/tags/sub-post-content.tag +++ b/src/web/app/desktop/tags/sub-post-content.tag @@ -1,6 +1,6 @@ <mk-sub-post-content> <div class="body"> - <a class="reply" if={ post.reply_to_id }> + <a class="reply" if={ post.reply_id }> <i class="fa fa-reply"></i> </a> <span ref="text"></span> diff --git a/src/web/app/desktop/tags/timeline.tag b/src/web/app/desktop/tags/timeline.tag index 2d6b439e38..44f3d5d8ec 100644 --- a/src/web/app/desktop/tags/timeline.tag +++ b/src/web/app/desktop/tags/timeline.tag @@ -82,8 +82,8 @@ </mk-timeline> <mk-timeline-post tabindex="-1" title={ title } onkeydown={ onKeyDown } dblclick={ onDblClick }> - <div class="reply-to" if={ p.reply_to }> - <mk-timeline-post-sub post={ p.reply_to }/> + <div class="reply-to" if={ p.reply }> + <mk-timeline-post-sub post={ p.reply }/> </div> <div class="repost" if={ isRepost }> <p> @@ -112,7 +112,8 @@ </header> <div class="body"> <div class="text" ref="text"> - <a class="reply" if={ p.reply_to }> + <p class="channel" if={ p.channel != null }><a href={ CONFIG.chUrl + '/' + p.channel.id } target="_blank">{ p.channel.title }</a>:</p> + <a class="reply" if={ p.reply }> <i class="fa fa-reply"></i> </a> <p class="dummy"></p> @@ -333,6 +334,9 @@ font-weight 400 font-style normal + > .channel + margin 0 + > .reply margin-right 8px color #717171 diff --git a/src/web/app/desktop/tags/ui.tag b/src/web/app/desktop/tags/ui.tag index e0d7393b08..3123c34f4f 100644 --- a/src/web/app/desktop/tags/ui.tag +++ b/src/web/app/desktop/tags/ui.tag @@ -319,18 +319,26 @@ </mk-ui-header-notifications> <mk-ui-header-nav> - <ul if={ SIGNIN }> - <li class="home { active: page == 'home' }"> - <a href={ CONFIG.url }> - <i class="fa fa-home"></i> - <p>%i18n:desktop.tags.mk-ui-header-nav.home%</p> - </a> - </li> - <li class="messaging"> - <a onclick={ messaging }> - <i class="fa fa-comments"></i> - <p>%i18n:desktop.tags.mk-ui-header-nav.messaging%</p> - <i class="fa fa-circle" if={ hasUnreadMessagingMessages }></i> + <ul> + <virtual if={ SIGNIN }> + <li class="home { active: page == 'home' }"> + <a href={ CONFIG.url }> + <i class="fa fa-home"></i> + <p>%i18n:desktop.tags.mk-ui-header-nav.home%</p> + </a> + </li> + <li class="messaging"> + <a onclick={ messaging }> + <i class="fa fa-comments"></i> + <p>%i18n:desktop.tags.mk-ui-header-nav.messaging%</p> + <i class="fa fa-circle" if={ hasUnreadMessagingMessages }></i> + </a> + </li> + </virtual> + <li class="ch"> + <a href={ CONFIG.chUrl } target="_blank"> + <i class="fa fa-television"></i> + <p>%i18n:desktop.tags.mk-ui-header-nav.ch%</p> </a> </li> <li class="info"> diff --git a/src/web/app/dev/style.styl b/src/web/app/dev/style.styl index 4fd537709d..cdbcb0e261 100644 --- a/src/web/app/dev/style.styl +++ b/src/web/app/dev/style.styl @@ -1,4 +1,5 @@ -@import "../base" +@import "../app" +@import "../reset" html background-color #fff diff --git a/src/web/app/init.js b/src/web/app/init.js index cb661c2595..5a6899ed4f 100644 --- a/src/web/app/init.js +++ b/src/web/app/init.js @@ -21,6 +21,11 @@ require('./common/tags'); console.info(`Misskey v${VERSION} (葵 aoi)`); +{ // Set lang attr + const html = document.documentElement; + html.setAttribute('lang', LANG); +} + { // Set description meta tag const head = document.getElementsByTagName('head')[0]; const meta = document.createElement('meta'); diff --git a/src/web/app/mobile/router.js b/src/web/app/mobile/router.js index d59b2ec3a1..01eb3c8145 100644 --- a/src/web/app/mobile/router.js +++ b/src/web/app/mobile/router.js @@ -8,6 +8,7 @@ let page = null; export default me => { route('/', index); + route('/selectdrive', selectDrive); route('/i/notifications', notifications); route('/i/messaging', messaging); route('/i/messaging/:username', messaging); @@ -122,6 +123,10 @@ export default me => { mount(el); } + function selectDrive() { + mount(document.createElement('mk-selectdrive-page')); + } + function notFound() { mount(document.createElement('mk-not-found')); } diff --git a/src/web/app/mobile/style.styl b/src/web/app/mobile/style.styl index bd6965e402..63e4f2349f 100644 --- a/src/web/app/mobile/style.styl +++ b/src/web/app/mobile/style.styl @@ -1,4 +1,5 @@ -@import "../base" +@import "../app" +@import "../reset" #wait top auto diff --git a/src/web/app/mobile/tags/drive.tag b/src/web/app/mobile/tags/drive.tag index 9f3e647735..6929c50ab1 100644 --- a/src/web/app/mobile/tags/drive.tag +++ b/src/web/app/mobile/tags/drive.tag @@ -1,5 +1,5 @@ <mk-drive> - <nav> + <nav ref="nav"> <p onclick={ goRoot }><i class="fa fa-cloud"></i>%i18n:mobile.tags.mk-drive.drive%</p> <virtual each={ folder in hierarchyFolders }> <span><i class="fa fa-angle-right"></i></span> @@ -56,10 +56,6 @@ display block background #fff - &[data-is-naked] - > nav - top 48px - > nav display block position sticky @@ -205,6 +201,10 @@ } else { this.fetch(); } + + if (this.opts.isNaked) { + this.refs.nav.style.top = `${this.opts.top}px`; + } }); this.on('unmount', () => { @@ -483,7 +483,7 @@ if (fn == null || fn == '') return; switch (fn) { case '1': - this.refs.file.click(); + this.selectLocalFile(); break; case '2': this.urlUpload(); @@ -503,6 +503,10 @@ } }; + this.selectLocalFile = () => { + this.refs.file.click(); + }; + this.createFolder = () => { const name = window.prompt('フォルダー名'); if (name == null || name == '') return; diff --git a/src/web/app/mobile/tags/index.js b/src/web/app/mobile/tags/index.js index c5aafd20ba..19952c20cd 100644 --- a/src/web/app/mobile/tags/index.js +++ b/src/web/app/mobile/tags/index.js @@ -1,6 +1,4 @@ require('./ui.tag'); -require('./ui-header.tag'); -require('./ui-nav.tag'); require('./page/entrance.tag'); require('./page/entrance/signin.tag'); require('./page/entrance/signup.tag'); @@ -21,6 +19,7 @@ require('./page/settings/authorized-apps.tag'); require('./page/settings/twitter.tag'); require('./page/messaging.tag'); require('./page/messaging-room.tag'); +require('./page/selectdrive.tag'); require('./home.tag'); require('./home-timeline.tag'); require('./timeline.tag'); diff --git a/src/web/app/mobile/tags/notifications.tag b/src/web/app/mobile/tags/notifications.tag index 7370aa84d3..2e95990314 100644 --- a/src/web/app/mobile/tags/notifications.tag +++ b/src/web/app/mobile/tags/notifications.tag @@ -123,6 +123,12 @@ }); this.onNotification = notification => { + // TODO: ユーザーが画面を見てないと思われるとき(ブラウザやタブがアクティブじゃないなど)は送信しない + this.stream.send({ + type: 'read_notification', + id: notification.id + }); + this.notifications.unshift(notification); this.update(); }; diff --git a/src/web/app/mobile/tags/page/drive.tag b/src/web/app/mobile/tags/page/drive.tag index 1169e3b9eb..218960c702 100644 --- a/src/web/app/mobile/tags/page/drive.tag +++ b/src/web/app/mobile/tags/page/drive.tag @@ -1,6 +1,6 @@ <mk-drive-page> <mk-ui ref="ui"> - <mk-drive ref="browser" folder={ parent.opts.folder } file={ parent.opts.file } data-is-naked="true"/> + <mk-drive ref="browser" folder={ parent.opts.folder } file={ parent.opts.file } is-naked={ true } top={ 48 }/> </mk-ui> <style> :scope diff --git a/src/web/app/mobile/tags/page/notifications.tag b/src/web/app/mobile/tags/page/notifications.tag index 06a5be039f..743de04393 100644 --- a/src/web/app/mobile/tags/page/notifications.tag +++ b/src/web/app/mobile/tags/page/notifications.tag @@ -10,16 +10,30 @@ import ui from '../../scripts/ui-event'; import Progress from '../../../common/scripts/loading'; + this.mixin('api'); + this.on('mount', () => { document.title = 'Misskey | %i18n:mobile.tags.mk-notifications-page.notifications%'; ui.trigger('title', '<i class="fa fa-bell-o"></i>%i18n:mobile.tags.mk-notifications-page.notifications%'); document.documentElement.style.background = '#313a42'; + ui.trigger('func', () => { + this.readAll(); + }, 'check'); + Progress.start(); this.refs.ui.refs.notifications.on('fetched', () => { Progress.done(); }); }); + + this.readAll = () => { + const ok = window.confirm('%i18n:mobile.tags.mk-notifications-page.read-all%'); + + if (!ok) return; + + this.api('notifications/mark_as_read_all'); + }; </script> </mk-notifications-page> diff --git a/src/web/app/mobile/tags/page/selectdrive.tag b/src/web/app/mobile/tags/page/selectdrive.tag new file mode 100644 index 0000000000..79ea3548f8 --- /dev/null +++ b/src/web/app/mobile/tags/page/selectdrive.tag @@ -0,0 +1,87 @@ +<mk-selectdrive-page> + <header> + <h1>%i18n:mobile.tags.mk-selectdrive-page.select-file%<span class="count" if={ files.length > 0 }>({ files.length })</span></h1> + <button class="upload" onclick={ upload }><i class="fa fa-upload"></i></button> + <button if={ multiple } class="ok" onclick={ ok }><i class="fa fa-check"></i></button> + </header> + <mk-drive ref="browser" select-file={ true } multiple={ multiple } is-naked={ true } top={ 42 }/> + + <style> + :scope + display block + width 100% + height 100% + background #fff + + > header + position fixed + top 0 + left 0 + width 100% + z-index 1000 + background #fff + box-shadow 0 1px rgba(0, 0, 0, 0.1) + + > h1 + margin 0 + padding 0 + text-align center + line-height 42px + font-size 1em + font-weight normal + + > .count + margin-left 4px + opacity 0.5 + + > .upload + position absolute + top 0 + left 0 + line-height 42px + width 42px + + > .ok + position absolute + top 0 + right 0 + line-height 42px + width 42px + + > mk-drive + top 42px + + </style> + <script> + const q = (new URL(location)).searchParams; + this.multiple = q.get('multiple') == 'true' ? true : false; + + this.on('mount', () => { + document.documentElement.style.background = '#fff'; + + this.refs.browser.on('selected', file => { + this.files = [file]; + this.ok(); + }); + + this.refs.browser.on('change-selection', files => { + this.update({ + files: files + }); + }); + }); + + this.upload = () => { + this.refs.browser.selectLocalFile(); + }; + + this.close = () => { + window.close(); + }; + + this.ok = () => { + window.opener.cb(this.multiple ? this.files : this.files[0]); + window.close(); + }; + </script> +</mk-selectdrive-page> diff --git a/src/web/app/mobile/tags/post-detail.tag b/src/web/app/mobile/tags/post-detail.tag index ed275749ec..8a32101036 100644 --- a/src/web/app/mobile/tags/post-detail.tag +++ b/src/web/app/mobile/tags/post-detail.tag @@ -1,5 +1,5 @@ <mk-post-detail> - <button class="read-more" if={ p.reply_to && p.reply_to.reply_to_id && context == null } onclick={ loadContext } disabled={ loadingContext }> + <button class="read-more" if={ p.reply && p.reply.reply_id && context == null } onclick={ loadContext } disabled={ loadingContext }> <i class="fa fa-ellipsis-v" if={ !contextFetching }></i> <i class="fa fa-spinner fa-pulse" if={ contextFetching }></i> </button> @@ -8,8 +8,8 @@ <mk-post-detail-sub post={ post }/> </virtual> </div> - <div class="reply-to" if={ p.reply_to }> - <mk-post-detail-sub post={ p.reply_to }/> + <div class="reply-to" if={ p.reply }> + <mk-post-detail-sub post={ p.reply }/> </div> <div class="repost" if={ isRepost }> <p> @@ -348,7 +348,7 @@ // Fetch context this.api('posts/context', { - post_id: this.p.reply_to_id + post_id: this.p.reply_id }).then(context => { this.update({ contextFetching: false, diff --git a/src/web/app/mobile/tags/post-form.tag b/src/web/app/mobile/tags/post-form.tag index cf267de94a..d7d382c9e2 100644 --- a/src/web/app/mobile/tags/post-form.tag +++ b/src/web/app/mobile/tags/post-form.tag @@ -267,7 +267,7 @@ this.api('posts/create', { text: this.refs.text.value == '' ? undefined : this.refs.text.value, media_ids: files, - reply_to_id: opts.reply ? opts.reply.id : undefined, + reply_id: opts.reply ? opts.reply.id : undefined, poll: this.poll ? this.refs.poll.get() : undefined }).then(data => { this.trigger('post'); diff --git a/src/web/app/mobile/tags/sub-post-content.tag b/src/web/app/mobile/tags/sub-post-content.tag index 97e0ecec03..e32e245185 100644 --- a/src/web/app/mobile/tags/sub-post-content.tag +++ b/src/web/app/mobile/tags/sub-post-content.tag @@ -1,5 +1,5 @@ <mk-sub-post-content> - <div class="body"><a class="reply" if={ post.reply_to_id }><i class="fa fa-reply"></i></a><span ref="text"></span><a class="quote" if={ post.repost_id } href={ '/post:' + post.repost_id }>RP: ...</a></div> + <div class="body"><a class="reply" if={ post.reply_id }><i class="fa fa-reply"></i></a><span ref="text"></span><a class="quote" if={ post.repost_id } href={ '/post:' + post.repost_id }>RP: ...</a></div> <details if={ post.media }> <summary>({ post.media.length }個のメディア)</summary> <mk-images-viewer images={ post.media }/> diff --git a/src/web/app/mobile/tags/timeline.tag b/src/web/app/mobile/tags/timeline.tag index c7f5bfd681..f9ec2cca60 100644 --- a/src/web/app/mobile/tags/timeline.tag +++ b/src/web/app/mobile/tags/timeline.tag @@ -137,8 +137,8 @@ </mk-timeline> <mk-timeline-post class={ repost: isRepost }> - <div class="reply-to" if={ p.reply_to }> - <mk-timeline-post-sub post={ p.reply_to }/> + <div class="reply-to" if={ p.reply }> + <mk-timeline-post-sub post={ p.reply }/> </div> <div class="repost" if={ isRepost }> <p> @@ -164,7 +164,8 @@ </header> <div class="body"> <div class="text" ref="text"> - <a class="reply" if={ p.reply_to }> + <p class="channel" if={ p.channel != null }><a href={ CONFIG.chUrl + '/' + p.channel.id } target="_blank">{ p.channel.title }</a>:</p> + <a class="reply" if={ p.reply }> <i class="fa fa-reply"></i> </a> <p class="dummy"></p> @@ -373,6 +374,9 @@ mk-url-preview margin-top 8px + > .channel + margin 0 + > .reply margin-right 8px color #717171 diff --git a/src/web/app/mobile/tags/ui-header.tag b/src/web/app/mobile/tags/ui-header.tag deleted file mode 100644 index 10b44b2153..0000000000 --- a/src/web/app/mobile/tags/ui-header.tag +++ /dev/null @@ -1,156 +0,0 @@ -<mk-ui-header> - <mk-special-message/> - <div class="main"> - <div class="backdrop"></div> - <div class="content"> - <button class="nav" onclick={ parent.toggleDrawer }><i class="fa fa-bars"></i></button> - <i class="fa fa-circle" if={ hasUnreadMessagingMessages }></i> - <h1 ref="title">Misskey</h1> - <button if={ func } onclick={ func }><i class="fa fa-{ funcIcon }"></i></button> - </div> - </div> - <style> - :scope - $height = 48px - - display block - position fixed - top 0 - z-index 1024 - width 100% - box-shadow 0 1px 0 rgba(#000, 0.075) - - > .main - color rgba(#fff, 0.9) - - > .backdrop - position absolute - top 0 - z-index 1023 - width 100% - height $height - -webkit-backdrop-filter blur(12px) - backdrop-filter blur(12px) - background-color rgba(#1b2023, 0.75) - - > .content - z-index 1024 - - > h1 - display block - margin 0 auto - padding 0 - width 100% - max-width calc(100% - 112px) - text-align center - font-size 1.1em - font-weight normal - line-height $height - white-space nowrap - overflow hidden - text-overflow ellipsis - - > i - > .icon - margin-right 8px - - > img - display inline-block - vertical-align bottom - width ($height - 16px) - height ($height - 16px) - margin 8px - border-radius 6px - - > .nav - display block - position absolute - top 0 - left 0 - width $height - font-size 1.4em - line-height $height - border-right solid 1px rgba(#000, 0.1) - - > i - transition all 0.2s ease - - > i - position absolute - top 8px - left 8px - pointer-events none - font-size 10px - color $theme-color - - > button:last-child - display block - position absolute - top 0 - right 0 - width $height - text-align center - font-size 1.4em - color inherit - line-height $height - border-left solid 1px rgba(#000, 0.1) - - </style> - <script> - import ui from '../scripts/ui-event'; - - this.mixin('api'); - this.mixin('stream'); - - this.func = null; - this.funcIcon = null; - - this.on('mount', () => { - this.stream.on('read_all_messaging_messages', this.onReadAllMessagingMessages); - this.stream.on('unread_messaging_message', this.onUnreadMessagingMessage); - - // Fetch count of unread messaging messages - this.api('messaging/unread').then(res => { - if (res.count > 0) { - this.update({ - hasUnreadMessagingMessages: true - }); - } - }); - }); - - this.on('unmount', () => { - this.stream.off('read_all_messaging_messages', this.onReadAllMessagingMessages); - this.stream.off('unread_messaging_message', this.onUnreadMessagingMessage); - - ui.off('title', this.setTitle); - ui.off('func', this.setFunc); - }); - - this.onReadAllMessagingMessages = () => { - this.update({ - hasUnreadMessagingMessages: false - }); - }; - - this.onUnreadMessagingMessage = () => { - this.update({ - hasUnreadMessagingMessages: true - }); - }; - - this.setTitle = title => { - this.refs.title.innerHTML = title; - }; - - this.setFunc = (fn, icon) => { - this.update({ - func: fn, - funcIcon: icon - }); - }; - - ui.on('title', this.setTitle); - ui.on('func', this.setFunc); - </script> -</mk-ui-header> diff --git a/src/web/app/mobile/tags/ui-nav.tag b/src/web/app/mobile/tags/ui-nav.tag deleted file mode 100644 index 34235ba4f1..0000000000 --- a/src/web/app/mobile/tags/ui-nav.tag +++ /dev/null @@ -1,170 +0,0 @@ -<mk-ui-nav> - <div class="backdrop" onclick={ parent.toggleDrawer }></div> - <div class="body"> - <a class="me" if={ SIGNIN } href={ '/' + I.username }> - <img class="avatar" src={ I.avatar_url + '?thumbnail&size=128' } alt="avatar"/> - <p class="name">{ I.name }</p> - </a> - <div class="links"> - <ul> - <li><a href="/"><i class="fa fa-home"></i>%i18n:mobile.tags.mk-ui-nav.home%<i class="fa fa-angle-right"></i></a></li> - <li><a href="/i/notifications"><i class="fa fa-bell-o"></i>%i18n:mobile.tags.mk-ui-nav.notifications%<i class="fa fa-angle-right"></i></a></li> - <li><a href="/i/messaging"><i class="fa fa-comments-o"></i>%i18n:mobile.tags.mk-ui-nav.messaging%<i class="i fa fa-circle" if={ hasUnreadMessagingMessages }></i><i class="fa fa-angle-right"></i></a></li> - </ul> - <ul> - <li><a onclick={ search }><i class="fa fa-search"></i>%i18n:mobile.tags.mk-ui-nav.search%<i class="fa fa-angle-right"></i></a></li> - </ul> - <ul> - <li><a href="/i/drive"><i class="fa fa-cloud"></i>%i18n:mobile.tags.mk-ui-nav.drive%<i class="fa fa-angle-right"></i></a></li> - </ul> - <ul> - <li><a href="/i/settings"><i class="fa fa-cog"></i>%i18n:mobile.tags.mk-ui-nav.settings%<i class="fa fa-angle-right"></i></a></li> - </ul> - </div> - <a href={ CONFIG.aboutUrl }><p class="about">%i18n:mobile.tags.mk-ui-nav.about%</p></a> - </div> - <style> - :scope - display none - - .backdrop - position fixed - top 0 - left 0 - z-index 1025 - width 100% - height 100% - background rgba(0, 0, 0, 0.2) - - .body - position fixed - top 0 - left 0 - z-index 1026 - width 240px - height 100% - overflow auto - -webkit-overflow-scrolling touch - color #777 - background #fff - - .me - display block - margin 0 - padding 16px - - .avatar - display inline - max-width 64px - border-radius 32px - vertical-align middle - - .name - display block - margin 0 16px - position absolute - top 0 - left 80px - padding 0 - width calc(100% - 112px) - color #777 - line-height 96px - overflow hidden - text-overflow ellipsis - white-space nowrap - - ul - display block - margin 16px 0 - padding 0 - list-style none - - &:first-child - margin-top 0 - - li - display block - font-size 1em - line-height 1em - - a - display block - padding 0 20px - line-height 3rem - line-height calc(1rem + 30px) - color #777 - text-decoration none - - > i:first-child - margin-right 0.5em - - > .i - margin-left 6px - vertical-align super - font-size 10px - color $theme-color - - > i:last-child - position absolute - top 0 - right 0 - padding 0 20px - font-size 1.2em - line-height calc(1rem + 30px) - color #ccc - - .about - margin 0 - padding 1em 0 - text-align center - font-size 0.8em - opacity 0.5 - - a - color #777 - - </style> - <script> - this.mixin('i'); - this.mixin('page'); - this.mixin('api'); - this.mixin('stream'); - - this.on('mount', () => { - this.stream.on('read_all_messaging_messages', this.onReadAllMessagingMessages); - this.stream.on('unread_messaging_message', this.onUnreadMessagingMessage); - - // Fetch count of unread messaging messages - this.api('messaging/unread').then(res => { - if (res.count > 0) { - this.update({ - hasUnreadMessagingMessages: true - }); - } - }); - }); - - this.on('unmount', () => { - this.stream.off('read_all_messaging_messages', this.onReadAllMessagingMessages); - this.stream.off('unread_messaging_message', this.onUnreadMessagingMessage); - }); - - this.onReadAllMessagingMessages = () => { - this.update({ - hasUnreadMessagingMessages: false - }); - }; - - this.onUnreadMessagingMessage = () => { - this.update({ - hasUnreadMessagingMessages: true - }); - }; - - this.search = () => { - const query = window.prompt('%i18n:mobile.tags.mk-ui-nav.search%'); - if (query == null || query == '') return; - this.page('/search:' + query); - }; - </script> -</mk-ui-nav> diff --git a/src/web/app/mobile/tags/ui.tag b/src/web/app/mobile/tags/ui.tag index 9d9cd4d74a..b2d96f6b8b 100644 --- a/src/web/app/mobile/tags/ui.tag +++ b/src/web/app/mobile/tags/ui.tag @@ -30,9 +30,378 @@ }; this.onStreamNotification = notification => { + // TODO: ユーザーが画面を見てないと思われるとき(ブラウザやタブがアクティブじゃないなど)は送信しない + this.stream.send({ + type: 'read_notification', + id: notification.id + }); + riot.mount(document.body.appendChild(document.createElement('mk-notify')), { notification: notification }); }; </script> </mk-ui> + +<mk-ui-header> + <mk-special-message/> + <div class="main"> + <div class="backdrop"></div> + <div class="content"> + <button class="nav" onclick={ parent.toggleDrawer }><i class="fa fa-bars"></i></button> + <i class="fa fa-circle" if={ hasUnreadNotifications || hasUnreadMessagingMessages }></i> + <h1 ref="title">Misskey</h1> + <button if={ func } onclick={ func }><i class="fa fa-{ funcIcon }"></i></button> + </div> + </div> + <style> + :scope + $height = 48px + + display block + position fixed + top 0 + z-index 1024 + width 100% + box-shadow 0 1px 0 rgba(#000, 0.075) + + > .main + color rgba(#fff, 0.9) + + > .backdrop + position absolute + top 0 + z-index 1023 + width 100% + height $height + -webkit-backdrop-filter blur(12px) + backdrop-filter blur(12px) + background-color rgba(#1b2023, 0.75) + + > .content + z-index 1024 + + > h1 + display block + margin 0 auto + padding 0 + width 100% + max-width calc(100% - 112px) + text-align center + font-size 1.1em + font-weight normal + line-height $height + white-space nowrap + overflow hidden + text-overflow ellipsis + + > i + > .icon + margin-right 8px + + > img + display inline-block + vertical-align bottom + width ($height - 16px) + height ($height - 16px) + margin 8px + border-radius 6px + + > .nav + display block + position absolute + top 0 + left 0 + width $height + font-size 1.4em + line-height $height + border-right solid 1px rgba(#000, 0.1) + + > i + transition all 0.2s ease + + > i + position absolute + top 8px + left 8px + pointer-events none + font-size 10px + color $theme-color + + > button:last-child + display block + position absolute + top 0 + right 0 + width $height + text-align center + font-size 1.4em + color inherit + line-height $height + border-left solid 1px rgba(#000, 0.1) + + </style> + <script> + import ui from '../scripts/ui-event'; + + this.mixin('api'); + this.mixin('stream'); + + this.func = null; + this.funcIcon = null; + + this.on('mount', () => { + this.stream.on('read_all_notifications', this.onReadAllNotifications); + this.stream.on('read_all_messaging_messages', this.onReadAllMessagingMessages); + this.stream.on('unread_messaging_message', this.onUnreadMessagingMessage); + + // Fetch count of unread notifications + this.api('notifications/get_unread_count').then(res => { + if (res.count > 0) { + this.update({ + hasUnreadNotifications: true + }); + } + }); + + // Fetch count of unread messaging messages + this.api('messaging/unread').then(res => { + if (res.count > 0) { + this.update({ + hasUnreadMessagingMessages: true + }); + } + }); + }); + + this.on('unmount', () => { + this.stream.off('read_all_notifications', this.onReadAllNotifications); + this.stream.off('read_all_messaging_messages', this.onReadAllMessagingMessages); + this.stream.off('unread_messaging_message', this.onUnreadMessagingMessage); + + ui.off('title', this.setTitle); + ui.off('func', this.setFunc); + }); + + this.onReadAllNotifications = () => { + this.update({ + hasUnreadNotifications: false + }); + }; + + this.onReadAllMessagingMessages = () => { + this.update({ + hasUnreadMessagingMessages: false + }); + }; + + this.onUnreadMessagingMessage = () => { + this.update({ + hasUnreadMessagingMessages: true + }); + }; + + this.setTitle = title => { + this.refs.title.innerHTML = title; + }; + + this.setFunc = (fn, icon) => { + this.update({ + func: fn, + funcIcon: icon + }); + }; + + ui.on('title', this.setTitle); + ui.on('func', this.setFunc); + </script> +</mk-ui-header> + +<mk-ui-nav> + <div class="backdrop" onclick={ parent.toggleDrawer }></div> + <div class="body"> + <a class="me" if={ SIGNIN } href={ '/' + I.username }> + <img class="avatar" src={ I.avatar_url + '?thumbnail&size=128' } alt="avatar"/> + <p class="name">{ I.name }</p> + </a> + <div class="links"> + <ul> + <li><a href="/"><i class="fa fa-home"></i>%i18n:mobile.tags.mk-ui-nav.home%<i class="fa fa-angle-right"></i></a></li> + <li><a href="/i/notifications"><i class="fa fa-bell-o"></i>%i18n:mobile.tags.mk-ui-nav.notifications%<i class="i fa fa-circle" if={ hasUnreadNotifications }></i><i class="fa fa-angle-right"></i></a></li> + <li><a href="/i/messaging"><i class="fa fa-comments-o"></i>%i18n:mobile.tags.mk-ui-nav.messaging%<i class="i fa fa-circle" if={ hasUnreadMessagingMessages }></i><i class="fa fa-angle-right"></i></a></li> + </ul> + <ul> + <li><a href={ CONFIG.chUrl } target="_blank"><i class="fa fa-television"></i>%i18n:mobile.tags.mk-ui-nav.ch%<i class="fa fa-angle-right"></i></a></li> + <li><a href="/i/drive"><i class="fa fa-cloud"></i>%i18n:mobile.tags.mk-ui-nav.drive%<i class="fa fa-angle-right"></i></a></li> + </ul> + <ul> + <li><a onclick={ search }><i class="fa fa-search"></i>%i18n:mobile.tags.mk-ui-nav.search%<i class="fa fa-angle-right"></i></a></li> + </ul> + <ul> + <li><a href="/i/settings"><i class="fa fa-cog"></i>%i18n:mobile.tags.mk-ui-nav.settings%<i class="fa fa-angle-right"></i></a></li> + </ul> + </div> + <a href={ CONFIG.aboutUrl }><p class="about">%i18n:mobile.tags.mk-ui-nav.about%</p></a> + </div> + <style> + :scope + display none + + .backdrop + position fixed + top 0 + left 0 + z-index 1025 + width 100% + height 100% + background rgba(0, 0, 0, 0.2) + + .body + position fixed + top 0 + left 0 + z-index 1026 + width 240px + height 100% + overflow auto + -webkit-overflow-scrolling touch + color #777 + background #fff + + .me + display block + margin 0 + padding 16px + + .avatar + display inline + max-width 64px + border-radius 32px + vertical-align middle + + .name + display block + margin 0 16px + position absolute + top 0 + left 80px + padding 0 + width calc(100% - 112px) + color #777 + line-height 96px + overflow hidden + text-overflow ellipsis + white-space nowrap + + ul + display block + margin 16px 0 + padding 0 + list-style none + + &:first-child + margin-top 0 + + li + display block + font-size 1em + line-height 1em + + a + display block + padding 0 20px + line-height 3rem + line-height calc(1rem + 30px) + color #777 + text-decoration none + + > i:first-child + margin-right 0.5em + + > .i + margin-left 6px + vertical-align super + font-size 10px + color $theme-color + + > i:last-child + position absolute + top 0 + right 0 + padding 0 20px + font-size 1.2em + line-height calc(1rem + 30px) + color #ccc + + .about + margin 0 + padding 1em 0 + text-align center + font-size 0.8em + opacity 0.5 + + a + color #777 + + </style> + <script> + this.mixin('i'); + this.mixin('page'); + this.mixin('api'); + this.mixin('stream'); + + this.on('mount', () => { + this.stream.on('read_all_notifications', this.onReadAllNotifications); + this.stream.on('read_all_messaging_messages', this.onReadAllMessagingMessages); + this.stream.on('unread_messaging_message', this.onUnreadMessagingMessage); + + // Fetch count of unread notifications + this.api('notifications/get_unread_count').then(res => { + if (res.count > 0) { + this.update({ + hasUnreadNotifications: true + }); + } + }); + + // Fetch count of unread messaging messages + this.api('messaging/unread').then(res => { + if (res.count > 0) { + this.update({ + hasUnreadMessagingMessages: true + }); + } + }); + }); + + this.on('unmount', () => { + this.stream.off('read_all_notifications', this.onReadAllNotifications); + this.stream.off('read_all_messaging_messages', this.onReadAllMessagingMessages); + this.stream.off('unread_messaging_message', this.onUnreadMessagingMessage); + }); + + this.onReadAllNotifications = () => { + this.update({ + hasUnreadNotifications: false + }); + }; + + this.onReadAllMessagingMessages = () => { + this.update({ + hasUnreadMessagingMessages: false + }); + }; + + this.onUnreadMessagingMessage = () => { + this.update({ + hasUnreadMessagingMessages: true + }); + }; + + this.search = () => { + const query = window.prompt('%i18n:mobile.tags.mk-ui-nav.search%'); + if (query == null || query == '') return; + this.page('/search:' + query); + }; + </script> +</mk-ui-nav> diff --git a/src/web/app/reset.styl b/src/web/app/reset.styl index 85bbd11473..3d4b06dbdf 100644 --- a/src/web/app/reset.styl +++ b/src/web/app/reset.styl @@ -1,16 +1,3 @@ -* - position relative - box-sizing border-box - background-clip padding-box !important - -html -body - margin 0 - padding 0 - -body - overflow-wrap break-word - input:not([type]) input[type='text'] input[type='password'] diff --git a/src/web/app/safe.js b/src/web/app/safe.js index c5fbb83a92..77293be81d 100644 --- a/src/web/app/safe.js +++ b/src/web/app/safe.js @@ -7,5 +7,8 @@ if (!('fetch' in window)) { alert( 'お使いのブラウザが古いためMisskeyを動作させることができません。' + - 'バージョンを最新のものに更新するか、別のブラウザをお試しください。'); + 'バージョンを最新のものに更新するか、別のブラウザをお試しください。' + + '\n\n' + + 'Your browser seems outdated.' + + 'To run Misskey, please update your browser to latest version or try other browsers.'); } diff --git a/src/web/app/stats/style.styl b/src/web/app/stats/style.styl index b48d7aeb9e..5ae230ea56 100644 --- a/src/web/app/stats/style.styl +++ b/src/web/app/stats/style.styl @@ -1,4 +1,5 @@ -@import "../base" +@import "../app" +@import "../reset" html color #456267 diff --git a/src/web/app/status/style.styl b/src/web/app/status/style.styl index b48d7aeb9e..5ae230ea56 100644 --- a/src/web/app/status/style.styl +++ b/src/web/app/status/style.styl @@ -1,4 +1,5 @@ -@import "../base" +@import "../app" +@import "../reset" html color #456267 diff --git a/test/api.js b/test/api.js index 1e731b5549..c0da9d6c5b 100644 --- a/test/api.js +++ b/test/api.js @@ -277,15 +277,15 @@ describe('API', () => { const me = await insertSakurako(); const post = { text: 'さく', - reply_to_id: himaPost._id.toString() + reply_id: himaPost._id.toString() }; const res = await request('/posts/create', post, me); res.should.have.status(200); res.body.should.be.a('object'); res.body.should.have.property('text').eql(post.text); - res.body.should.have.property('reply_to_id').eql(post.reply_to_id); - res.body.should.have.property('reply_to'); - res.body.reply_to.should.have.property('text').eql(himaPost.text); + res.body.should.have.property('reply_id').eql(post.reply_id); + res.body.should.have.property('reply'); + res.body.reply.should.have.property('text').eql(himaPost.text); })); it('repostできる', async(async () => { @@ -350,7 +350,7 @@ describe('API', () => { const me = await insertSakurako(); const post = { text: 'さく', - reply_to_id: '000000000000000000000000' + reply_id: '000000000000000000000000' }; const res = await request('/posts/create', post, me); res.should.have.status(400); @@ -369,7 +369,7 @@ describe('API', () => { const me = await insertSakurako(); const post = { text: 'さく', - reply_to_id: 'kyoppie' + reply_id: 'kyoppie' }; const res = await request('/posts/create', post, me); res.should.have.status(400); @@ -1152,9 +1152,12 @@ async function insertHimawari(opts) { } async function insertDriveFile(opts) { - return await db.get('drive_files').insert(Object.assign({ - name: 'strawberry-pasta.png' - }, opts)); + return await db.get('drive_files.files').insert({ + length: opts.datasize, + metadata: Object.assign({ + name: 'strawberry-pasta.png' + }, opts) + }); } async function insertDriveFolder(opts) { diff --git a/tools/migration/reply_to-to-reply.js b/tools/migration/reply_to-to-reply.js new file mode 100644 index 0000000000..ceb272ebc9 --- /dev/null +++ b/tools/migration/reply_to-to-reply.js @@ -0,0 +1,5 @@ +db.posts.update({}, { + $rename: { + reply_to_id: 'reply_id' + } +}, false, true); diff --git a/tools/migration/use-gridfs.js b/tools/migration/use-gridfs.js new file mode 100644 index 0000000000..d41514416c --- /dev/null +++ b/tools/migration/use-gridfs.js @@ -0,0 +1,49 @@ +// for Node.js interpret + +const { default: db } = require('../../built/db/mongodb') +const { default: DriveFile, getGridFSBucket } = require('../../built/api/models/drive-file') +const { Duplex } = require('stream') + +const writeToGridFS = (bucket, buffer, ...rest) => new Promise((resolve, reject) => { + const writeStream = bucket.openUploadStreamWithId(...rest) + + const dataStream = new Duplex() + dataStream.push(buffer) + dataStream.push(null) + + writeStream.once('finish', resolve) + writeStream.on('error', reject) + + dataStream.pipe(writeStream) +}) + +const migrateToGridFS = async (doc) => { + const id = doc._id + const buffer = doc.data.buffer + const created_at = doc.created_at + + delete doc._id + delete doc.created_at + delete doc.datasize + delete doc.hash + delete doc.data + + const bucket = await getGridFSBucket() + const added = await writeToGridFS(bucket, buffer, id, `${id}/${doc.name}`, { metadata: doc }) + + const result = await DriveFile.update(id, { + $set: { + uploadDate: created_at + } + }) + + return added && result.ok === 1 +} + +const main = async () => { + const docs = await db.get('drive_files').find() + const all = await Promise.all(docs.map(migrateToGridFS)) + return all +} + +main().then(console.dir).catch(console.error) diff --git a/webpack/plugins/const.ts b/webpack/plugins/const.ts index ccfcb45260..f64160b01a 100644 --- a/webpack/plugins/const.ts +++ b/webpack/plugins/const.ts @@ -7,7 +7,8 @@ import * as webpack from 'webpack'; import version from '../../src/version'; const constants = require('../../src/const.json'); -export default () => new webpack.DefinePlugin({ +export default lang => new webpack.DefinePlugin({ VERSION: JSON.stringify(version), + LANG: JSON.stringify(lang), THEME_COLOR: JSON.stringify(constants.themeColor) }); diff --git a/webpack/plugins/index.ts b/webpack/plugins/index.ts index d5191f1555..345af7df9e 100644 --- a/webpack/plugins/index.ts +++ b/webpack/plugins/index.ts @@ -8,9 +8,9 @@ import banner from './banner'; const env = process.env.NODE_ENV; const isProduction = env === 'production'; -export default version => { +export default (version, lang) => { const plugins = [ - constant(), + constant(lang), new StringReplacePlugin(), hoist() ]; diff --git a/webpack/webpack.config.ts b/webpack/webpack.config.ts index 5199285d55..97782a4102 100644 --- a/webpack/webpack.config.ts +++ b/webpack/webpack.config.ts @@ -16,6 +16,7 @@ module.exports = langs.map(([lang, locale]) => { const entry = { desktop: './src/web/app/desktop/script.js', mobile: './src/web/app/mobile/script.js', + ch: './src/web/app/ch/script.js', stats: './src/web/app/stats/script.js', status: './src/web/app/status/script.js', dev: './src/web/app/dev/script.js', @@ -31,7 +32,7 @@ module.exports = langs.map(([lang, locale]) => { name, entry, module: module_(lang, locale), - plugins: plugins(version), + plugins: plugins(version, lang), output }; });