diff options
| author | ha-dai <contact@haradai.net> | 2017-11-27 03:41:47 +0900 |
|---|---|---|
| committer | ha-dai <contact@haradai.net> | 2017-11-27 03:41:47 +0900 |
| commit | 6c75bc6d5188cbbf80fe1086fa0e8828f4edb873 (patch) | |
| tree | 3ffedcc3a06e53269e92d2990cf0b3bb247ac04a /src | |
| parent | Merge branch 'master' of https://github.com/syuilo/misskey (diff) | |
| parent | Update dependencies :rocket: (diff) | |
| download | misskey-6c75bc6d5188cbbf80fe1086fa0e8828f4edb873.tar.gz misskey-6c75bc6d5188cbbf80fe1086fa0e8828f4edb873.tar.bz2 misskey-6c75bc6d5188cbbf80fe1086fa0e8828f4edb873.zip | |
Merge branch 'master' of github.com:syuilo/misskey
Diffstat (limited to 'src')
244 files changed, 6508 insertions, 2456 deletions
diff --git a/src/api/bot/core.ts b/src/api/bot/core.ts index 53fb18119e..ddae6405f5 100644 --- a/src/api/bot/core.ts +++ b/src/api/bot/core.ts @@ -5,6 +5,7 @@ import User, { IUser, init as initUser } from '../models/user'; import getPostSummary from '../../common/get-post-summary'; import getUserSummary from '../../common/get-user-summary'; +import getNotificationSummary from '../../common/get-notification-summary'; import Othello, { ai as othelloAi } from '../../common/othello'; @@ -62,7 +63,7 @@ export default class BotCore extends EventEmitter { return bot; } - public async q(query: string): Promise<string | void> { + public async q(query: string): Promise<string> { if (this.context != null) { return await this.context.q(query); } @@ -84,7 +85,10 @@ export default class BotCore extends EventEmitter { 'logout, signout: サインアウトします\n' + 'post: 投稿します\n' + 'tl: タイムラインを見ます\n' + - '@<ユーザー名>: ユーザーを表示します'; + 'no: 通知を見ます\n' + + '@<ユーザー名>: ユーザーを表示します\n' + + '\n' + + 'タイムラインや通知を見た後、「次」というとさらに遡ることができます。'; case 'me': return this.user ? `${this.user.name}としてサインインしています。\n\n${getUserSummary(this.user)}` : 'サインインしていません'; @@ -113,7 +117,16 @@ export default class BotCore extends EventEmitter { case 'tl': case 'タイムライン': - return await this.tlCommand(); + if (this.user == null) return 'まずサインインしてください。'; + this.setContext(new TlContext(this)); + return await this.context.greet(); + + case 'no': + case 'notifications': + case '通知': + if (this.user == null) return 'まずサインインしてください。'; + this.setContext(new NotificationsContext(this)); + return await this.context.greet(); case 'guessing-game': case '数当てゲーム': @@ -155,21 +168,7 @@ export default class BotCore extends EventEmitter { this.emit('updated'); } - public async tlCommand(): Promise<string | void> { - if (this.user == null) return 'まずサインインしてください。'; - - const tl = await require('../endpoints/posts/timeline')({ - limit: 5 - }, this.user); - - const text = tl - .map(post => getPostSummary(post)) - .join('\n-----\n'); - - return text; - } - - public async showUserCommand(q: string): Promise<string | void> { + public async showUserCommand(q: string): Promise<string> { try { const user = await require('../endpoints/users/show')({ username: q.substr(1) @@ -200,6 +199,8 @@ abstract class Context extends EventEmitter { if (data.type == 'guessing-game') return GuessingGameContext.import(bot, data.content); if (data.type == 'othello') return OthelloContext.import(bot, data.content); if (data.type == 'post') return PostContext.import(bot, data.content); + if (data.type == 'tl') return TlContext.import(bot, data.content); + if (data.type == 'notifications') return NotificationsContext.import(bot, data.content); if (data.type == 'signin') return SigninContext.import(bot, data.content); return null; } @@ -232,7 +233,7 @@ class SigninContext extends Context { } } else { // Compare password - const same = bcrypt.compareSync(query, this.temporaryUser.password); + const same = await bcrypt.compare(query, this.temporaryUser.password); if (same) { this.bot.signin(this.temporaryUser); @@ -285,6 +286,110 @@ class PostContext extends Context { } } +class TlContext extends Context { + private next: string = null; + + public async greet(): Promise<string> { + return await this.getTl(); + } + + public async q(query: string): Promise<string> { + if (query == '次') { + return await this.getTl(); + } else { + this.bot.clearContext(); + return await this.bot.q(query); + } + } + + private async getTl() { + const tl = await require('../endpoints/posts/timeline')({ + limit: 5, + max_id: this.next ? this.next : undefined + }, this.bot.user); + + if (tl.length > 0) { + this.next = tl[tl.length - 1].id; + this.emit('updated'); + + const text = tl + .map(post => `${post.user.name}\n「${getPostSummary(post)}」`) + .join('\n-----\n'); + + return text; + } else { + return 'タイムラインに表示するものがありません...'; + } + } + + public export() { + return { + type: 'tl', + content: { + next: this.next, + } + }; + } + + public static import(bot: BotCore, data: any) { + const context = new TlContext(bot); + context.next = data.next; + return context; + } +} + +class NotificationsContext extends Context { + private next: string = null; + + public async greet(): Promise<string> { + return await this.getNotifications(); + } + + public async q(query: string): Promise<string> { + if (query == '次') { + return await this.getNotifications(); + } else { + this.bot.clearContext(); + return await this.bot.q(query); + } + } + + private async getNotifications() { + const notifications = await require('../endpoints/i/notifications')({ + limit: 5, + max_id: this.next ? this.next : undefined + }, this.bot.user); + + if (notifications.length > 0) { + this.next = notifications[notifications.length - 1].id; + this.emit('updated'); + + const text = notifications + .map(notification => getNotificationSummary(notification)) + .join('\n-----\n'); + + return text; + } else { + return '通知はありません'; + } + } + + public export() { + return { + type: 'notifications', + content: { + next: this.next, + } + }; + } + + public static import(bot: BotCore, data: any) { + const context = new NotificationsContext(bot); + context.next = data.next; + return context; + } +} + class GuessingGameContext extends Context { private secret: number; private history: number[] = []; diff --git a/src/api/bot/interfaces/line.ts b/src/api/bot/interfaces/line.ts index 0caa71ed2b..43c25f8032 100644 --- a/src/api/bot/interfaces/line.ts +++ b/src/api/bot/interfaces/line.ts @@ -135,6 +135,8 @@ class LineBot extends BotCore { actions: actions } }]); + + return null; } public async showUserTimelinePostback(userId: string) { diff --git a/src/api/common/add-file-to-drive.ts b/src/api/common/add-file-to-drive.ts index 714eeb520d..2a649788af 100644 --- a/src/api/common/add-file-to-drive.ts +++ b/src/api/common/add-file-to-drive.ts @@ -1,172 +1,264 @@ +import { Buffer } from 'buffer'; +import * as fs from 'fs'; +import * as tmp from 'tmp'; +import * as stream from 'stream'; + import * as mongodb from 'mongodb'; import * as crypto from 'crypto'; 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 event, { publishDriveStream } from '../event'; import config from '../../conf'; const log = debug('misskey:register-drive-file'); -/** - * Add file to drive - * - * @param user User who wish to add file - * @param fileName File name - * @param data Contents - * @param comment Comment - * @param type File type - * @param folderId Folder ID - * @param force If set to true, forcibly upload the file even if there is a file with the same hash. - * @return Object that represents added file - */ -export default ( +const tmpFile = (): Promise<string> => new Promise((resolve, reject) => { + tmp.file((e, path) => { + if (e) return reject(e); + resolve(path); + }); +}); + +const addToGridFS = (name: string, readable: stream.Readable, type: string, metadata: any): Promise<any> => + getGridFSBucket() + .then(bucket => new Promise((resolve, reject) => { + const writeStream = bucket.openUploadStream(name, { contentType: type, metadata }); + writeStream.once('finish', (doc) => { resolve(doc); }); + writeStream.on('error', reject); + readable.pipe(writeStream); + })); + +const addFile = async ( user: any, - data: Buffer, + path: string, name: string = null, comment: string = null, folderId: mongodb.ObjectID = null, force: boolean = false -) => new Promise<any>(async (resolve, reject) => { - log(`registering ${name} (user: ${user.username})`); - - // File size - const size = data.byteLength; +) => { + log(`registering ${name} (user: ${user.username}, path: ${path})`); - log(`size is ${size}`); - - // File type - let mime = 'application/octet-stream'; - const type = fileType(data); - if (type !== null) { - mime = type.mime; - - if (name === null) { - name = `untitled.${type.ext}`; - } - } else { - if (name === null) { - name = 'untitled'; - } - } - - log(`type is ${mime}`); + // Calculate hash, get content type and get file size + const [hash, [mime, ext], size] = await Promise.all([ + // hash + ((): Promise<string> => new Promise((res, rej) => { + const readable = fs.createReadStream(path); + const hash = crypto.createHash('md5'); + const chunks = []; + readable + .on('error', rej) + .pipe(hash) + .on('error', rej) + .on('data', (chunk) => chunks.push(chunk)) + .on('end', () => { + const buffer = Buffer.concat(chunks); + res(buffer.toString('hex')); + }); + }))(), + // mime + ((): Promise<[string, string | null]> => new Promise((res, rej) => { + const readable = fs.createReadStream(path); + readable + .on('error', rej) + .once('data', (buffer: Buffer) => { + readable.destroy(); + const type = fileType(buffer); + if (type) { + return res([type.mime, type.ext]); + } else { + // 種類が同定できなかったら application/octet-stream にする + return res(['application/octet-stream', null]); + } + }); + }))(), + // size + ((): Promise<number> => new Promise((res, rej) => { + fs.stat(path, (err, stats) => { + if (err) return rej(err); + res(stats.size); + }); + }))() + ]); - // Generate hash - const hash = crypto - .createHash('sha256') - .update(data) - .digest('hex') as string; + log(`hash: ${hash}, mime: ${mime}, ext: ${ext}, size: ${size}`); - log(`hash is ${hash}`); + // detect name + const detectedName: string = name || (ext ? `untitled.${ext}` : 'untitled'); 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) { log('file with same hash is found'); - return resolve(much); + return much; } else { log('file with same hash is not found'); } } - // Calculate drive usage - const usage = ((await DriveFile - .aggregate([ - { $match: { user_id: user._id } }, - { $project: { - datasize: true - }}, - { $group: { - _id: null, - usage: { $sum: '$datasize' } - }} - ]))[0] || { - usage: 0 - }).usage; + const [properties, folder] = await Promise.all([ + // properties + (async () => { + // 画像かどうか + if (!/^image\/.*$/.test(mime)) { + return null; + } - log(`drive usage is ${usage}`); + const imageType = mime.split('/')[1]; - // If usage limit exceeded - if (usage + size > user.drive_capacity) { - return reject('no-free-space'); - } + // 画像でもPNGかJPEGでないならスキップ + if (imageType != 'png' && imageType != 'jpeg') { + return null; + } + + // If the file is an image, calculate width and height to save in property + const g = gm(fs.createReadStream(path), name); + const size = await prominence(g).size(); + const properties = { + width: size.width, + height: size.height + }; - // If the folder is specified - let folder: any = null; - if (folderId !== null) { - folder = await DriveFolder - .findOne({ + log('image width and height is calculated'); + + return properties; + })(), + // folder + (async () => { + if (!folderId) { + return null; + } + const driveFolder = await DriveFolder.findOne({ _id: folderId, user_id: user._id }); + if (!driveFolder) { + throw 'folder-not-found'; + } + return driveFolder; + })(), + // usage checker + (async () => { + // Calculate drive usage + const usage = await DriveFile + .aggregate([{ + $match: { 'metadata.user_id': user._id } + }, { + $project: { + length: true + } + }, { + $group: { + _id: null, + usage: { $sum: '$length' } + } + }]) + .then((aggregates: any[]) => { + if (aggregates.length > 0) { + return aggregates[0].usage; + } + return 0; + }); - if (folder === null) { - return reject('folder-not-found'); - } - } - - let properties: any = null; + log(`drive usage is ${usage}`); - // If the file is an image - if (/^image\/.*$/.test(mime)) { - // Calculate width and height to save in property - const g = gm(data, name); - const size = await prominence(g).size(); - properties = { - width: size.width, - height: size.height - }; + // If usage limit exceeded + if (usage + size > user.drive_capacity) { + throw 'no-free-space'; + } + })() + ]); - log('image width and height is calculated'); - } + const readable = fs.createReadStream(path); - // Create DriveFile document - const file = await DriveFile.insert({ - created_at: new Date(), + return addToGridFS(detectedName, readable, mime, { 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); - - // Serialize - const fileObj = await serialize(file); +/** + * Add file to drive + * + * @param user User who wish to add file + * @param file File path or readableStream + * @param comment Comment + * @param type File type + * @param folderId Folder ID + * @param force If set to true, forcibly upload the file even if there is a file with the same hash. + * @return Object that represents added file + */ +export default (user: any, file: string | stream.Readable, ...args) => new Promise<any>((resolve, reject) => { + // Get file path + new Promise((res: (v: [string, boolean]) => void, rej) => { + if (typeof file === 'string') { + res([file, false]); + return; + } + if (typeof file === 'object' && typeof file.read === 'function') { + tmpFile() + .then(path => { + const readable: stream.Readable = file; + const writable = fs.createWriteStream(path); + readable + .on('error', rej) + .on('end', () => { + res([path, true]); + }) + .pipe(writable) + .on('error', rej); + }) + .catch(rej); + } + rej(new Error('un-compatible file.')); + }) + .then(([path, remove]): Promise<any> => new Promise((res, rej) => { + addFile(user, path, ...args) + .then(file => { + res(file); + if (remove) { + fs.unlink(path, (e) => { + if (e) log(e.stack); + }); + } + }) + .catch(rej); + })) + .then(file => { + log(`drive file has been created ${file._id}`); + resolve(file); - // Publish drive_file_created event - event(user._id, 'drive_file_created', fileObj); + serialize(file).then(serializedFile => { + // Publish drive_file_created event + event(user._id, 'drive_file_created', serializedFile); + publishDriveStream(user._id, 'file_created', serializedFile); - // Register to search database - if (config.elasticsearch.enable) { - const es = require('../../db/elasticsearch'); - es.index({ - index: 'misskey', - type: 'drive_file', - id: file._id.toString(), - body: { - name: file.name, - user_id: user._id.toString() + // Register to search database + if (config.elasticsearch.enable) { + const es = require('../../db/elasticsearch'); + es.index({ + index: 'misskey', + type: 'drive_file', + id: file._id.toString(), + body: { + name: file.name, + user_id: user._id.toString() + } + }); } }); - } + }) + .catch(reject); }); diff --git a/src/api/common/notify.ts b/src/api/common/notify.ts index e7ec37d4e4..4b3e6a5d54 100644 --- a/src/api/common/notify.ts +++ b/src/api/common/notify.ts @@ -27,4 +27,12 @@ export default ( // Publish notification event event(notifiee, 'notification', await serialize(notification)); + + // 3秒経っても(今回作成した)通知が既読にならなかったら「未読の通知がありますよ」イベントを発行する + setTimeout(async () => { + const fresh = await Notification.findOne({ _id: notification._id }, { is_read: true }); + if (!fresh.is_read) { + event(notifiee, 'unread_notification', await serialize(notification)); + } + }, 3000); }); diff --git a/src/api/common/push-sw.ts b/src/api/common/push-sw.ts new file mode 100644 index 0000000000..2993c760ee --- /dev/null +++ b/src/api/common/push-sw.ts @@ -0,0 +1,52 @@ +const push = require('web-push'); +import * as mongo from 'mongodb'; +import Subscription from '../models/sw-subscription'; +import config from '../../conf'; + +if (config.sw) { + // アプリケーションの連絡先と、サーバーサイドの鍵ペアの情報を登録 + push.setVapidDetails( + config.maintainer.url, + config.sw.public_key, + config.sw.private_key); +} + +export default async function(userId: mongo.ObjectID | string, type, body?) { + if (!config.sw) return; + + if (typeof userId === 'string') { + userId = new mongo.ObjectID(userId); + } + + // Fetch + const subscriptions = await Subscription.find({ + user_id: userId + }); + + subscriptions.forEach(subscription => { + const pushSubscription = { + endpoint: subscription.endpoint, + keys: { + auth: subscription.auth, + p256dh: subscription.publickey + } + }; + + push.sendNotification(pushSubscription, JSON.stringify({ + type, body + })).catch(err => { + //console.log(err.statusCode); + //console.log(err.headers); + //console.log(err.body); + + if (err.statusCode == 410) { + Subscription.remove({ + user_id: userId, + endpoint: subscription.endpoint, + auth: subscription.auth, + publickey: subscription.publickey + }); + } + }); + }); +} diff --git a/src/api/common/read-messaging-message.ts b/src/api/common/read-messaging-message.ts index 3257ec8b07..8e5e5b2b68 100644 --- a/src/api/common/read-messaging-message.ts +++ b/src/api/common/read-messaging-message.ts @@ -3,6 +3,7 @@ import Message from '../models/messaging-message'; import { IMessagingMessage as IMessage } from '../models/messaging-message'; import publishUserStream from '../event'; import { publishMessagingStream } from '../event'; +import { publishMessagingIndexStream } from '../event'; /** * Mark as read message(s) @@ -49,6 +50,7 @@ export default ( // Publish event publishMessagingStream(otherpartyId, userId, 'read', ids.map(id => id.toString())); + publishMessagingIndexStream(userId, 'read', ids.map(id => id.toString())); // Calc count of my unread messages const count = await Message diff --git a/src/api/common/signin.ts b/src/api/common/signin.ts new file mode 100644 index 0000000000..693e62f39f --- /dev/null +++ b/src/api/common/signin.ts @@ -0,0 +1,19 @@ +import config from '../../conf'; + +export default function(res, user, redirect: boolean) { + const expires = 1000 * 60 * 60 * 24 * 365; // One Year + res.cookie('i', user.token, { + path: '/', + domain: `.${config.host}`, + secure: config.url.substr(0, 5) === 'https', + httpOnly: false, + expires: new Date(Date.now() + expires), + maxAge: expires + }); + + if (redirect) { + res.redirect(config.url); + } else { + res.sendStatus(204); + } +} diff --git a/src/api/endpoints.ts b/src/api/endpoints.ts index afefce39e5..06fb9a64ae 100644 --- a/src/api/endpoints.ts +++ b/src/api/endpoints.ts @@ -147,6 +147,11 @@ const endpoints: Endpoint[] = [ }, { + name: 'sw/register', + withCredential: true + }, + + { name: 'i', withCredential: true }, @@ -160,6 +165,11 @@ const endpoints: Endpoint[] = [ kind: 'account-write' }, { + name: 'i/update_home', + withCredential: true, + kind: 'account-write' + }, + { name: 'i/change_password', withCredential: true }, diff --git a/src/api/endpoints/app/create.ts b/src/api/endpoints/app/create.ts index 498c4f144e..ca684de02d 100644 --- a/src/api/endpoints/app/create.ts +++ b/src/api/endpoints/app/create.ts @@ -85,7 +85,7 @@ module.exports = async (params, user) => new Promise(async (res, rej) => { if (permissionErr) return rej('invalid permission param'); // Get 'callback_url' parameter - // TODO: Check $ is valid url + // TODO: Check it is valid url const [callbackUrl = null, callbackUrlErr] = $(params.callback_url).optional.nullable.string().$; if (callbackUrlErr) return rej('invalid callback_url param'); diff --git a/src/api/endpoints/channels/posts.ts b/src/api/endpoints/channels/posts.ts index fa91fb93ee..5c071a124f 100644 --- a/src/api/endpoints/channels/posts.ts +++ b/src/api/endpoints/channels/posts.ts @@ -3,7 +3,7 @@ */ import $ from 'cafy'; import { default as Channel, IChannel } from '../../models/channel'; -import { default as Post, IPost } from '../../models/post'; +import Post from '../../models/post'; import serialize from '../../serializers/post'; /** 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..b2e094775c 100644 --- a/src/api/endpoints/drive/files.ts +++ b/src/api/endpoints/drive/files.ts @@ -13,35 +13,39 @@ 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'; + + // Get 'type' parameter + const [type, typeErr] = $(params.type).optional.string().match(/^[a-zA-Z\/\-\*]+$/).$; + if (typeErr) throw 'invalid type 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; @@ -53,18 +57,18 @@ module.exports = (params, user, app) => new Promise(async (res, rej) => { $lt: maxId }; } + if (type) { + query.contentType = new RegExp(`^${type.replace(/\*/g, '.+?')}$`); + } // 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/create.ts b/src/api/endpoints/drive/files/create.ts index 43dca7762a..7546eca309 100644 --- a/src/api/endpoints/drive/files/create.ts +++ b/src/api/endpoints/drive/files/create.ts @@ -1,7 +1,6 @@ /** * Module dependencies */ -import * as fs from 'fs'; import $ from 'cafy'; import { validateFileName } from '../../../models/drive-file'; import serialize from '../../../serializers/drive-file'; @@ -15,14 +14,11 @@ import create from '../../../common/add-file-to-drive'; * @param {any} user * @return {Promise<any>} */ -module.exports = (file, params, user) => new Promise(async (res, rej) => { +module.exports = async (file, params, user): Promise<any> => { if (file == null) { - return rej('file is required'); + throw 'file is required'; } - const buffer = fs.readFileSync(file.path); - fs.unlink(file.path, (err) => { if (err) console.log(err); }); - // Get 'name' parameter let name = file.originalname; if (name !== undefined && name !== null) { @@ -32,7 +28,7 @@ module.exports = (file, params, user) => new Promise(async (res, rej) => { } else if (name === 'blob') { name = null; } else if (!validateFileName(name)) { - return rej('invalid name'); + throw 'invalid name'; } } else { name = null; @@ -40,14 +36,11 @@ module.exports = (file, params, user) => new Promise(async (res, rej) => { // 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'; // Create file - const driveFile = await create(user, buffer, name, null, folderId); + const driveFile = await create(user, file.path, name, null, folderId); // Serialize - const fileObj = await serialize(driveFile); - - // Response - res(fileObj); -}); + return serialize(driveFile); +}; diff --git a/src/api/endpoints/drive/files/find.ts b/src/api/endpoints/drive/files/find.ts index cd0b33f2ca..a1cdf1643e 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 - } + filename: 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..f39a420d6e 100644 --- a/src/api/endpoints/drive/files/update.ts +++ b/src/api/endpoints/drive/files/update.ts @@ -6,7 +6,7 @@ import DriveFolder from '../../../models/drive-folder'; import DriveFile from '../../../models/drive-file'; import { validateFileName } from '../../../models/drive-file'; import serialize from '../../../serializers/drive-file'; -import event from '../../../event'; +import { publishDriveStream } from '../../../event'; /** * Update a file @@ -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.filename = 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 + filename: file.filename, + 'metadata.folder_id': file.metadata.folder_id } }); @@ -76,6 +72,6 @@ module.exports = (params, user) => new Promise(async (res, rej) => { // Response res(fileObj); - // Publish drive_file_updated event - event(user._id, 'drive_file_updated', fileObj); + // Publish file_updated event + publishDriveStream(user._id, 'file_updated', fileObj); }); diff --git a/src/api/endpoints/drive/files/upload_from_url.ts b/src/api/endpoints/drive/files/upload_from_url.ts index 46cfffb69c..519e0bdf65 100644 --- a/src/api/endpoints/drive/files/upload_from_url.ts +++ b/src/api/endpoints/drive/files/upload_from_url.ts @@ -2,11 +2,16 @@ * Module dependencies */ import * as URL from 'url'; -const download = require('download'); import $ from 'cafy'; import { validateFileName } from '../../../models/drive-file'; import serialize from '../../../serializers/drive-file'; import create from '../../../common/add-file-to-drive'; +import * as debug from 'debug'; +import * as tmp from 'tmp'; +import * as fs from 'fs'; +import * as request from 'request'; + +const log = debug('misskey:endpoint:upload_from_url'); /** * Create a file from a URL @@ -15,11 +20,11 @@ import create from '../../../common/add-file-to-drive'; * @param {any} user * @return {Promise<any>} */ -module.exports = (params, user) => new Promise(async (res, rej) => { +module.exports = async (params, user): Promise<any> => { // Get 'url' parameter // TODO: Validate this url const [url, urlErr] = $(params.url).string().$; - if (urlErr) return rej('invalid url param'); + if (urlErr) throw 'invalid url param'; let name = URL.parse(url).pathname.split('/').pop(); if (!validateFileName(name)) { @@ -28,17 +33,35 @@ module.exports = (params, user) => new Promise(async (res, rej) => { // 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'; + + // Create temp file + const path = await new Promise((res: (string) => void, rej) => { + tmp.file((e, path) => { + if (e) return rej(e); + res(path); + }); + }); - // Download file - const data = await download(url); + // write content at URL to temp file + await new Promise((res, rej) => { + const writable = fs.createWriteStream(path); + request(url) + .on('error', rej) + .on('end', () => { + writable.close(); + res(path); + }) + .pipe(writable) + .on('error', rej); + }); - // Create file - const driveFile = await create(user, data, name, null, folderId); + const driveFile = await create(user, path, name, null, folderId); - // Serialize - const fileObj = await serialize(driveFile); + // clean-up + fs.unlink(path, (e) => { + if (e) log(e.stack); + }); - // Response - res(fileObj); -}); + return serialize(driveFile); +}; diff --git a/src/api/endpoints/drive/folders/create.ts b/src/api/endpoints/drive/folders/create.ts index 8c875db164..be847b2153 100644 --- a/src/api/endpoints/drive/folders/create.ts +++ b/src/api/endpoints/drive/folders/create.ts @@ -5,7 +5,7 @@ import $ from 'cafy'; import DriveFolder from '../../../models/drive-folder'; import { isValidFolderName } from '../../../models/drive-folder'; import serialize from '../../../serializers/drive-folder'; -import event from '../../../event'; +import { publishDriveStream } from '../../../event'; /** * Create drive folder @@ -52,6 +52,6 @@ module.exports = (params, user) => new Promise(async (res, rej) => { // Response res(folderObj); - // Publish drive_folder_created event - event(user._id, 'drive_folder_created', folderObj); + // Publish folder_created event + publishDriveStream(user._id, 'folder_created', folderObj); }); 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..ff673402ab 100644 --- a/src/api/endpoints/drive/folders/update.ts +++ b/src/api/endpoints/drive/folders/update.ts @@ -4,8 +4,8 @@ import $ from 'cafy'; import DriveFolder from '../../../models/drive-folder'; import { isValidFolderName } from '../../../models/drive-folder'; -import serialize from '../../../serializers/drive-file'; -import event from '../../../event'; +import serialize from '../../../serializers/drive-folder'; +import { publishDriveStream } from '../../../event'; /** * Update a folder @@ -96,6 +96,6 @@ module.exports = (params, user) => new Promise(async (res, rej) => { // Response res(folderObj); - // Publish drive_folder_updated event - event(user._id, 'drive_folder_updated', folderObj); + // Publish folder_updated event + publishDriveStream(user._id, 'folder_updated', folderObj); }); diff --git a/src/api/endpoints/drive/stream.ts b/src/api/endpoints/drive/stream.ts index 32f7ac7e0a..7ee255e5d1 100644 --- a/src/api/endpoints/drive/stream.ts +++ b/src/api/endpoints/drive/stream.ts @@ -39,7 +39,7 @@ module.exports = (params, user) => new Promise(async (res, rej) => { _id: -1 }; const query = { - user_id: user._id + 'metadata.user_id': user._id } as any; if (sinceId) { sort._id = 1; @@ -52,15 +52,12 @@ module.exports = (params, user) => new Promise(async (res, rej) => { }; } if (type) { - query.type = new RegExp(`^${type.replace(/\*/g, '.+?')}$`); + query.contentType = new RegExp(`^${type.replace(/\*/g, '.+?')}$`); } // Issue query const files = await DriveFile .find(query, { - fields: { - data: false - }, limit: limit, sort: sort }); diff --git a/src/api/endpoints/i/appdata/get.ts b/src/api/endpoints/i/appdata/get.ts index a1a57fa13a..571208d46c 100644 --- a/src/api/endpoints/i/appdata/get.ts +++ b/src/api/endpoints/i/appdata/get.ts @@ -13,38 +13,27 @@ import Appdata from '../../../models/appdata'; * @param {Boolean} isSecure * @return {Promise<any>} */ -module.exports = (params, user, app, isSecure) => new Promise(async (res, rej) => { +module.exports = (params, user, app) => new Promise(async (res, rej) => { + if (app == null) return rej('このAPIはサードパーティAppからのみ利用できます'); + // Get 'key' parameter const [key = null, keyError] = $(params.key).optional.nullable.string().match(/[a-z_]+/).$; if (keyError) return rej('invalid key param'); - if (isSecure) { - if (!user.data) { - return res(); - } - if (key !== null) { - const data = {}; - data[key] = user.data[key]; - res(data); - } else { - res(user.data); - } - } else { - const select = {}; - if (key !== null) { - select[`data.${key}`] = true; - } - const appdata = await Appdata.findOne({ - app_id: app._id, - user_id: user._id - }, { - fields: select - }); + const select = {}; + if (key !== null) { + select[`data.${key}`] = true; + } + const appdata = await Appdata.findOne({ + app_id: app._id, + user_id: user._id + }, { + fields: select + }); - if (appdata) { - res(appdata.data); - } else { - res(); - } + if (appdata) { + res(appdata.data); + } else { + res(); } }); diff --git a/src/api/endpoints/i/appdata/set.ts b/src/api/endpoints/i/appdata/set.ts index 9c3dbe185b..2804a14cb3 100644 --- a/src/api/endpoints/i/appdata/set.ts +++ b/src/api/endpoints/i/appdata/set.ts @@ -3,9 +3,6 @@ */ import $ from 'cafy'; import Appdata from '../../../models/appdata'; -import User from '../../../models/user'; -import serialize from '../../../serializers/user'; -import event from '../../../event'; /** * Set app data @@ -16,7 +13,9 @@ import event from '../../../event'; * @param {Boolean} isSecure * @return {Promise<any>} */ -module.exports = (params, user, app, isSecure) => new Promise(async (res, rej) => { +module.exports = (params, user, app) => new Promise(async (res, rej) => { + if (app == null) return rej('このAPIはサードパーティAppからのみ利用できます'); + // Get 'data' parameter const [data, dataError] = $(params.data).optional.object() .pipe(obj => { @@ -43,31 +42,17 @@ module.exports = (params, user, app, isSecure) => new Promise(async (res, rej) = set[`data.${key}`] = value; } - if (isSecure) { - const _user = await User.findOneAndUpdate(user._id, { + await Appdata.update({ + app_id: app._id, + user_id: user._id + }, Object.assign({ + app_id: app._id, + user_id: user._id + }, { $set: set + }), { + upsert: true }); - res(204); - - // Publish i updated event - event(user._id, 'i_updated', await serialize(_user, user, { - detail: true, - includeSecrets: true - })); - } else { - await Appdata.update({ - app_id: app._id, - user_id: user._id - }, Object.assign({ - app_id: app._id, - user_id: user._id - }, { - $set: set - }), { - upsert: true - }); - - res(204); - } + res(204); }); diff --git a/src/api/endpoints/i/change_password.ts b/src/api/endpoints/i/change_password.ts index faceded29d..16f1a2e4ec 100644 --- a/src/api/endpoints/i/change_password.ts +++ b/src/api/endpoints/i/change_password.ts @@ -22,15 +22,15 @@ module.exports = async (params, user) => new Promise(async (res, rej) => { if (newPasswordErr) return rej('invalid new_password param'); // Compare password - const same = bcrypt.compareSync(currentPassword, user.password); + const same = await bcrypt.compare(currentPassword, user.password); if (!same) { return rej('incorrect password'); } // Generate hash of password - const salt = bcrypt.genSaltSync(8); - const hash = bcrypt.hashSync(newPassword, salt); + const salt = await bcrypt.genSalt(8); + const hash = await bcrypt.hash(newPassword, salt); await User.update(user._id, { $set: { diff --git a/src/api/endpoints/i/regenerate_token.ts b/src/api/endpoints/i/regenerate_token.ts index f96d10ebfc..653468330f 100644 --- a/src/api/endpoints/i/regenerate_token.ts +++ b/src/api/endpoints/i/regenerate_token.ts @@ -20,7 +20,7 @@ module.exports = async (params, user) => new Promise(async (res, rej) => { if (passwordErr) return rej('invalid password param'); // Compare password - const same = bcrypt.compareSync(password, user.password); + const same = await bcrypt.compare(password, user.password); if (!same) { return rej('incorrect password'); diff --git a/src/api/endpoints/i/update.ts b/src/api/endpoints/i/update.ts index 111a4b1909..c484c51a96 100644 --- a/src/api/endpoints/i/update.ts +++ b/src/api/endpoints/i/update.ts @@ -48,13 +48,19 @@ module.exports = async (params, user, _, isSecure) => new Promise(async (res, re if (bannerIdErr) return rej('invalid banner_id param'); if (bannerId) user.banner_id = bannerId; + // Get 'show_donation' parameter + const [showDonation, showDonationErr] = $(params.show_donation).optional.boolean().$; + if (showDonationErr) return rej('invalid show_donation param'); + if (showDonation) user.client_settings.show_donation = showDonation; + await User.update(user._id, { $set: { name: user.name, description: user.description, avatar_id: user.avatar_id, banner_id: user.banner_id, - profile: user.profile + profile: user.profile, + 'client_settings.show_donation': user.client_settings.show_donation } }); diff --git a/src/api/endpoints/i/update_home.ts b/src/api/endpoints/i/update_home.ts new file mode 100644 index 0000000000..429e88529a --- /dev/null +++ b/src/api/endpoints/i/update_home.ts @@ -0,0 +1,60 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import User from '../../models/user'; + +/** + * Update myself + * + * @param {any} params + * @param {any} user + * @param {any} _ + * @param {boolean} isSecure + * @return {Promise<any>} + */ +module.exports = async (params, user, _, isSecure) => new Promise(async (res, rej) => { + // Get 'home' parameter + const [home, homeErr] = $(params.home).optional.array().each( + $().strict.object() + .have('name', $().string()) + .have('id', $().string()) + .have('place', $().string()) + .have('data', $().object())).$; + if (homeErr) return rej('invalid home param'); + + // Get 'id' parameter + const [id, idErr] = $(params.id).optional.string().$; + if (idErr) return rej('invalid id param'); + + // Get 'data' parameter + const [data, dataErr] = $(params.data).optional.object().$; + if (dataErr) return rej('invalid data param'); + + if (home) { + await User.update(user._id, { + $set: { + 'client_settings.home': home + } + }); + + res(); + } else { + if (id == null && data == null) return rej('you need to set id and data params if home param unset'); + + const _home = user.client_settings.home; + const widget = _home.find(w => w.id == id); + + if (widget == null) return rej('widget not found'); + + widget.data = data; + + await User.update(user._id, { + $set: { + 'client_settings.home': _home + } + }); + + res(); + } +}); diff --git a/src/api/endpoints/messaging/messages/create.ts b/src/api/endpoints/messaging/messages/create.ts index 8af55d850c..3c7689f967 100644 --- a/src/api/endpoints/messaging/messages/create.ts +++ b/src/api/endpoints/messaging/messages/create.ts @@ -9,7 +9,7 @@ import User from '../../../models/user'; import DriveFile from '../../../models/drive-file'; import serialize from '../../../serializers/messaging-message'; import publishUserStream from '../../../event'; -import { publishMessagingStream } from '../../../event'; +import { publishMessagingStream, publishMessagingIndexStream, pushSw } from '../../../event'; import config from '../../../../conf'; /** @@ -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) { @@ -87,10 +85,12 @@ module.exports = (params, user) => new Promise(async (res, rej) => { // 自分のストリーム publishMessagingStream(message.user_id, message.recipient_id, 'message', messageObj); + publishMessagingIndexStream(message.user_id, 'message', messageObj); publishUserStream(message.user_id, 'messaging_message', messageObj); // 相手のストリーム publishMessagingStream(message.recipient_id, message.user_id, 'message', messageObj); + publishMessagingIndexStream(message.recipient_id, 'message', messageObj); publishUserStream(message.recipient_id, 'messaging_message', messageObj); // 3秒経っても(今回作成した)メッセージが既読にならなかったら「未読のメッセージがありますよ」イベントを発行する @@ -98,6 +98,7 @@ module.exports = (params, user) => new Promise(async (res, rej) => { const freshMessage = await Message.findOne({ _id: message._id }, { is_read: true }); if (!freshMessage.is_read) { publishUserStream(message.recipient_id, 'unread_messaging_message', messageObj); + pushSw(message.recipient_id, 'unread_messaging_message', messageObj); } }, 3000); diff --git a/src/api/endpoints/meta.ts b/src/api/endpoints/meta.ts index a3f1d50329..e27ca39e7e 100644 --- a/src/api/endpoints/meta.ts +++ b/src/api/endpoints/meta.ts @@ -4,6 +4,7 @@ import * as os from 'os'; import version from '../../version'; import config from '../../conf'; +import Meta from '../models/meta'; /** * @swagger @@ -39,6 +40,8 @@ import config from '../../conf'; * @return {Promise<any>} */ module.exports = (params) => new Promise(async (res, rej) => { + const meta = (await Meta.findOne()) || {}; + res({ maintainer: config.maintainer, version: version, @@ -49,6 +52,8 @@ module.exports = (params) => new Promise(async (res, rej) => { cpu: { model: os.cpus()[0].model, cores: os.cpus().length - } + }, + top_image: meta.top_image, + broadcasts: meta.broadcasts }); }); diff --git a/src/api/endpoints/posts/create.ts b/src/api/endpoints/posts/create.ts index f982b9ee93..ae4959dae4 100644 --- a/src/api/endpoints/posts/create.ts +++ b/src/api/endpoints/posts/create.ts @@ -14,7 +14,7 @@ import ChannelWatching from '../../models/channel-watching'; import serialize from '../../serializers/post'; import notify from '../../common/notify'; import watch from '../../common/watch-post'; -import { default as event, publishChannelStream } from '../../event'; +import event, { pushSw, publishChannelStream } from '../../event'; import config from '../../../conf'; /** @@ -44,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) { @@ -236,7 +234,7 @@ module.exports = (params, user: IUser, app) => new Promise(async (res, rej) => { const mentions = []; - function addMention(mentionee, type) { + function addMention(mentionee, reason) { // Reject if already added if (mentions.some(x => x.equals(mentionee))) return; @@ -245,7 +243,8 @@ module.exports = (params, user: IUser, app) => new Promise(async (res, rej) => { // Publish event if (!user._id.equals(mentionee)) { - event(mentionee, type, postObj); + event(mentionee, reason, postObj); + pushSw(mentionee, reason, postObj); } } diff --git a/src/api/endpoints/posts/reactions/create.ts b/src/api/endpoints/posts/reactions/create.ts index eecb928123..d537463dfe 100644 --- a/src/api/endpoints/posts/reactions/create.ts +++ b/src/api/endpoints/posts/reactions/create.ts @@ -7,7 +7,9 @@ import Post from '../../../models/post'; import Watching from '../../../models/post-watching'; import notify from '../../../common/notify'; import watch from '../../../common/watch-post'; -import { publishPostStream } from '../../../event'; +import { publishPostStream, pushSw } from '../../../event'; +import serializePost from '../../../serializers/post'; +import serializeUser from '../../../serializers/user'; /** * React to a post @@ -87,6 +89,12 @@ module.exports = (params, user) => new Promise(async (res, rej) => { reaction: reaction }); + pushSw(post.user_id, 'reaction', { + user: await serializeUser(user, post.user_id), + post: await serializePost(post, post.user_id), + reaction: reaction + }); + // Fetch watchers Watching .find({ diff --git a/src/api/endpoints/posts/timeline.ts b/src/api/endpoints/posts/timeline.ts index aa5aff5ba5..0d08b95463 100644 --- a/src/api/endpoints/posts/timeline.ts +++ b/src/api/endpoints/posts/timeline.ts @@ -2,6 +2,7 @@ * 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'; @@ -15,32 +16,41 @@ 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'); - } + // Get 'since_date' parameter + const [sinceDate, sinceDateErr] = $(params.since_date).optional.number().$; + if (sinceDateErr) throw 'invalid since_date param'; + + // Get 'max_date' parameter + const [maxDate, maxDateErr] = $(params.max_date).optional.number().$; + if (maxDateErr) throw 'invalid max_date param'; - // ID list of the user itself and other users who the user follows - const followingIds = await getFriends(user._id); + // Check if only one of since_id, max_id, since_date, max_date specified + if ([sinceId, maxId, sinceDate, maxDate].filter(x => x != null).length > 1) { + throw 'only one of since_id, max_id, since_date, max_date can be specified'; + } - // Watchしているチャンネルを取得 - const watches = await ChannelWatching.find({ - user_id: user._id, - // 削除されたドキュメントは除く - deleted_at: { $exists: false } + const { followingIds, watchingChannelIds } = await rap({ + // ID list of the user itself and other users who the user follows + followingIds: getFriends(user._id), + // Watchしているチャンネルを取得 + watchingChannelIds: ChannelWatching.find({ + user_id: user._id, + // 削除されたドキュメントは除く + deleted_at: { $exists: false } + }).then(watches => watches.map(w => w.channel_id)) }); //#region Construct query @@ -65,7 +75,7 @@ module.exports = (params, user, app) => new Promise(async (res, rej) => { }, { // Watchしているチャンネルへの投稿 channel_id: { - $in: watches.map(w => w.channel_id) + $in: watchingChannelIds } }] } as any; @@ -79,6 +89,15 @@ module.exports = (params, user, app) => new Promise(async (res, rej) => { query._id = { $lt: maxId }; + } else if (sinceDate) { + sort._id = 1; + query.created_at = { + $gt: new Date(sinceDate) + }; + } else if (maxDate) { + query.created_at = { + $lt: new Date(maxDate) + }; } //#endregion @@ -90,7 +109,5 @@ module.exports = (params, user, app) => new Promise(async (res, rej) => { }); // Serialize - res(await Promise.all(timeline.map(async post => - await serialize(post, user) - ))); -}); + return await Promise.all(timeline.map(post => serialize(post, user))); +}; diff --git a/src/api/endpoints/sw/register.ts b/src/api/endpoints/sw/register.ts new file mode 100644 index 0000000000..99406138db --- /dev/null +++ b/src/api/endpoints/sw/register.ts @@ -0,0 +1,50 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import Subscription from '../../models/sw-subscription'; + +/** + * subscribe service worker + * + * @param {any} params + * @param {any} user + * @param {any} _ + * @param {boolean} isSecure + * @return {Promise<any>} + */ +module.exports = async (params, user, _, isSecure) => new Promise(async (res, rej) => { + // Get 'endpoint' parameter + const [endpoint, endpointErr] = $(params.endpoint).string().$; + if (endpointErr) return rej('invalid endpoint param'); + + // Get 'auth' parameter + const [auth, authErr] = $(params.auth).string().$; + if (authErr) return rej('invalid auth param'); + + // Get 'publickey' parameter + const [publickey, publickeyErr] = $(params.publickey).string().$; + if (publickeyErr) return rej('invalid publickey param'); + + // if already subscribed + const exist = await Subscription.findOne({ + user_id: user._id, + endpoint: endpoint, + auth: auth, + publickey: publickey, + deleted_at: { $exists: false } + }); + + if (exist !== null) { + return res(); + } + + await Subscription.insert({ + user_id: user._id, + endpoint: endpoint, + auth: auth, + publickey: publickey + }); + + res(); +}); diff --git a/src/api/endpoints/users/get_frequently_replied_users.ts b/src/api/endpoints/users/get_frequently_replied_users.ts index bb0f3b4cea..a8add623d4 100644 --- a/src/api/endpoints/users/get_frequently_replied_users.ts +++ b/src/api/endpoints/users/get_frequently_replied_users.ts @@ -11,6 +11,10 @@ module.exports = (params, me) => new Promise(async (res, rej) => { const [userId, userIdErr] = $(params.user_id).id().$; if (userIdErr) return rej('invalid user_id param'); + // Get 'limit' parameter + const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$; + if (limitErr) return rej('invalid limit param'); + // Lookup user const user = await User.findOne({ _id: userId @@ -82,8 +86,8 @@ module.exports = (params, me) => new Promise(async (res, rej) => { // Sort replies by frequency const repliedUsersSorted = Object.keys(repliedUsers).sort((a, b) => repliedUsers[b] - repliedUsers[a]); - // Lookup top 10 replies - const topRepliedUsers = repliedUsersSorted.slice(0, 10); + // Extract top replied users + const topRepliedUsers = repliedUsersSorted.slice(0, limit); // Make replies object (includes weights) const repliesObj = await Promise.all(topRepliedUsers.map(async (user) => ({ diff --git a/src/api/endpoints/users/posts.ts b/src/api/endpoints/users/posts.ts index d8204b8b80..fe821cf17a 100644 --- a/src/api/endpoints/users/posts.ts +++ b/src/api/endpoints/users/posts.ts @@ -46,9 +46,17 @@ module.exports = (params, me) => new Promise(async (res, rej) => { 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 'since_date' parameter + const [sinceDate, sinceDateErr] = $(params.since_date).optional.number().$; + if (sinceDateErr) throw 'invalid since_date param'; + + // Get 'max_date' parameter + const [maxDate, maxDateErr] = $(params.max_date).optional.number().$; + if (maxDateErr) throw 'invalid max_date param'; + + // Check if only one of since_id, max_id, since_date, max_date specified + if ([sinceId, maxId, sinceDate, maxDate].filter(x => x != null).length > 1) { + throw 'only one of since_id, max_id, since_date, max_date can be specified'; } const q = userId !== undefined @@ -66,13 +74,15 @@ module.exports = (params, me) => new Promise(async (res, rej) => { return rej('user not found'); } - // Construct query + //#region Construct query const sort = { _id: -1 }; + const query = { user_id: user._id } as any; + if (sinceId) { sort._id = 1; query._id = { @@ -82,6 +92,15 @@ module.exports = (params, me) => new Promise(async (res, rej) => { query._id = { $lt: maxId }; + } else if (sinceDate) { + sort._id = 1; + query.created_at = { + $gt: new Date(sinceDate) + }; + } else if (maxDate) { + query.created_at = { + $lt: new Date(maxDate) + }; } if (!includeReplies) { @@ -94,6 +113,7 @@ module.exports = (params, me) => new Promise(async (res, rej) => { $ne: null }; } + //#endregion // Issue query const posts = await Post diff --git a/src/api/event.ts b/src/api/event.ts index 909b0d2556..4a2e4e453d 100644 --- a/src/api/event.ts +++ b/src/api/event.ts @@ -1,5 +1,6 @@ import * as mongo from 'mongodb'; import * as redis from 'redis'; +import swPush from './common/push-sw'; import config from '../conf'; type ID = string | mongo.ObjectID; @@ -17,6 +18,14 @@ class MisskeyEvent { this.publish(`user-stream:${userId}`, type, typeof value === 'undefined' ? null : value); } + public publishSw(userId: ID, type: string, value?: any): void { + swPush(userId, type, value); + } + + public publishDriveStream(userId: ID, type: string, value?: any): void { + this.publish(`drive-stream:${userId}`, type, typeof value === 'undefined' ? null : value); + } + public publishPostStream(postId: ID, type: string, value?: any): void { this.publish(`post-stream:${postId}`, type, typeof value === 'undefined' ? null : value); } @@ -25,6 +34,10 @@ class MisskeyEvent { this.publish(`messaging-stream:${userId}-${otherpartyId}`, type, typeof value === 'undefined' ? null : value); } + public publishMessagingIndexStream(userId: ID, type: string, value?: any): void { + this.publish(`messaging-index-stream:${userId}`, 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); } @@ -42,8 +55,14 @@ const ev = new MisskeyEvent(); export default ev.publishUserStream.bind(ev); +export const pushSw = ev.publishSw.bind(ev); + +export const publishDriveStream = ev.publishDriveStream.bind(ev); + export const publishPostStream = ev.publishPostStream.bind(ev); export const publishMessagingStream = ev.publishMessagingStream.bind(ev); +export const publishMessagingIndexStream = ev.publishMessagingIndexStream.bind(ev); + export const publishChannelStream = ev.publishChannelStream.bind(ev); diff --git a/src/api/models/drive-file.ts b/src/api/models/drive-file.ts index 8d158cf563..802ee5a5fe 100644 --- a/src/api/models/drive-file.ts +++ b/src/api/models/drive-file.ts @@ -1,11 +1,20 @@ -import db from '../../db/mongodb'; +import * as mongodb from 'mongodb'; +import monkDb, { nativeDbConn } from '../../db/mongodb'; -const collection = db.get('drive_files'); - -(collection as any).createIndex('hash'); // fuck type definition +const collection = monkDb.get('drive_files.files'); 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/meta.ts b/src/api/models/meta.ts new file mode 100644 index 0000000000..c7dba8fcba --- /dev/null +++ b/src/api/models/meta.ts @@ -0,0 +1,7 @@ +import db from '../../db/mongodb'; + +export default db.get('meta') as any; // fuck type definition + +export type IMeta = { + top_image: string; +}; diff --git a/src/api/models/notification.ts b/src/api/models/notification.ts index 1065e8baaa..e3dc6c70a3 100644 --- a/src/api/models/notification.ts +++ b/src/api/models/notification.ts @@ -1,8 +1,47 @@ import * as mongo from 'mongodb'; import db from '../../db/mongodb'; +import { IUser } from './user'; export default db.get('notifications') as any; // fuck type definition export interface INotification { _id: mongo.ObjectID; + created_at: Date; + + /** + * 通知の受信者 + */ + notifiee?: IUser; + + /** + * 通知の受信者 + */ + notifiee_id: mongo.ObjectID; + + /** + * イニシエータ(initiator)、Origin。通知を行う原因となったユーザー + */ + notifier?: IUser; + + /** + * イニシエータ(initiator)、Origin。通知を行う原因となったユーザー + */ + notifier_id: mongo.ObjectID; + + /** + * 通知の種類。 + * follow - フォローされた + * mention - 投稿で自分が言及された + * reply - (自分または自分がWatchしている)投稿が返信された + * repost - (自分または自分がWatchしている)投稿がRepostされた + * quote - (自分または自分がWatchしている)投稿が引用Repostされた + * reaction - (自分または自分がWatchしている)投稿にリアクションされた + * poll_vote - (自分または自分がWatchしている)投稿の投票に投票された + */ + type: 'follow' | 'mention' | 'reply' | 'repost' | 'quote' | 'reaction' | 'poll_vote'; + + /** + * 通知が読まれたかどうか + */ + is_read: Boolean; } diff --git a/src/api/models/sw-subscription.ts b/src/api/models/sw-subscription.ts new file mode 100644 index 0000000000..ecca04cb91 --- /dev/null +++ b/src/api/models/sw-subscription.ts @@ -0,0 +1,3 @@ +import db from '../../db/mongodb'; + +export default db.get('sw_subscriptions') as any; // fuck type definition diff --git a/src/api/private/signin.ts b/src/api/private/signin.ts index c7dc243980..0ebf8d6aa1 100644 --- a/src/api/private/signin.ts +++ b/src/api/private/signin.ts @@ -4,7 +4,7 @@ import { default as User, IUser } from '../models/user'; import Signin from '../models/signin'; import serialize from '../serializers/signin'; import event from '../event'; -import config from '../../conf'; +import signin from '../common/signin'; export default async (req: express.Request, res: express.Response) => { res.header('Access-Control-Allow-Credentials', 'true'); @@ -40,20 +40,10 @@ export default async (req: express.Request, res: express.Response) => { } // Compare password - const same = bcrypt.compareSync(password, user.password); + const same = await bcrypt.compare(password, user.password); if (same) { - const expires = 1000 * 60 * 60 * 24 * 365; // One Year - res.cookie('i', user.token, { - path: '/', - domain: `.${config.host}`, - secure: config.url.substr(0, 5) === 'https', - httpOnly: false, - expires: new Date(Date.now() + expires), - maxAge: expires - }); - - res.sendStatus(204); + signin(res, user, false); } else { res.status(400).send({ error: 'incorrect password' diff --git a/src/api/private/signup.ts b/src/api/private/signup.ts index bcc17a876d..466c6a489f 100644 --- a/src/api/private/signup.ts +++ b/src/api/private/signup.ts @@ -1,3 +1,4 @@ +import * as uuid from 'uuid'; import * as express from 'express'; import * as bcrypt from 'bcryptjs'; import recaptcha = require('recaptcha-promise'); @@ -8,9 +9,31 @@ import generateUserToken from '../common/generate-native-user-token'; import config from '../../conf'; recaptcha.init({ - secret_key: config.recaptcha.secretKey + secret_key: config.recaptcha.secret_key }); +const home = { + left: [ + 'profile', + 'calendar', + 'activity', + 'rss-reader', + 'trends', + 'photo-stream', + 'version' + ], + right: [ + 'broadcast', + 'notifications', + 'user-recommendation', + 'recommended-polls', + 'server', + 'donation', + 'nav', + 'tips' + ] +}; + export default async (req: express.Request, res: express.Response) => { // Verify recaptcha // ただしテスト時はこの機構は障害となるため無効にする @@ -54,12 +77,34 @@ export default async (req: express.Request, res: express.Response) => { } // Generate hash of password - const salt = bcrypt.genSaltSync(8); - const hash = bcrypt.hashSync(password, salt); + const salt = await bcrypt.genSalt(8); + const hash = await bcrypt.hash(password, salt); // Generate secret const secret = generateUserToken(); + //#region Construct home data + const homeData = []; + + home.left.forEach(widget => { + homeData.push({ + name: widget, + id: uuid(), + place: 'left', + data: {} + }); + }); + + home.right.forEach(widget => { + homeData.push({ + name: widget, + id: uuid(), + place: 'right', + data: {} + }); + }); + //#endregion + // Create account const account: IUser = await User.insert({ token: secret, @@ -88,6 +133,11 @@ export default async (req: express.Request, res: express.Response) => { height: null, location: null, weight: null + }, + settings: {}, + client_settings: { + home: homeData, + show_donation: false } }); diff --git a/src/api/serializers/drive-file.ts b/src/api/serializers/drive-file.ts index b4e2ab064a..dcdaa01fab 100644 --- a/src/api/serializers/drive-file.ts +++ b/src/api/serializers/drive-file.ts @@ -31,44 +31,44 @@ 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.'); + + // rendered target + let _target: any = {}; + + _target.id = _file._id; + _target.created_at = _file.uploadDate; + _target.name = _file.filename; + _target.type = _file.contentType; + _target.datasize = _file.length; + _target.md5 = _file.md5; - delete _file.data; + _target = Object.assign(_target, _file.metadata); - _file.url = `${config.drive_url}/${_file.id}/${encodeURIComponent(_file.name)}`; + _target.url = `${config.drive_url}/${_target.id}/${encodeURIComponent(_target.name)}`; - if (opts.detail && _file.folder_id) { + 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 7c3690ef79..03fd120772 100644 --- a/src/api/serializers/post.ts +++ b/src/api/serializers/post.ts @@ -12,6 +12,7 @@ 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 @@ -21,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, }; @@ -56,6 +57,8 @@ const self = ( _post = deepcopy(post); } + if (!_post) throw 'invalid post arg.'; + const id = _post._id; // Rename _id to id @@ -70,105 +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 = await serializeChannel(_post.channel_id); + _post.channel = serializeChannel(_post.channel_id); } // Populate media if (_post.media_ids) { - _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_id) { // Populate reply to post - _post.reply = await self(_post.reply_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 = poll.choices + .filter(c => c.id == vote.choice)[0]; - if (vote != null) { - const myChoice = _post.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..3d84156606 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 @@ -34,9 +35,10 @@ export default ( let _user: any; const fields = opts.detail ? { - data: false + settings: false } : { - data: false, + settings: false, + client_settings: false, profile: false, keywords: false, domains: false @@ -55,6 +57,8 @@ export default ( _user = deepcopy(user); } + if (!_user) return reject('invalid user arg.'); + // Me const meId: mongo.ObjectID = me ? mongo.ObjectID.prototype.isPrototypeOf(me) @@ -69,7 +73,7 @@ export default ( delete _user._id; // Remove needless properties - delete _user.lates_post; + delete _user.latest_post; // Remove private properties delete _user.password; @@ -83,8 +87,8 @@ export default ( // Visible via only the official client if (!opts.includeSecrets) { - delete _user.data; delete _user.email; + delete _user.client_settings; } _user.avatar_url = _user.avatar_id != null @@ -104,26 +108,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 +140,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/server.ts b/src/api/server.ts index 3de32d9eab..026357b465 100644 --- a/src/api/server.ts +++ b/src/api/server.ts @@ -40,7 +40,7 @@ app.get('/', (req, res) => { endpoints.forEach(endpoint => endpoint.withFile ? app.post(`/${endpoint.name}`, - endpoint.withFile ? multer({ dest: 'uploads/' }).single('file') : null, + endpoint.withFile ? multer({ storage: multer.diskStorage({}) }).single('file') : null, require('./api-handler').default.bind(null, endpoint)) : app.post(`/${endpoint.name}`, require('./api-handler').default.bind(null, endpoint)) diff --git a/src/api/service/twitter.ts b/src/api/service/twitter.ts index 9fb274aacb..f164cdc458 100644 --- a/src/api/service/twitter.ts +++ b/src/api/service/twitter.ts @@ -1,4 +1,6 @@ import * as express from 'express'; +import * as cookie from 'cookie'; +import * as uuid from 'uuid'; // import * as Twitter from 'twitter'; // const Twitter = require('twitter'); import autwh from 'autwh'; @@ -7,6 +9,7 @@ import User from '../models/user'; import serialize from '../serializers/user'; import event from '../event'; import config from '../../conf'; +import signin from '../common/signin'; module.exports = (app: express.Application) => { app.get('/disconnect/twitter', async (req, res): Promise<any> => { @@ -30,8 +33,13 @@ module.exports = (app: express.Application) => { if (config.twitter == null) { app.get('/connect/twitter', (req, res) => { - res.send('現在Twitterへ接続できません'); + res.send('現在Twitterへ接続できません (このインスタンスではTwitterはサポートされていません)'); }); + + app.get('/signin/twitter', (req, res) => { + res.send('現在Twitterへ接続できません (このインスタンスではTwitterはサポートされていません)'); + }); + return; } @@ -48,14 +56,58 @@ module.exports = (app: express.Application) => { res.redirect(ctx.url); }); + app.get('/signin/twitter', async (req, res): Promise<any> => { + const ctx = await twAuth.begin(); + + const sessid = uuid(); + + redis.set(sessid, JSON.stringify(ctx)); + + const expires = 1000 * 60 * 60; // 1h + res.cookie('signin_with_twitter_session_id', sessid, { + path: '/', + domain: `.${config.host}`, + secure: config.url.substr(0, 5) === 'https', + httpOnly: true, + expires: new Date(Date.now() + expires), + maxAge: expires + }); + + res.redirect(ctx.url); + }); + app.get('/tw/cb', (req, res): any => { - if (res.locals.user == null) return res.send('plz signin'); - redis.get(res.locals.user, async (_, ctx) => { - const result = await twAuth.done(JSON.parse(ctx), req.query.oauth_verifier); + if (res.locals.user == null) { + // req.headers['cookie'] は常に string ですが、型定義の都合上 + // string | string[] になっているので string を明示しています + const cookies = cookie.parse((req.headers['cookie'] as string || '')); + + const sessid = cookies['signin_with_twitter_session_id']; + + if (sessid == undefined) { + res.status(400).send('invalid session'); + } + + redis.get(sessid, async (_, ctx) => { + const result = await twAuth.done(JSON.parse(ctx), req.query.oauth_verifier); + + const user = await User.findOne({ + 'twitter.user_id': result.userId + }); - const user = await User.findOneAndUpdate({ - token: res.locals.user - }, { + if (user == null) { + res.status(404).send(`@${result.screenName}と連携しているMisskeyアカウントはありませんでした...`); + } + + signin(res, user, true); + }); + } else { + redis.get(res.locals.user, async (_, ctx) => { + const result = await twAuth.done(JSON.parse(ctx), req.query.oauth_verifier); + + const user = await User.findOneAndUpdate({ + token: res.locals.user + }, { $set: { twitter: { access_token: result.accessToken, @@ -66,13 +118,14 @@ module.exports = (app: express.Application) => { } }); - res.send(`Twitter: @${result.screenName} を、Misskey: @${user.username} に接続しました!`); + res.send(`Twitter: @${result.screenName} を、Misskey: @${user.username} に接続しました!`); - // Publish i updated event - event(user._id, 'i_updated', await serialize(user, user, { - detail: true, - includeSecrets: true - })); - }); + // Publish i updated event + event(user._id, 'i_updated', await serialize(user, user, { + detail: true, + includeSecrets: true + })); + }); + } }); }; diff --git a/src/api/stream/drive.ts b/src/api/stream/drive.ts new file mode 100644 index 0000000000..c97ab80dcc --- /dev/null +++ b/src/api/stream/drive.ts @@ -0,0 +1,10 @@ +import * as websocket from 'websocket'; +import * as redis from 'redis'; + +export default function(request: websocket.request, connection: websocket.connection, subscriber: redis.RedisClient, user: any): void { + // Subscribe drive stream + subscriber.subscribe(`misskey:drive-stream:${user._id}`); + subscriber.on('message', (_, data) => { + connection.send(data); + }); +} diff --git a/src/api/stream/messaging-index.ts b/src/api/stream/messaging-index.ts new file mode 100644 index 0000000000..c1b2fbc806 --- /dev/null +++ b/src/api/stream/messaging-index.ts @@ -0,0 +1,10 @@ +import * as websocket from 'websocket'; +import * as redis from 'redis'; + +export default function(request: websocket.request, connection: websocket.connection, subscriber: redis.RedisClient, user: any): void { + // Subscribe messaging index stream + subscriber.subscribe(`misskey:messaging-index-stream:${user._id}`); + subscriber.on('message', (_, data) => { + connection.send(data); + }); +} diff --git a/src/api/stream/requests.ts b/src/api/stream/requests.ts new file mode 100644 index 0000000000..2c36e58b6e --- /dev/null +++ b/src/api/stream/requests.ts @@ -0,0 +1,19 @@ +import * as websocket from 'websocket'; +import Xev from 'xev'; + +const ev = new Xev(); + +export default function homeStream(request: websocket.request, connection: websocket.connection): void { + const onRequest = request => { + connection.send(JSON.stringify({ + type: 'request', + body: request + })); + }; + + ev.addListener('request', onRequest); + + connection.on('close', () => { + ev.removeListener('request', onRequest); + }); +} diff --git a/src/api/streaming.ts b/src/api/streaming.ts index 0e512fb210..c06d64c245 100644 --- a/src/api/streaming.ts +++ b/src/api/streaming.ts @@ -7,8 +7,11 @@ import AccessToken from './models/access-token'; import isNativeToken from './common/is-native-token'; import homeStream from './stream/home'; +import driveStream from './stream/drive'; import messagingStream from './stream/messaging'; +import messagingIndexStream from './stream/messaging-index'; import serverStream from './stream/server'; +import requestsStream from './stream/requests'; import channelStream from './stream/channel'; module.exports = (server: http.Server) => { @@ -27,6 +30,11 @@ module.exports = (server: http.Server) => { return; } + if (request.resourceURL.pathname === '/requests') { + requestsStream(request, connection); + return; + } + // Connect to Redis const subscriber = redis.createClient( config.redis.port, config.redis.host); @@ -51,7 +59,9 @@ module.exports = (server: http.Server) => { const channel = request.resourceURL.pathname === '/' ? homeStream : + request.resourceURL.pathname === '/drive' ? driveStream : request.resourceURL.pathname === '/messaging' ? messagingStream : + request.resourceURL.pathname === '/messaging-index' ? messagingIndexStream : null; if (channel !== null) { diff --git a/src/common/get-notification-summary.ts b/src/common/get-notification-summary.ts new file mode 100644 index 0000000000..03db722c84 --- /dev/null +++ b/src/common/get-notification-summary.ts @@ -0,0 +1,27 @@ +import getPostSummary from './get-post-summary'; +import getReactionEmoji from './get-reaction-emoji'; + +/** + * 通知を表す文字列を取得します。 + * @param notification 通知 + */ +export default function(notification: any): string { + switch (notification.type) { + case 'follow': + return `${notification.user.name}にフォローされました`; + case 'mention': + return `言及されました:\n${notification.user.name}「${getPostSummary(notification.post)}」`; + case 'reply': + return `返信されました:\n${notification.user.name}「${getPostSummary(notification.post)}」`; + case 'repost': + return `Repostされました:\n${notification.user.name}「${getPostSummary(notification.post)}」`; + case 'quote': + return `引用されました:\n${notification.user.name}「${getPostSummary(notification.post)}」`; + case 'reaction': + return `リアクションされました:\n${notification.user.name} <${getReactionEmoji(notification.reaction)}>「${getPostSummary(notification.post)}」`; + case 'poll_vote': + return `投票されました:\n${notification.user.name}「${getPostSummary(notification.post)}」`; + default: + return `<不明な通知タイプ: ${notification.type}>`; + } +} diff --git a/src/common/get-reaction-emoji.ts b/src/common/get-reaction-emoji.ts new file mode 100644 index 0000000000..c661205379 --- /dev/null +++ b/src/common/get-reaction-emoji.ts @@ -0,0 +1,14 @@ +export default function(reaction: string): string { + switch (reaction) { + case 'like': return '👍'; + case 'love': return '❤️'; + case 'laugh': return '😆'; + case 'hmm': return '🤔'; + case 'surprise': return '😮'; + case 'congrats': return '🎉'; + case 'angry': return '💢'; + case 'confused': return '😥'; + case 'pudding': return '🍮'; + default: return ''; + } +} diff --git a/src/config.ts b/src/config.ts index d37d227a41..3ff8007586 100644 --- a/src/config.ts +++ b/src/config.ts @@ -3,7 +3,6 @@ */ import * as fs from 'fs'; -import * as URL from 'url'; import * as yaml from 'js-yaml'; import isUrl = require('is-url'); @@ -23,16 +22,23 @@ export const path = process.env.NODE_ENV == 'test' * ユーザーが設定する必要のある情報 */ type Source = { - maintainer: string; + /** + * メンテナ情報 + */ + maintainer: { + /** + * メンテナの名前 + */ + name: string; + /** + * メンテナの連絡先(URLかmailto形式のURL) + */ + url: string; + }; url: string; secondary_url: string; port: number; - https: { - enable: boolean; - key: string; - cert: string; - ca: string; - }; + https?: { [x: string]: string }; mongodb: { host: string; port: number; @@ -52,8 +58,8 @@ type Source = { pass: string; }; recaptcha: { - siteKey: string; - secretKey: string; + site_key: string; + secret_key: string; }; accesslog?: string; accesses?: { @@ -75,6 +81,14 @@ type Source = { analysis?: { mecab_command?: string; }; + + /** + * Service Worker + */ + sw?: { + public_key: string; + private_key: string; + }; }; /** @@ -106,14 +120,6 @@ export default function load() { if (!isUrl(config.url)) urlError(config.url); if (!isUrl(config.secondary_url)) urlError(config.secondary_url); - const url = URL.parse(config.url); - const head = url.host.split('.')[0]; - - if (head != 'misskey') { - console.error(`プライマリドメインは、必ず「misskey」ドメインで始まっていなければなりません(現在の設定では「${head}」で始まっています)。例えば「https://misskey.xyz」「http://misskey.my.app.example.com」などが正しいプライマリURLです。`); - process.exit(); - } - config.url = normalizeUrl(config.url); config.secondary_url = normalizeUrl(config.secondary_url); diff --git a/src/const.json b/src/const.json index eeb304c9f3..924b4dd8b3 100644 --- a/src/const.json +++ b/src/const.json @@ -1,4 +1,4 @@ { - "themeColor": "#f43636", + "themeColor": "#ff4e45", "themeColorForeground": "#fff" } 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/file/assets/not-an-image.png b/src/file/assets/not-an-image.png Binary files differnew file mode 100644 index 0000000000..bf98b293f7 --- /dev/null +++ b/src/file/assets/not-an-image.png diff --git a/src/file/assets/thumbnail-not-available.png b/src/file/assets/thumbnail-not-available.png Binary files differnew file mode 100644 index 0000000000..f960ce4d00 --- /dev/null +++ b/src/file/assets/thumbnail-not-available.png diff --git a/src/file/server.ts b/src/file/server.ts index ee67cf7860..1f8d21b80d 100644 --- a/src/file/server.ts +++ b/src/file/server.ts @@ -8,8 +8,9 @@ import * as bodyParser from 'body-parser'; import * as cors from 'cors'; import * as mongodb from 'mongodb'; import * as gm from 'gm'; +import * as stream from 'stream'; -import File from '../api/models/drive-file'; +import DriveFile, { getGridFSBucket } from '../api/models/drive-file'; /** * Init app @@ -33,101 +34,127 @@ app.get('/', (req, res) => { }); app.get('/default-avatar.jpg', (req, res) => { - const file = fs.readFileSync(`${__dirname}/assets/avatar.jpg`); + const file = fs.createReadStream(`${__dirname}/assets/avatar.jpg`); send(file, 'image/jpeg', req, res); }); app.get('/app-default.jpg', (req, res) => { - const file = fs.readFileSync(`${__dirname}/assets/dummy.png`); + const file = fs.createReadStream(`${__dirname}/assets/dummy.png`); send(file, 'image/png', req, res); }); -async function raw(data: Buffer, type: string, download: boolean, res: express.Response): Promise<any> { - res.header('Content-Type', type); +interface ISend { + contentType: string; + stream: stream.Readable; +} - if (download) { - res.header('Content-Disposition', 'attachment'); - } +function thumbnail(data: stream.Readable, type: string, resize: number): ISend { + const readable: stream.Readable = (() => { + // 画像ではない場合 + if (!/^image\/.*$/.test(type)) { + // 使わないことにしたストリームはしっかり取り壊しておく + data.destroy(); + return fs.createReadStream(`${__dirname}/assets/not-an-image.png`); + } - res.send(data); -} + const imageType = type.split('/')[1]; -async function thumbnail(data: Buffer, type: string, resize: number, res: express.Response): Promise<any> { - if (!/^image\/.*$/.test(type)) { - data = fs.readFileSync(`${__dirname}/assets/dummy.png`); - } + // 画像でもPNGかJPEGでないならダメ + if (imageType != 'png' && imageType != 'jpeg') { + // 使わないことにしたストリームはしっかり取り壊しておく + data.destroy(); + return fs.createReadStream(`${__dirname}/assets/thumbnail-not-available.png`); + } - let g = gm(data); + return data; + })(); + + let g = gm(readable); if (resize) { g = g.resize(resize, resize); } - g + const stream = g .compress('jpeg') .quality(80) - .toBuffer('jpeg', (err, img) => { - if (err !== undefined && err !== null) { - console.error(err); - res.sendStatus(500); - return; - } + .stream(); - res.header('Content-Type', 'image/jpeg'); - res.send(img); - }); + return { + contentType: 'image/jpeg', + stream + }; } -function send(data: Buffer, type: string, req: express.Request, res: express.Response): void { - if (req.query.thumbnail !== undefined) { - thumbnail(data, type, req.query.size, res); - } else { - raw(data, type, req.query.download !== undefined, res); - } -} +const commonReadableHandlerGenerator = (req: express.Request, res: express.Response) => (e: Error): void => { + console.dir(e); + req.destroy(); + res.destroy(e); +}; -/** - * Routing - */ +function send(readable: stream.Readable, type: string, req: express.Request, res: express.Response): void { + readable.on('error', commonReadableHandlerGenerator(req, res)); -app.get('/:id', async (req, res) => { - // Validate id - if (!mongodb.ObjectID.isValid(req.params.id)) { - res.status(400).send('incorrect id'); - return; - } + const data = ((): ISend => { + if (req.query.thumbnail !== undefined) { + return thumbnail(readable, type, req.query.size); + } + return { + contentType: type, + stream: readable + }; + })(); - const file = await File.findOne({ _id: new mongodb.ObjectID(req.params.id) }); + if (readable !== data.stream) { + data.stream.on('error', commonReadableHandlerGenerator(req, res)); + } - if (file == null) { - res.status(404).sendFile(`${__dirname} / assets / dummy.png`); - return; - } else if (file.data == null) { - res.sendStatus(400); - return; + if (req.query.download !== undefined) { + res.header('Content-Disposition', 'attachment'); } - send(file.data.buffer, file.type, req, res); -}); + res.header('Content-Type', data.contentType); + + data.stream.pipe(res); + + data.stream.on('end', () => { + res.end(); + }); +} -app.get('/:id/:name', async (req, res) => { +async function sendFileById(req: express.Request, res: express.Response): Promise<void> { // Validate id if (!mongodb.ObjectID.isValid(req.params.id)) { res.status(400).send('incorrect id'); 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 }); + + // validate name + if (req.params.name !== undefined && req.params.name !== file.filename) { + res.status(404).send('there is no file has given name'); + return; + } 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 readable = bucket.openDownloadStream(fileId); + + send(readable, file.contentType, req, res); +} + +/** + * Routing + */ + +app.get('/:id', sendFileById); +app.get('/:id/:name', sendFileById); module.exports = app; diff --git a/src/index.ts b/src/index.ts index aa53c91239..218455d6f4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,7 +8,7 @@ import * as fs from 'fs'; import * as os from 'os'; import * as cluster from 'cluster'; import * as debug from 'debug'; -import * as chalk from 'chalk'; +import chalk from 'chalk'; // import portUsed = require('tcp-port-used'); import isRoot = require('is-root'); import { master } from 'accesses'; diff --git a/src/log-request.ts b/src/log-request.ts new file mode 100644 index 0000000000..e431aa271d --- /dev/null +++ b/src/log-request.ts @@ -0,0 +1,21 @@ +import * as crypto from 'crypto'; +import * as express from 'express'; +import * as proxyAddr from 'proxy-addr'; +import Xev from 'xev'; + +const ev = new Xev(); + +export default function(req: express.Request) { + const ip = proxyAddr(req, () => true); + + const md5 = crypto.createHash('md5'); + md5.update(ip); + const hashedIp = md5.digest('hex').substr(0, 3); + + ev.emit('request', { + ip: hashedIp, + method: req.method, + hostname: req.hostname, + path: req.originalUrl + }); +} diff --git a/src/server.ts b/src/server.ts index 240800c1e2..a2165d672b 100644 --- a/src/server.ts +++ b/src/server.ts @@ -11,6 +11,7 @@ import * as morgan from 'morgan'; import Accesses from 'accesses'; import vhost = require('vhost'); +import log from './log-request'; import config from './conf'; /** @@ -35,7 +36,12 @@ app.use(morgan(process.env.NODE_ENV == 'production' ? 'combined' : 'dev', { stream: config.accesslog ? fs.createWriteStream(config.accesslog) : null })); -// Drop request that without 'Host' header +app.use((req, res, next) => { + log(req); + next(); +}); + +// Drop request when without 'Host' header app.use((req, res, next) => { if (!req.headers['host']) { res.sendStatus(400); @@ -55,13 +61,17 @@ app.use(require('./web/server')); /** * Create server */ -const server = config.https.enable ? - https.createServer({ - key: fs.readFileSync(config.https.key), - cert: fs.readFileSync(config.https.cert), - ca: fs.readFileSync(config.https.ca) - }, app) : - http.createServer(app); +const server = (() => { + if (config.https) { + const certs = {}; + Object.keys(config.https).forEach(k => { + certs[k] = fs.readFileSync(config.https[k]); + }); + return https.createServer(certs, app); + } else { + return http.createServer(app); + } +})(); /** * Steaming diff --git a/src/utils/cli/progressbar.ts b/src/utils/cli/progressbar.ts index 4afb4b0904..72496fdedc 100644 --- a/src/utils/cli/progressbar.ts +++ b/src/utils/cli/progressbar.ts @@ -1,6 +1,6 @@ import { EventEmitter } from 'events'; import * as readline from 'readline'; -import * as chalk from 'chalk'; +import chalk from 'chalk'; /** * Progress bar diff --git a/src/utils/logger.ts b/src/utils/logger.ts index ecfacbc952..fae1042c39 100644 --- a/src/utils/logger.ts +++ b/src/utils/logger.ts @@ -1,8 +1,8 @@ -import * as chalk from 'chalk'; +import chalk, { Chalk } from 'chalk'; export type LogLevel = 'Error' | 'Warn' | 'Info'; -function toLevelColor(level: LogLevel): chalk.ChalkStyle { +function toLevelColor(level: LogLevel): Chalk { switch (level) { case 'Error': return chalk.red; case 'Warn': return chalk.yellow; diff --git a/src/web/app/auth/script.js b/src/web/app/auth/script.ts index fe7f9befe8..dd598d1ed6 100644 --- a/src/web/app/auth/script.js +++ b/src/web/app/auth/script.ts @@ -14,7 +14,7 @@ document.title = 'Misskey | アプリの連携'; /** * init */ -init(me => { +init(() => { mount(document.createElement('mk-index')); }); diff --git a/src/web/app/auth/tags/index.js b/src/web/app/auth/tags/index.ts index 42dffe67d9..42dffe67d9 100644 --- a/src/web/app/auth/tags/index.js +++ b/src/web/app/auth/tags/index.ts diff --git a/src/web/app/base.pug b/src/web/app/base.pug index b1ca80deb9..3c3546d50d 100644 --- a/src/web/app/base.pug +++ b/src/web/app/base.pug @@ -9,6 +9,7 @@ html meta(name='application-name' content='Misskey') meta(name='theme-color' content=themeColor) meta(name='referrer' content='origin') + link(rel='manifest' href='/manifest.json') title Misskey diff --git a/src/web/app/boot.js b/src/web/app/boot.js index ac6c18d649..4a8ea030a1 100644 --- a/src/web/app/boot.js +++ b/src/web/app/boot.js @@ -27,7 +27,9 @@ // misskey.alice => misskey // misskey.strawberry.pasta => misskey // dev.misskey.arisu.tachibana => dev - let app = url.host.split('.')[0]; + let app = url.host == 'localhost' + ? 'misskey' + : url.host.split('.')[0]; // Detect the user language // Note: The default language is English diff --git a/src/web/app/ch/router.js b/src/web/app/ch/router.ts index 424158f403..f10c4acdf0 100644 --- a/src/web/app/ch/router.js +++ b/src/web/app/ch/router.ts @@ -1,8 +1,8 @@ import * as riot from 'riot'; -const route = require('page'); +import * as route from 'page'; let page = null; -export default me => { +export default () => { route('/', index); route('/:channel', channel); route('*', notFound); @@ -22,7 +22,7 @@ export default me => { } // EXEC - route(); + (route as any)(); }; function mount(content) { diff --git a/src/web/app/ch/script.js b/src/web/app/ch/script.ts index 760d405c52..e23558037c 100644 --- a/src/web/app/ch/script.js +++ b/src/web/app/ch/script.ts @@ -12,7 +12,7 @@ import route from './router'; /** * init */ -init(me => { +init(() => { // Start routing - route(me); + route(); }); diff --git a/src/web/app/ch/tags/channel.tag b/src/web/app/ch/tags/channel.tag index 4ae62e7b39..716d61cde4 100644 --- a/src/web/app/ch/tags/channel.tag +++ b/src/web/app/ch/tags/channel.tag @@ -26,11 +26,11 @@ <hr> <mk-channel-form if={ SIGNIN } channel={ channel } ref="form"/> <div if={ !SIGNIN }> - <p>参加するには<a href={ CONFIG.url }>ログインまたは新規登録</a>してください</p> + <p>参加するには<a href={ _URL_ }>ログインまたは新規登録</a>してください</p> </div> <hr> <footer> - <small><a href={ CONFIG.url }>Misskey</a> ver { version } (葵 aoi)</small> + <small><a href={ _URL_ }>Misskey</a> ver { _VERSION_ } (葵 aoi)</small> </footer> </main> <style> @@ -55,7 +55,7 @@ </style> <script> import Progress from '../../common/scripts/loading'; - import ChannelStream from '../../common/scripts/channel-stream'; + import ChannelStream from '../../common/scripts/streaming/channel-stream'; this.mixin('i'); this.mixin('api'); @@ -66,7 +66,6 @@ this.channel = null; this.posts = null; this.connection = new ChannelStream(this.id); - this.version = VERSION; this.unreadCount = 0; this.on('mount', () => { @@ -166,7 +165,7 @@ <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> + <a class="name" href={ _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> @@ -284,8 +283,6 @@ </style> <script> - import CONFIG from '../../common/scripts/config'; - this.mixin('api'); this.channel = this.opts.channel; @@ -343,7 +340,7 @@ }; this.changeFile = () => { - this.refs.file.files.forEach(this.upload); + Array.from(this.refs.file.files).forEach(this.upload); }; this.selectFile = () => { @@ -357,7 +354,7 @@ }); }; - window.open(CONFIG.url + '/selectdrive?multiple=true', + window.open(_URL_ + '/selectdrive?multiple=true', 'drive_window', 'height=500,width=800'); }; @@ -367,7 +364,7 @@ }; this.onpaste = e => { - e.clipboardData.items.forEach(item => { + Array.from(e.clipboardData.items).forEach(item => { if (item.kind == 'file') { this.upload(item.getAsFile()); } @@ -390,7 +387,7 @@ </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> + <div class="line-it-button" data-lang="ja" data-type="share-a" data-url={ _CH_URL_ } style="display: none;"></div> <script> this.on('mount', () => { const head = document.getElementsByTagName('head')[0]; diff --git a/src/web/app/ch/tags/header.tag b/src/web/app/ch/tags/header.tag index 5cdcbd09cc..dec83c9a5b 100644 --- a/src/web/app/ch/tags/header.tag +++ b/src/web/app/ch/tags/header.tag @@ -1,10 +1,10 @@ <mk-header> <div> - <a href={ CONFIG.chUrl }>Index</a> | <a href={ CONFIG.url }>Misskey</a> + <a href={ _CH_URL_ }>Index</a> | <a href={ _URL_ }>Misskey</a> </div> <div> - <a if={ !SIGNIN } href={ CONFIG.url }>ログイン(新規登録)</a> - <a if={ SIGNIN } href={ CONFIG.url + '/' + I.username }>{ I.username }</a> + <a if={ !SIGNIN } href={ _URL_ }>ログイン(新規登録)</a> + <a if={ SIGNIN } href={ _URL_ + '/' + I.username }>{ I.username }</a> </div> <style> :scope diff --git a/src/web/app/ch/tags/index.tag b/src/web/app/ch/tags/index.tag index 50ccc0d91c..5f3871802a 100644 --- a/src/web/app/ch/tags/index.tag +++ b/src/web/app/ch/tags/index.tag @@ -15,7 +15,9 @@ this.mixin('api'); this.on('mount', () => { - this.api('channels').then(channels => { + this.api('channels', { + limit: 100 + }).then(channels => { this.update({ channels: channels }); diff --git a/src/web/app/ch/tags/index.js b/src/web/app/ch/tags/index.ts index 12ffdaeb84..12ffdaeb84 100644 --- a/src/web/app/ch/tags/index.js +++ b/src/web/app/ch/tags/index.ts diff --git a/src/web/app/common/mios.ts b/src/web/app/common/mios.ts new file mode 100644 index 0000000000..6ee42ea8a7 --- /dev/null +++ b/src/web/app/common/mios.ts @@ -0,0 +1,351 @@ +import { EventEmitter } from 'eventemitter3'; +import * as riot from 'riot'; +import signout from './scripts/signout'; +import Progress from './scripts/loading'; +import HomeStreamManager from './scripts/streaming/home-stream-manager'; +import api from './scripts/api'; + +//#region environment variables +declare const _VERSION_: string; +declare const _LANG_: string; +declare const _API_URL_: string; +declare const _SW_PUBLICKEY_: string; +//#endregion + +/** + * Misskey Operating System + */ +export default class MiOS extends EventEmitter { + /** + * Misskeyの /meta で取得できるメタ情報 + */ + private meta: { + data: { [x: string]: any }; + chachedAt: Date; + }; + + private isMetaFetching = false; + + /** + * A signing user + */ + public i: { [x: string]: any }; + + /** + * Whether signed in + */ + public get isSignedin() { + return this.i != null; + } + + /** + * Whether is debug mode + */ + public get debug() { + return localStorage.getItem('debug') == 'true'; + } + + /** + * A connection manager of home stream + */ + public stream: HomeStreamManager; + + /** + * A registration of service worker + */ + private swRegistration: ServiceWorkerRegistration = null; + + /** + * Whether should register ServiceWorker + */ + private shouldRegisterSw: boolean; + + /** + * MiOSインスタンスを作成します + * @param shouldRegisterSw ServiceWorkerを登録するかどうか + */ + constructor(shouldRegisterSw = false) { + super(); + + this.shouldRegisterSw = shouldRegisterSw; + + //#region BIND + this.log = this.log.bind(this); + this.logInfo = this.logInfo.bind(this); + this.logWarn = this.logWarn.bind(this); + this.logError = this.logError.bind(this); + this.init = this.init.bind(this); + this.api = this.api.bind(this); + this.getMeta = this.getMeta.bind(this); + this.registerSw = this.registerSw.bind(this); + //#endregion + } + + public log(...args) { + if (!this.debug) return; + console.log.apply(null, args); + } + + public logInfo(...args) { + if (!this.debug) return; + console.info.apply(null, args); + } + + public logWarn(...args) { + if (!this.debug) return; + console.warn.apply(null, args); + } + + public logError(...args) { + if (!this.debug) return; + console.error.apply(null, args); + } + + /** + * Initialize MiOS (boot) + * @param callback A function that call when initialized + */ + public async init(callback) { + // ユーザーをフェッチしてコールバックする + const fetchme = (token, cb) => { + let me = null; + + // Return when not signed in + if (token == null) { + return done(); + } + + // Fetch user + fetch(`${_API_URL_}/i`, { + method: 'POST', + body: JSON.stringify({ + i: token + }) + }) + // When success + .then(res => { + // When failed to authenticate user + if (res.status !== 200) { + return signout(); + } + + // Parse response + res.json().then(i => { + me = i; + me.token = token; + done(); + }); + }) + // When failure + .catch(() => { + // Render the error screen + document.body.innerHTML = '<mk-error />'; + riot.mount('*'); + + Progress.done(); + }); + + function done() { + if (cb) cb(me); + } + }; + + // フェッチが完了したとき + const fetched = me => { + if (me) { + riot.observable(me); + + // この me オブジェクトを更新するメソッド + me.update = data => { + if (data) Object.assign(me, data); + me.trigger('updated'); + }; + + // ローカルストレージにキャッシュ + localStorage.setItem('me', JSON.stringify(me)); + + // 自分の情報が更新されたとき + me.on('updated', () => { + // キャッシュ更新 + localStorage.setItem('me', JSON.stringify(me)); + }); + } + + this.i = me; + + // Init home stream manager + this.stream = this.isSignedin + ? new HomeStreamManager(this.i) + : null; + + // Finish init + callback(); + + //#region Post + + // Init service worker + if (this.shouldRegisterSw) this.registerSw(); + + //#endregion + }; + + // Get cached account data + const cachedMe = JSON.parse(localStorage.getItem('me')); + + // キャッシュがあったとき + if (cachedMe) { + // とりあえずキャッシュされたデータでお茶を濁して(?)おいて、 + fetched(cachedMe); + + // 後から新鮮なデータをフェッチ + fetchme(cachedMe.token, freshData => { + Object.assign(cachedMe, freshData); + cachedMe.trigger('updated'); + cachedMe.trigger('refreshed'); + }); + } else { + // Get token from cookie + const i = (document.cookie.match(/i=(!\w+)/) || [null, null])[1]; + + fetchme(i, fetched); + } + } + + /** + * Register service worker + */ + private registerSw() { + // Check whether service worker and push manager supported + const isSwSupported = + ('serviceWorker' in navigator) && ('PushManager' in window); + + // Reject when browser not service worker supported + if (!isSwSupported) return; + + // Reject when not signed in to Misskey + if (!this.isSignedin) return; + + // When service worker activated + navigator.serviceWorker.ready.then(registration => { + this.log('[sw] ready: ', registration); + + this.swRegistration = registration; + + // Options of pushManager.subscribe + // SEE: https://developer.mozilla.org/en-US/docs/Web/API/PushManager/subscribe#Parameters + const opts = { + // A boolean indicating that the returned push subscription + // will only be used for messages whose effect is made visible to the user. + userVisibleOnly: true, + + // A public key your push server will use to send + // messages to client apps via a push server. + applicationServerKey: urlBase64ToUint8Array(_SW_PUBLICKEY_) + }; + + // Subscribe push notification + this.swRegistration.pushManager.subscribe(opts).then(subscription => { + this.log('[sw] Subscribe OK:', subscription); + + function encode(buffer: ArrayBuffer) { + return btoa(String.fromCharCode.apply(null, new Uint8Array(buffer))); + } + + // Register + this.api('sw/register', { + endpoint: subscription.endpoint, + auth: encode(subscription.getKey('auth')), + publickey: encode(subscription.getKey('p256dh')) + }); + }) + // When subscribe failed + .catch(async (err: Error) => { + this.logError('[sw] Subscribe Error:', err); + + // 通知が許可されていなかったとき + if (err.name == 'NotAllowedError') { + this.logError('[sw] Subscribe failed due to notification not allowed'); + return; + } + + // 違うapplicationServerKey (または gcm_sender_id)のサブスクリプションが + // 既に存在していることが原因でエラーになった可能性があるので、 + // そのサブスクリプションを解除しておく + const subscription = await this.swRegistration.pushManager.getSubscription(); + if (subscription) subscription.unsubscribe(); + }); + }); + + // The path of service worker script + const sw = `/sw.${_VERSION_}.${_LANG_}.js`; + + // Register service worker + navigator.serviceWorker.register(sw).then(registration => { + // 登録成功 + this.logInfo('[sw] Registration successful with scope: ', registration.scope); + }).catch(err => { + // 登録失敗 :( + this.logError('[sw] Registration failed: ', err); + }); + } + + /** + * Misskey APIにリクエストします + * @param endpoint エンドポイント名 + * @param data パラメータ + */ + public api(endpoint: string, data?: { [x: string]: any }) { + return api(this.i, endpoint, data); + } + + /** + * Misskeyのメタ情報を取得します + * @param force キャッシュを無視するか否か + */ + public getMeta(force = false) { + return new Promise<{ [x: string]: any }>(async (res, rej) => { + if (this.isMetaFetching) { + this.once('_meta_fetched_', () => { + res(this.meta.data); + }); + return; + } + + const expire = 1000 * 60; // 1min + + // forceが有効, meta情報を保持していない or 期限切れ + if (force || this.meta == null || Date.now() - this.meta.chachedAt.getTime() > expire) { + this.isMetaFetching = true; + const meta = await this.api('meta'); + this.meta = { + data: meta, + chachedAt: new Date() + }; + this.isMetaFetching = false; + this.emit('_meta_fetched_'); + res(meta); + } else { + res(this.meta.data); + } + }); + } +} + +/** + * Convert the URL safe base64 string to a Uint8Array + * @param base64String base64 string + */ +function urlBase64ToUint8Array(base64String: string): Uint8Array { + const padding = '='.repeat((4 - base64String.length % 4) % 4); + const base64 = (base64String + padding) + .replace(/\-/g, '+') + .replace(/_/g, '/'); + + const rawData = window.atob(base64); + const outputArray = new Uint8Array(rawData.length); + + for (let i = 0; i < rawData.length; ++i) { + outputArray[i] = rawData.charCodeAt(i); + } + return outputArray; +} diff --git a/src/web/app/common/mixins.ts b/src/web/app/common/mixins.ts new file mode 100644 index 0000000000..e9c3625937 --- /dev/null +++ b/src/web/app/common/mixins.ts @@ -0,0 +1,40 @@ +import * as riot from 'riot'; + +import MiOS from './mios'; +import ServerStreamManager from './scripts/streaming/server-stream-manager'; +import RequestsStreamManager from './scripts/streaming/requests-stream-manager'; +import MessagingIndexStreamManager from './scripts/streaming/messaging-index-stream-manager'; +import DriveStreamManager from './scripts/streaming/drive-stream-manager'; + +export default (mios: MiOS) => { + (riot as any).mixin('os', { + mios: mios + }); + + (riot as any).mixin('i', { + init: function() { + this.I = mios.i; + this.SIGNIN = mios.isSignedin; + + if (this.SIGNIN) { + this.on('mount', () => { + mios.i.on('updated', this.update); + }); + this.on('unmount', () => { + mios.i.off('updated', this.update); + }); + } + }, + me: mios.i + }); + + (riot as any).mixin('api', { + api: mios.api + }); + + (riot as any).mixin('stream', { stream: mios.stream }); + (riot as any).mixin('drive-stream', { driveStream: new DriveStreamManager(mios.i) }); + (riot as any).mixin('server-stream', { serverStream: new ServerStreamManager() }); + (riot as any).mixin('requests-stream', { requestsStream: new RequestsStreamManager() }); + (riot as any).mixin('messaging-index-stream', { messagingIndexStream: new MessagingIndexStreamManager(mios.i) }); +}; diff --git a/src/web/app/common/mixins/api.js b/src/web/app/common/mixins/api.js deleted file mode 100644 index 42d96db559..0000000000 --- a/src/web/app/common/mixins/api.js +++ /dev/null @@ -1,8 +0,0 @@ -import * as riot from 'riot'; -import api from '../scripts/api'; - -export default me => { - riot.mixin('api', { - api: api.bind(null, me ? me.token : null) - }); -}; diff --git a/src/web/app/common/mixins/i.js b/src/web/app/common/mixins/i.js deleted file mode 100644 index 5225147766..0000000000 --- a/src/web/app/common/mixins/i.js +++ /dev/null @@ -1,20 +0,0 @@ -import * as riot from 'riot'; - -export default me => { - riot.mixin('i', { - init: function() { - this.I = me; - this.SIGNIN = me != null; - - if (this.SIGNIN) { - this.on('mount', () => { - me.on('updated', this.update); - }); - this.on('unmount', () => { - me.off('updated', this.update); - }); - } - }, - me: me - }); -}; diff --git a/src/web/app/common/mixins/index.js b/src/web/app/common/mixins/index.js deleted file mode 100644 index 9718ee949b..0000000000 --- a/src/web/app/common/mixins/index.js +++ /dev/null @@ -1,9 +0,0 @@ -import activateMe from './i'; -import activateApi from './api'; -import activateStream from './stream'; - -export default (me, stream) => { - activateMe(me); - activateApi(me); - activateStream(stream); -}; diff --git a/src/web/app/common/mixins/stream.js b/src/web/app/common/mixins/stream.js deleted file mode 100644 index 4706042b04..0000000000 --- a/src/web/app/common/mixins/stream.js +++ /dev/null @@ -1,5 +0,0 @@ -import * as riot from 'riot'; - -export default stream => { - riot.mixin('stream', { stream }); -}; diff --git a/src/web/app/common/scripts/api.js b/src/web/app/common/scripts/api.ts index 4855f736c7..e62447b0a0 100644 --- a/src/web/app/common/scripts/api.js +++ b/src/web/app/common/scripts/api.ts @@ -2,7 +2,7 @@ * API Request */ -import CONFIG from './config'; +declare const _API_URL_: string; let spinner = null; let pending = 0; @@ -14,7 +14,7 @@ let pending = 0; * @param {any} [data={}] Data * @return {Promise<any>} Response */ -export default (i, endpoint, data = {}) => { +export default (i, endpoint, data = {}): Promise<{ [x: string]: any }> => { if (++pending === 1) { spinner = document.createElement('div'); spinner.setAttribute('id', 'wait'); @@ -22,11 +22,11 @@ export default (i, endpoint, data = {}) => { } // Append the credential - if (i != null) data.i = typeof i === 'object' ? i.token : i; + if (i != null) (data as any).i = typeof i === 'object' ? i.token : i; return new Promise((resolve, reject) => { // Send request - fetch(endpoint.indexOf('://') > -1 ? endpoint : `${CONFIG.apiUrl}/${endpoint}`, { + fetch(endpoint.indexOf('://') > -1 ? endpoint : `${_API_URL_}/${endpoint}`, { method: 'POST', body: JSON.stringify(data), credentials: endpoint === 'signin' ? 'include' : 'omit' diff --git a/src/web/app/common/scripts/bytes-to-size.js b/src/web/app/common/scripts/bytes-to-size.ts index af0268dbd0..1d2b1e7ce3 100644 --- a/src/web/app/common/scripts/bytes-to-size.js +++ b/src/web/app/common/scripts/bytes-to-size.ts @@ -1,6 +1,6 @@ export default (bytes, digits = 0) => { - var sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']; + const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']; if (bytes == 0) return '0Byte'; - var i = parseInt(Math.floor(Math.log(bytes) / Math.log(1024))); + const i = Math.floor(Math.log(bytes) / Math.log(1024)); return (bytes / Math.pow(1024, i)).toFixed(digits).replace(/\.0+$/, '') + sizes[i]; }; diff --git a/src/web/app/common/scripts/check-for-update.js b/src/web/app/common/scripts/check-for-update.js deleted file mode 100644 index 7cb7839d29..0000000000 --- a/src/web/app/common/scripts/check-for-update.js +++ /dev/null @@ -1,14 +0,0 @@ -import CONFIG from './config'; - -export default function() { - fetch(CONFIG.apiUrl + '/meta', { - method: 'POST' - }).then(res => { - res.json().then(meta => { - if (meta.version != VERSION) { - localStorage.setItem('should-refresh', 'true'); - alert('%i18n:common.update-available%'.replace('{newer}', meta.version).replace('{current}', VERSION)); - } - }); - }); -}; diff --git a/src/web/app/common/scripts/check-for-update.ts b/src/web/app/common/scripts/check-for-update.ts new file mode 100644 index 0000000000..c447a517fa --- /dev/null +++ b/src/web/app/common/scripts/check-for-update.ts @@ -0,0 +1,12 @@ +import MiOS from '../mios'; + +declare const _VERSION_: string; + +export default async function(mios: MiOS) { + const meta = await mios.getMeta(); + + if (meta.version != _VERSION_) { + localStorage.setItem('should-refresh', 'true'); + alert('%i18n:common.update-available%'.replace('{newer}', meta.version).replace('{current}', _VERSION_)); + } +} diff --git a/src/web/app/common/scripts/compose-notification.ts b/src/web/app/common/scripts/compose-notification.ts new file mode 100644 index 0000000000..d0e0c2098d --- /dev/null +++ b/src/web/app/common/scripts/compose-notification.ts @@ -0,0 +1,60 @@ +import getPostSummary from '../../../../common/get-post-summary'; +import getReactionEmoji from '../../../../common/get-reaction-emoji'; + +type Notification = { + title: string; + body: string; + icon: string; + onclick?: any; +}; + +// TODO: i18n + +export default function(type, data): Notification { + switch (type) { + case 'drive_file_created': + return { + title: 'ファイルがアップロードされました', + body: data.name, + icon: data.url + '?thumbnail&size=64' + }; + + case 'mention': + return { + title: `${data.user.name}さんから:`, + body: getPostSummary(data), + icon: data.user.avatar_url + '?thumbnail&size=64' + }; + + case 'reply': + return { + title: `${data.user.name}さんから返信:`, + body: getPostSummary(data), + icon: data.user.avatar_url + '?thumbnail&size=64' + }; + + case 'quote': + return { + title: `${data.user.name}さんが引用:`, + body: getPostSummary(data), + icon: data.user.avatar_url + '?thumbnail&size=64' + }; + + case 'reaction': + return { + title: `${data.user.name}: ${getReactionEmoji(data.reaction)}:`, + body: getPostSummary(data.post), + icon: data.user.avatar_url + '?thumbnail&size=64' + }; + + case 'unread_messaging_message': + return { + title: `${data.user.name}さんからメッセージ:`, + body: data.text, // TODO: getMessagingMessageSummary(data), + icon: data.user.avatar_url + '?thumbnail&size=64' + }; + + default: + return null; + } +} diff --git a/src/web/app/common/scripts/config.js b/src/web/app/common/scripts/config.js deleted file mode 100644 index c5015622f0..0000000000 --- a/src/web/app/common/scripts/config.js +++ /dev/null @@ -1,25 +0,0 @@ -const Url = new URL(location.href); - -const isRoot = Url.host.split('.')[0] == 'misskey'; - -const host = isRoot ? Url.host : Url.host.substring(Url.host.indexOf('.') + 1, Url.host.length); -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}`; -const statusUrl = `${scheme}//status.${host}`; - -export default { - host, - scheme, - url, - apiUrl, - chUrl, - devUrl, - aboutUrl, - statsUrl, - statusUrl -}; diff --git a/src/web/app/common/scripts/contains.js b/src/web/app/common/scripts/contains.ts index a5071b3f25..a5071b3f25 100644 --- a/src/web/app/common/scripts/contains.js +++ b/src/web/app/common/scripts/contains.ts diff --git a/src/web/app/common/scripts/copy-to-clipboard.js b/src/web/app/common/scripts/copy-to-clipboard.ts index 3d2741f8d7..3d2741f8d7 100644 --- a/src/web/app/common/scripts/copy-to-clipboard.js +++ b/src/web/app/common/scripts/copy-to-clipboard.ts diff --git a/src/web/app/common/scripts/date-stringify.js b/src/web/app/common/scripts/date-stringify.ts index e51de8833d..e51de8833d 100644 --- a/src/web/app/common/scripts/date-stringify.js +++ b/src/web/app/common/scripts/date-stringify.ts diff --git a/src/web/app/common/scripts/gcd.js b/src/web/app/common/scripts/gcd.ts index 9a19f9da66..9a19f9da66 100644 --- a/src/web/app/common/scripts/gcd.js +++ b/src/web/app/common/scripts/gcd.ts diff --git a/src/web/app/common/scripts/generate-default-userdata.js b/src/web/app/common/scripts/generate-default-userdata.js deleted file mode 100644 index 1200563e1a..0000000000 --- a/src/web/app/common/scripts/generate-default-userdata.js +++ /dev/null @@ -1,45 +0,0 @@ -import uuid from './uuid'; - -const home = { - left: [ - 'profile', - 'calendar', - 'rss-reader', - 'photo-stream', - 'version' - ], - right: [ - 'broadcast', - 'notifications', - 'user-recommendation', - 'donation', - 'nav', - 'tips' - ] -}; - -export default () => { - const homeData = []; - - home.left.forEach(widget => { - homeData.push({ - name: widget, - id: uuid(), - place: 'left' - }); - }); - - home.right.forEach(widget => { - homeData.push({ - name: widget, - id: uuid(), - place: 'right' - }); - }); - - const data = { - home: JSON.stringify(homeData) - }; - - return data; -}; diff --git a/src/web/app/common/scripts/get-kao.js b/src/web/app/common/scripts/get-kao.ts index 0b77ee285a..2168c5be88 100644 --- a/src/web/app/common/scripts/get-kao.js +++ b/src/web/app/common/scripts/get-kao.ts @@ -1,5 +1,5 @@ export default () => [ '(=^・・^=)', 'v(‘ω’)v', - '🐡( '-' 🐡 )フグパンチ!!!!' + '🐡( \'-\' 🐡 )フグパンチ!!!!' ][Math.floor(Math.random() * 3)]; diff --git a/src/web/app/common/scripts/get-median.ts b/src/web/app/common/scripts/get-median.ts new file mode 100644 index 0000000000..91a415d5b2 --- /dev/null +++ b/src/web/app/common/scripts/get-median.ts @@ -0,0 +1,11 @@ +/** + * 中央値を求めます + * @param samples サンプル + */ +export default function(samples) { + if (!samples.length) return 0; + const numbers = samples.slice(0).sort((a, b) => a - b); + const middle = Math.floor(numbers.length / 2); + const isEven = numbers.length % 2 === 0; + return isEven ? (numbers[middle] + numbers[middle - 1]) / 2 : numbers[middle]; +} diff --git a/src/web/app/common/scripts/is-promise.js b/src/web/app/common/scripts/is-promise.ts index 3b4cd70b49..3b4cd70b49 100644 --- a/src/web/app/common/scripts/is-promise.js +++ b/src/web/app/common/scripts/is-promise.ts diff --git a/src/web/app/common/scripts/loading.js b/src/web/app/common/scripts/loading.ts index c48e626648..c48e626648 100644 --- a/src/web/app/common/scripts/loading.js +++ b/src/web/app/common/scripts/loading.ts diff --git a/src/web/app/common/scripts/signout.js b/src/web/app/common/scripts/signout.js deleted file mode 100644 index 6c95cfbc9c..0000000000 --- a/src/web/app/common/scripts/signout.js +++ /dev/null @@ -1,7 +0,0 @@ -import CONFIG from './config'; - -export default () => { - localStorage.removeItem('me'); - document.cookie = `i=; domain=.${CONFIG.host}; expires=Thu, 01 Jan 1970 00:00:01 GMT;`; - location.href = '/'; -}; diff --git a/src/web/app/common/scripts/signout.ts b/src/web/app/common/scripts/signout.ts new file mode 100644 index 0000000000..2923196549 --- /dev/null +++ b/src/web/app/common/scripts/signout.ts @@ -0,0 +1,7 @@ +declare const _HOST_: string; + +export default () => { + localStorage.removeItem('me'); + document.cookie = `i=; domain=.${_HOST_}; expires=Thu, 01 Jan 1970 00:00:01 GMT;`; + location.href = '/'; +}; diff --git a/src/web/app/common/scripts/channel-stream.js b/src/web/app/common/scripts/streaming/channel-stream.ts index 17944dbe45..434b108b9e 100644 --- a/src/web/app/common/scripts/channel-stream.js +++ b/src/web/app/common/scripts/streaming/channel-stream.ts @@ -1,16 +1,12 @@ -'use strict'; - import Stream from './stream'; /** * Channel stream connection */ -class Connection extends Stream { +export default class Connection extends Stream { constructor(channelId) { super('channel', { channel: channelId }); } } - -export default Connection; diff --git a/src/web/app/common/scripts/streaming/drive-stream-manager.ts b/src/web/app/common/scripts/streaming/drive-stream-manager.ts new file mode 100644 index 0000000000..8acdd7cbba --- /dev/null +++ b/src/web/app/common/scripts/streaming/drive-stream-manager.ts @@ -0,0 +1,20 @@ +import StreamManager from './stream-manager'; +import Connection from './drive-stream'; + +export default class DriveStreamManager extends StreamManager<Connection> { + private me; + + constructor(me) { + super(); + + this.me = me; + } + + public getConnection() { + if (this.connection == null) { + this.connection = new Connection(this.me); + } + + return this.connection; + } +} diff --git a/src/web/app/common/scripts/streaming/drive-stream.ts b/src/web/app/common/scripts/streaming/drive-stream.ts new file mode 100644 index 0000000000..0da3f12554 --- /dev/null +++ b/src/web/app/common/scripts/streaming/drive-stream.ts @@ -0,0 +1,12 @@ +import Stream from './stream'; + +/** + * Drive stream connection + */ +export default class Connection extends Stream { + constructor(me) { + super('drive', { + i: me.token + }); + } +} diff --git a/src/web/app/common/scripts/streaming/home-stream-manager.ts b/src/web/app/common/scripts/streaming/home-stream-manager.ts new file mode 100644 index 0000000000..ad1dc870eb --- /dev/null +++ b/src/web/app/common/scripts/streaming/home-stream-manager.ts @@ -0,0 +1,20 @@ +import StreamManager from './stream-manager'; +import Connection from './home-stream'; + +export default class HomeStreamManager extends StreamManager<Connection> { + private me; + + constructor(me) { + super(); + + this.me = me; + } + + public getConnection() { + if (this.connection == null) { + this.connection = new Connection(this.me); + } + + return this.connection; + } +} diff --git a/src/web/app/common/scripts/home-stream.js b/src/web/app/common/scripts/streaming/home-stream.ts index de9ceb3b51..11ad754ef0 100644 --- a/src/web/app/common/scripts/home-stream.js +++ b/src/web/app/common/scripts/streaming/home-stream.ts @@ -1,12 +1,10 @@ -'use strict'; - import Stream from './stream'; -import signout from './signout'; +import signout from '../signout'; /** * Home stream connection */ -class Connection extends Stream { +export default class Connection extends Stream { constructor(me) { super('', { i: me.token @@ -17,13 +15,14 @@ class Connection extends Stream { this.send({ type: 'alive' }); }, 1000 * 60); + // 自分の情報が更新されたとき this.on('i_updated', me.update); + // トークンが再生成されたとき + // このままではAPIが利用できないので強制的にサインアウトさせる this.on('my_token_regenerated', () => { alert('%i18n:common.my-token-regenerated%'); signout(); }); } } - -export default Connection; diff --git a/src/web/app/common/scripts/streaming/messaging-index-stream-manager.ts b/src/web/app/common/scripts/streaming/messaging-index-stream-manager.ts new file mode 100644 index 0000000000..0f08b01481 --- /dev/null +++ b/src/web/app/common/scripts/streaming/messaging-index-stream-manager.ts @@ -0,0 +1,20 @@ +import StreamManager from './stream-manager'; +import Connection from './messaging-index-stream'; + +export default class MessagingIndexStreamManager extends StreamManager<Connection> { + private me; + + constructor(me) { + super(); + + this.me = me; + } + + public getConnection() { + if (this.connection == null) { + this.connection = new Connection(this.me); + } + + return this.connection; + } +} diff --git a/src/web/app/common/scripts/streaming/messaging-index-stream.ts b/src/web/app/common/scripts/streaming/messaging-index-stream.ts new file mode 100644 index 0000000000..8015c840b4 --- /dev/null +++ b/src/web/app/common/scripts/streaming/messaging-index-stream.ts @@ -0,0 +1,12 @@ +import Stream from './stream'; + +/** + * Messaging index stream connection + */ +export default class Connection extends Stream { + constructor(me) { + super('messaging-index', { + i: me.token + }); + } +} diff --git a/src/web/app/common/scripts/messaging-stream.js b/src/web/app/common/scripts/streaming/messaging-stream.ts index 261525d5f6..68dfc5ec09 100644 --- a/src/web/app/common/scripts/messaging-stream.js +++ b/src/web/app/common/scripts/streaming/messaging-stream.ts @@ -1,23 +1,19 @@ -'use strict'; - import Stream from './stream'; /** * Messaging stream connection */ -class Connection extends Stream { +export default class Connection extends Stream { constructor(me, otherparty) { super('messaging', { i: me.token, otherparty }); - this.on('_connected_', () => { + (this as any).on('_connected_', () => { this.send({ i: me.token }); }); } } - -export default Connection; diff --git a/src/web/app/common/scripts/streaming/requests-stream-manager.ts b/src/web/app/common/scripts/streaming/requests-stream-manager.ts new file mode 100644 index 0000000000..44db913e78 --- /dev/null +++ b/src/web/app/common/scripts/streaming/requests-stream-manager.ts @@ -0,0 +1,12 @@ +import StreamManager from './stream-manager'; +import Connection from './requests-stream'; + +export default class RequestsStreamManager extends StreamManager<Connection> { + public getConnection() { + if (this.connection == null) { + this.connection = new Connection(); + } + + return this.connection; + } +} diff --git a/src/web/app/common/scripts/streaming/requests-stream.ts b/src/web/app/common/scripts/streaming/requests-stream.ts new file mode 100644 index 0000000000..22ecea6c07 --- /dev/null +++ b/src/web/app/common/scripts/streaming/requests-stream.ts @@ -0,0 +1,10 @@ +import Stream from './stream'; + +/** + * Requests stream connection + */ +export default class Connection extends Stream { + constructor() { + super('requests'); + } +} diff --git a/src/web/app/common/scripts/streaming/server-stream-manager.ts b/src/web/app/common/scripts/streaming/server-stream-manager.ts new file mode 100644 index 0000000000..a170daebb9 --- /dev/null +++ b/src/web/app/common/scripts/streaming/server-stream-manager.ts @@ -0,0 +1,12 @@ +import StreamManager from './stream-manager'; +import Connection from './server-stream'; + +export default class ServerStreamManager extends StreamManager<Connection> { + public getConnection() { + if (this.connection == null) { + this.connection = new Connection(); + } + + return this.connection; + } +} diff --git a/src/web/app/common/scripts/server-stream.js b/src/web/app/common/scripts/streaming/server-stream.ts index a1c466b35d..b9e0684465 100644 --- a/src/web/app/common/scripts/server-stream.js +++ b/src/web/app/common/scripts/streaming/server-stream.ts @@ -1,14 +1,10 @@ -'use strict'; - import Stream from './stream'; /** * Server stream connection */ -class Connection extends Stream { +export default class Connection extends Stream { constructor() { super('server'); } } - -export default Connection; diff --git a/src/web/app/common/scripts/streaming/stream-manager.ts b/src/web/app/common/scripts/streaming/stream-manager.ts new file mode 100644 index 0000000000..5bb0dc701c --- /dev/null +++ b/src/web/app/common/scripts/streaming/stream-manager.ts @@ -0,0 +1,89 @@ +import { EventEmitter } from 'eventemitter3'; +import * as uuid from 'uuid'; +import Connection from './stream'; + +/** + * ストリーム接続を管理するクラス + * 複数の場所から同じストリームを利用する際、接続をまとめたりする + */ +export default abstract class StreamManager<T extends Connection> extends EventEmitter { + private _connection: T = null; + + private disposeTimerId: any; + + /** + * コネクションを必要としているユーザー + */ + private users = []; + + protected set connection(connection: T) { + this._connection = connection; + + if (this._connection == null) { + this.emit('disconnected'); + } else { + this.emit('connected', this._connection); + } + } + + protected get connection() { + return this._connection; + } + + /** + * コネクションを持っているか否か + */ + public get hasConnection() { + return this._connection != null; + } + + /** + * コネクションを要求します + */ + public abstract getConnection(): T; + + /** + * 現在接続しているコネクションを取得します + */ + public borrow() { + return this._connection; + } + + /** + * コネクションを要求するためのユーザーIDを発行します + */ + public use() { + // タイマー解除 + if (this.disposeTimerId) { + clearTimeout(this.disposeTimerId); + this.disposeTimerId = null; + } + + // ユーザーID生成 + const userId = uuid(); + + this.users.push(userId); + + return userId; + } + + /** + * コネクションを利用し終わってもう必要ないことを通知します + * @param userId use で発行したユーザーID + */ + public dispose(userId) { + this.users = this.users.filter(id => id != userId); + + // 誰もコネクションの利用者がいなくなったら + if (this.users.length == 0) { + // また直ぐに再利用される可能性があるので、一定時間待ち、 + // 新たな利用者が現れなければコネクションを切断する + this.disposeTimerId = setTimeout(() => { + this.disposeTimerId = null; + + this.connection.close(); + this.connection = null; + }, 3000); + } + } +} diff --git a/src/web/app/common/scripts/stream.js b/src/web/app/common/scripts/streaming/stream.ts index 981118b5de..770d77510f 100644 --- a/src/web/app/common/scripts/stream.js +++ b/src/web/app/common/scripts/streaming/stream.ts @@ -1,35 +1,38 @@ -'use strict'; +declare const _API_URL_: string; -const ReconnectingWebSocket = require('reconnecting-websocket'); -import * as riot from 'riot'; -import CONFIG from './config'; +import { EventEmitter } from 'eventemitter3'; +import * as ReconnectingWebsocket from 'reconnecting-websocket'; /** * Misskey stream connection */ -class Connection { - constructor(endpoint, params) { - // BIND ----------------------------------- +export default class Connection extends EventEmitter { + private state: string; + private buffer: any[]; + private socket: ReconnectingWebsocket; + + constructor(endpoint, params?) { + super(); + + //#region BIND this.onOpen = this.onOpen.bind(this); this.onClose = this.onClose.bind(this); this.onMessage = this.onMessage.bind(this); this.send = this.send.bind(this); this.close = this.close.bind(this); - // ---------------------------------------- - - riot.observable(this); + //#endregion this.state = 'initializing'; this.buffer = []; - const host = CONFIG.apiUrl.replace('http', 'ws'); + const host = _API_URL_.replace('http', 'ws'); const query = params ? Object.keys(params) .map(k => encodeURIComponent(k) + '=' + encodeURIComponent(params[k])) .join('&') : null; - this.socket = new ReconnectingWebSocket(`${host}/${endpoint}${query ? '?' + query : ''}`); + this.socket = new ReconnectingWebsocket(`${host}/${endpoint}${query ? '?' + query : ''}`); this.socket.addEventListener('open', this.onOpen); this.socket.addEventListener('close', this.onClose); this.socket.addEventListener('message', this.onMessage); @@ -37,11 +40,10 @@ class Connection { /** * Callback of when open connection - * @private */ - onOpen() { + private onOpen() { this.state = 'connected'; - this.trigger('_connected_'); + this.emit('_connected_'); // バッファーを処理 const _buffer = [].concat(this.buffer); // Shallow copy @@ -53,48 +55,42 @@ class Connection { /** * Callback of when close connection - * @private */ - onClose() { + private onClose() { this.state = 'reconnecting'; - this.trigger('_closed_'); + this.emit('_closed_'); } /** * Callback of when received a message from connection - * @private */ - onMessage(message) { + private onMessage(message) { try { const msg = JSON.parse(message.data); - if (msg.type) this.trigger(msg.type, msg.body); - } catch(e) { + if (msg.type) this.emit(msg.type, msg.body); + } catch (e) { // noop } } /** * Send a message to connection - * @public */ - send(message) { + public send(message) { // まだ接続が確立されていなかったらバッファリングして次に接続した時に送信する if (this.state != 'connected') { this.buffer.push(message); return; - }; + } this.socket.send(JSON.stringify(message)); } /** * Close this connection - * @public */ - close() { + public close() { this.socket.removeEventListener('open', this.onOpen); this.socket.removeEventListener('message', this.onMessage); } } - -export default Connection; diff --git a/src/web/app/common/scripts/text-compiler.js b/src/web/app/common/scripts/text-compiler.ts index 0a9b8022df..e0ea47df26 100644 --- a/src/web/app/common/scripts/text-compiler.js +++ b/src/web/app/common/scripts/text-compiler.ts @@ -1,6 +1,7 @@ +declare const _URL_: string; + import * as riot from 'riot'; -const pictograph = require('pictograph'); -import CONFIG from './config'; +import * as pictograph from 'pictograph'; const escape = text => text @@ -12,7 +13,7 @@ export default (tokens, shouldBreak) => { shouldBreak = true; } - const me = riot.mixin('i').me; + const me = (riot as any).mixin('i').me; let text = tokens.map(token => { switch (token.type) { @@ -26,7 +27,7 @@ export default (tokens, shouldBreak) => { case 'link': return `<a class="link" href="${escape(token.url)}" target="_blank" title="${escape(token.url)}">${escape(token.title)}</a>`; case 'mention': - return `<a href="${CONFIG.url + '/' + escape(token.username)}" target="_blank" data-user-preview="${token.content}" ${me && me.username == token.username ? 'data-is-me' : ''}>${token.content}</a>`; + return `<a href="${_URL_ + '/' + escape(token.username)}" target="_blank" data-user-preview="${token.content}" ${me && me.username == token.username ? 'data-is-me' : ''}>${token.content}</a>`; case 'hashtag': // TODO return `<a>${escape(token.content)}</a>`; case 'code': diff --git a/src/web/app/common/scripts/uuid.js b/src/web/app/common/scripts/uuid.js deleted file mode 100644 index ff016e18ad..0000000000 --- a/src/web/app/common/scripts/uuid.js +++ /dev/null @@ -1,18 +0,0 @@ -/** - * Generate a UUID - */ -export default () => { - let uuid = ''; - - for (let i = 0; i < 32; i++) { - const random = Math.random() * 16 | 0; - - if (i == 8 || i == 12 || i == 16 || i == 20) { - uuid += '-'; - } - - uuid += (i == 12 ? 4 : (i == 16 ? (random & 3 | 8) : random)).toString(16); - } - - return uuid; -}; diff --git a/src/web/app/common/tags/error.tag b/src/web/app/common/tags/error.tag index 62f4563e5c..51c2a6c13c 100644 --- a/src/web/app/common/tags/error.tag +++ b/src/web/app/common/tags/error.tag @@ -170,8 +170,6 @@ </style> <script> - import CONFIG from '../../common/scripts/config'; - this.on('mount', () => { this.update({ network: navigator.onLine @@ -193,7 +191,7 @@ }); // Check misskey server is available - fetch(`${CONFIG.apiUrl}/meta`).then(() => { + fetch(`${_API_URL_}/meta`).then(() => { this.update({ end: true, server: true diff --git a/src/web/app/common/tags/index.js b/src/web/app/common/tags/index.ts index 35a9f4586e..2f4e1181d4 100644 --- a/src/web/app/common/tags/index.js +++ b/src/web/app/common/tags/index.ts @@ -28,3 +28,4 @@ require('./reaction-picker.tag'); require('./reactions-viewer.tag'); require('./reaction-icon.tag'); require('./post-menu.tag'); +require('./nav-links.tag'); diff --git a/src/web/app/common/tags/introduction.tag b/src/web/app/common/tags/introduction.tag index fa1b1e247a..3256688d10 100644 --- a/src/web/app/common/tags/introduction.tag +++ b/src/web/app/common/tags/introduction.tag @@ -3,7 +3,7 @@ <h1>Misskeyとは?</h1> <p><ruby>Misskey<rt>みすきー</rt></ruby>は、<a href="http://syuilo.com" target="_blank">syuilo</a>が2014年くらいから<a href="https://github.com/syuilo/misskey" target="_blank">オープンソースで</a>開発・運営を行っている、ミニブログベースのSNSです。</p> <p>無料で誰でも利用でき、広告も掲載していません。</p> - <p><a href={ CONFIG.aboutUrl } target="_blank">もっと知りたい方はこちら</a></p> + <p><a href={ _ABOUT_URL_ } target="_blank">もっと知りたい方はこちら</a></p> </article> <style> :scope diff --git a/src/web/app/common/tags/messaging/index.tag b/src/web/app/common/tags/messaging/index.tag index 731c9da2c7..aebcec9d8d 100644 --- a/src/web/app/common/tags/messaging/index.tag +++ b/src/web/app/common/tags/messaging/index.tag @@ -1,5 +1,5 @@ -<mk-messaging> - <div class="search"> +<mk-messaging data-compact={ opts.compact }> + <div class="search" if={ !opts.compact }> <div class="form"> <label for="search-input"><i class="fa fa-search"></i></label> <input ref="search" type="search" oninput={ search } onkeydown={ onSearchKeydown } placeholder="%i18n:common.tags.mk-messaging.search-user%"/> @@ -31,11 +31,37 @@ </a> </virtual> </div> - <p class="no-history" if={ history.length == 0 }>%i18n:common.tags.mk-messaging.no-history%</p> + <p class="no-history" if={ !fetching && history.length == 0 }>%i18n:common.tags.mk-messaging.no-history%</p> + <p class="fetching" if={ fetching }><i class="fa fa-spinner fa-pulse fa-fw"></i>%i18n:common.loading%<mk-ellipsis/></p> <style> :scope display block + &[data-compact] + font-size 0.8em + + > .history + > a + &:last-child + border-bottom none + + &:not([data-is-me]):not([data-is-read]) + > div + background-image none + border-left solid 4px #3aa2dc + + > div + padding 16px + + > header + > mk-time + font-size 1em + + > .avatar + width 42px + height 42px + margin 0 12px 0 0 + > .search display block position -webkit-sticky @@ -75,7 +101,7 @@ > input margin 0 - padding 0 12px 0 38px + padding 0 0 0 38px width 100% font-size 1em line-height 38px @@ -272,6 +298,15 @@ color #999 font-weight 500 + > .fetching + margin 0 + padding 16px + text-align center + color #aaa + + > i + margin-right 4px + // TODO: element base media query @media (max-width 400px) > .search @@ -299,22 +334,61 @@ this.mixin('i'); this.mixin('api'); + this.mixin('messaging-index-stream'); + this.connection = this.messagingIndexStream.getConnection(); + this.connectionId = this.messagingIndexStream.use(); + this.searchResult = []; + this.history = []; + this.fetching = true; + + this.registerMessage = message => { + message.is_me = message.user_id == this.I.id; + message._click = () => { + this.trigger('navigate-user', message.is_me ? message.recipient : message.user); + }; + }; this.on('mount', () => { + this.connection.on('message', this.onMessage); + this.connection.on('read', this.onRead); + this.api('messaging/history').then(history => { - this.isLoading = false; + this.fetching = false; history.forEach(message => { - message.is_me = message.user_id == this.I.id - message._click = () => { - this.trigger('navigate-user', message.is_me ? message.recipient : message.user); - }; + this.registerMessage(message); }); this.history = history; this.update(); }); }); + this.on('unmount', () => { + this.connection.off('message', this.onMessage); + this.connection.off('read', this.onRead); + this.messagingIndexStream.dispose(this.connectionId); + }); + + this.onMessage = message => { + this.history = this.history.filter(m => !( + (m.recipient_id == message.recipient_id && m.user_id == message.user_id) || + (m.recipient_id == message.user_id && m.user_id == message.recipient_id))); + + this.registerMessage(message); + + this.history.unshift(message); + this.update(); + }; + + this.onRead = ids => { + ids.forEach(id => { + const found = this.history.find(m => m.id == id); + if (found) found.is_read = true; + }); + + this.update(); + }; + this.search = () => { const q = this.refs.search.value; if (q == '') { diff --git a/src/web/app/common/tags/messaging/message.tag b/src/web/app/common/tags/messaging/message.tag index d6db9070e2..ea1ea2310b 100644 --- a/src/web/app/common/tags/messaging/message.tag +++ b/src/web/app/common/tags/messaging/message.tag @@ -219,7 +219,7 @@ this.refs.text.innerHTML = compile(tokens); - this.refs.text.children.forEach(e => { + Array.from(this.refs.text.children).forEach(e => { if (e.tagName == 'MK-URL') riot.mount(e); }); diff --git a/src/web/app/common/tags/messaging/room.tag b/src/web/app/common/tags/messaging/room.tag index b1082e26be..a930327841 100644 --- a/src/web/app/common/tags/messaging/room.tag +++ b/src/web/app/common/tags/messaging/room.tag @@ -162,7 +162,7 @@ </style> <script> - import MessagingStreamConnection from '../../scripts/messaging-stream'; + import MessagingStreamConnection from '../../scripts/streaming/messaging-stream'; this.mixin('i'); this.mixin('api'); diff --git a/src/web/app/common/tags/nav-links.tag b/src/web/app/common/tags/nav-links.tag new file mode 100644 index 0000000000..6043f128fa --- /dev/null +++ b/src/web/app/common/tags/nav-links.tag @@ -0,0 +1,7 @@ +<mk-nav-links> + <a href={ _ABOUT_URL_ }>%i18n:common.tags.mk-nav-links.about%</a><i>・</i><a href={ _STATS_URL_ }>%i18n:common.tags.mk-nav-links.stats%</a><i>・</i><a href={ _STATUS_URL_ }>%i18n:common.tags.mk-nav-links.status%</a><i>・</i><a href="http://zawazawa.jp/misskey/">%i18n:common.tags.mk-nav-links.wiki%</a><i>・</i><a href="https://github.com/syuilo/misskey/blob/master/DONORS.md">%i18n:common.tags.mk-nav-links.donors%</a><i>・</i><a href="https://github.com/syuilo/misskey">%i18n:common.tags.mk-nav-links.repository%</a><i>・</i><a href={ _DEV_URL_ }>%i18n:common.tags.mk-nav-links.develop%</a><i>・</i><a href="https://twitter.com/misskey_xyz" target="_blank">Follow us on <i class="fa fa-twitter"></i></a> + <style> + :scope + display inline + </style> +</mk-nav-links> diff --git a/src/web/app/common/tags/raw.tag b/src/web/app/common/tags/raw.tag index e1285694e4..adc6de5a3b 100644 --- a/src/web/app/common/tags/raw.tag +++ b/src/web/app/common/tags/raw.tag @@ -5,5 +5,9 @@ </style> <script> this.root.innerHTML = this.opts.content; + + this.on('updated', () => { + this.root.innerHTML = this.opts.content; + }); </script> </mk-raw> diff --git a/src/web/app/common/tags/signin-history.tag b/src/web/app/common/tags/signin-history.tag index 9c96746249..b9bd859851 100644 --- a/src/web/app/common/tags/signin-history.tag +++ b/src/web/app/common/tags/signin-history.tag @@ -50,7 +50,10 @@ <script> this.mixin('i'); this.mixin('api'); + this.mixin('stream'); + this.connection = this.stream.getConnection(); + this.connectionId = this.stream.use(); this.history = []; this.fetching = true; @@ -63,11 +66,12 @@ }); }); - this.stream.on('signin', this.onSignin); + this.connection.on('signin', this.onSignin); }); this.on('unmount', () => { - this.stream.off('signin', this.onSignin); + this.connection.off('signin', this.onSignin); + this.stream.dispose(this.connectionId); }); this.onSignin = signin => { diff --git a/src/web/app/common/tags/signup.tag b/src/web/app/common/tags/signup.tag index 17de0347f5..6fec46ff31 100644 --- a/src/web/app/common/tags/signup.tag +++ b/src/web/app/common/tags/signup.tag @@ -3,7 +3,7 @@ <label class="username"> <p class="caption"><i class="fa fa-at"></i>%i18n:common.tags.mk-signup.username%</p> <input ref="username" type="text" pattern="^[a-zA-Z0-9-]{3,20}$" placeholder="a~z、A~Z、0~9、-" autocomplete="off" required="required" onkeyup={ onChangeUsername }/> - <p class="profile-page-url-preview" if={ refs.username.value != '' && username-state != 'invalidFormat' && username-state != 'minRange' && username-state != 'maxRange' }>{ CONFIG.url + '/' + refs.username.value }</p> + <p class="profile-page-url-preview" if={ refs.username.value != '' && username-state != 'invalidFormat' && username-state != 'minRange' && username-state != 'maxRange' }>{ _URL_ + '/' + refs.username.value }</p> <p class="info" if={ usernameState == 'wait' } style="color:#999"><i class="fa fa-fw fa-spinner fa-pulse"></i>%i18n:common.tags.mk-signup.checking%</p> <p class="info" if={ usernameState == 'ok' } style="color:#3CB7B5"><i class="fa fa-fw fa-check"></i>%i18n:common.tags.mk-signup.available%</p> <p class="info" if={ usernameState == 'unavailable' } style="color:#FF1161"><i class="fa fa-fw fa-exclamation-triangle"></i>%i18n:common.tags.mk-signup.unavailable%</p> @@ -30,7 +30,7 @@ </label> <label class="recaptcha"> <p class="caption"><i class="fa fa-toggle-on" if={ recaptchaed }></i><i class="fa fa-toggle-off" if={ !recaptchaed }></i>%i18n:common.tags.mk-signup.recaptcha%</p> - <div if={ recaptcha } class="g-recaptcha" data-callback="onRecaptchaed" data-expired-callback="onRecaptchaExpired" data-sitekey={ recaptcha.siteKey }></div> + <div if={ recaptcha } class="g-recaptcha" data-callback="onRecaptchaed" data-expired-callback="onRecaptchaExpired" data-sitekey={ recaptcha.site_key }></div> </label> <label class="agree-tou"> <input name="agree-tou" type="checkbox" autocomplete="off" required="required"/> @@ -193,20 +193,16 @@ }; this.on('mount', () => { - fetch('/config.json').then(res => { - res.json().then(conf => { - this.update({ - recaptcha: { - siteKey: conf.recaptcha.siteKey - } - }); - - const head = document.getElementsByTagName('head')[0]; - const script = document.createElement('script'); - script.setAttribute('src', 'https://www.google.com/recaptcha/api.js'); - head.appendChild(script); - }); + this.update({ + recaptcha: { + site_key: _RECAPTCHA_SITEKEY_ + } }); + + const head = document.getElementsByTagName('head')[0]; + const script = document.createElement('script'); + script.setAttribute('src', 'https://www.google.com/recaptcha/api.js'); + head.appendChild(script); }); this.onChangeUsername = () => { diff --git a/src/web/app/common/tags/stream-indicator.tag b/src/web/app/common/tags/stream-indicator.tag index ea1c437035..0d74985c88 100644 --- a/src/web/app/common/tags/stream-indicator.tag +++ b/src/web/app/common/tags/stream-indicator.tag @@ -1,13 +1,13 @@ <mk-stream-indicator> - <p if={ stream.state == 'initializing' }> + <p if={ connection.state == 'initializing' }> <i class="fa fa-spinner fa-spin"></i> <span>%i18n:common.tags.mk-stream-indicator.connecting%<mk-ellipsis/></span> </p> - <p if={ stream.state == 'reconnecting' }> + <p if={ connection.state == 'reconnecting' }> <i class="fa fa-spinner fa-spin"></i> <span>%i18n:common.tags.mk-stream-indicator.reconnecting%<mk-ellipsis/></span> </p> - <p if={ stream.state == 'connected' }> + <p if={ connection.state == 'connected' }> <i class="fa fa-check"></i> <span>%i18n:common.tags.mk-stream-indicator.connected%</span> </p> @@ -38,34 +38,41 @@ import anime from 'animejs'; this.mixin('i'); + this.mixin('stream'); + this.connection = this.stream.getConnection(); + this.connectionId = this.stream.use(); this.on('before-mount', () => { - if (this.stream.state == 'connected') { + if (this.connection.state == 'connected') { this.root.style.opacity = 0; } - }); - this.stream.on('_connected_', () => { - this.update(); - setTimeout(() => { + this.connection.on('_connected_', () => { + this.update(); + setTimeout(() => { + anime({ + targets: this.root, + opacity: 0, + easing: 'linear', + duration: 200 + }); + }, 1000); + }); + + this.connection.on('_closed_', () => { + this.update(); anime({ targets: this.root, - opacity: 0, + opacity: 1, easing: 'linear', - duration: 200 + duration: 100 }); - }, 1000); + }); }); - this.stream.on('_closed_', () => { - this.update(); - anime({ - targets: this.root, - opacity: 1, - easing: 'linear', - duration: 100 - }); + this.on('unmount', () => { + this.stream.dispose(this.connectionId); }); </script> </mk-stream-indicator> diff --git a/src/web/app/common/tags/twitter-setting.tag b/src/web/app/common/tags/twitter-setting.tag index 470426700c..3b70505ba2 100644 --- a/src/web/app/common/tags/twitter-setting.tag +++ b/src/web/app/common/tags/twitter-setting.tag @@ -1,10 +1,10 @@ <mk-twitter-setting> - <p>%i18n:common.tags.mk-twitter-setting.description%<a href={ CONFIG.aboutUrl + '/link-to-twitter' } target="_blank">%i18n:common.tags.mk-twitter-setting.detail%</a></p> + <p>%i18n:common.tags.mk-twitter-setting.description%<a href={ _ABOUT_URL_ + '/link-to-twitter' } target="_blank">%i18n:common.tags.mk-twitter-setting.detail%</a></p> <p class="account" if={ I.twitter } title={ 'Twitter ID: ' + I.twitter.user_id }>%i18n:common.tags.mk-twitter-setting.connected-to%: <a href={ 'https://twitter.com/' + I.twitter.screen_name } target="_blank">@{ I.twitter.screen_name }</a></p> <p> - <a href={ CONFIG.apiUrl + '/connect/twitter' } target="_blank" onclick={ connect }>{ I.twitter ? '%i18n:common.tags.mk-twitter-setting.reconnect%' : '%i18n:common.tags.mk-twitter-setting.connect%' }</a> + <a href={ _API_URL_ + '/connect/twitter' } target="_blank" onclick={ connect }>{ I.twitter ? '%i18n:common.tags.mk-twitter-setting.reconnect%' : '%i18n:common.tags.mk-twitter-setting.connect%' }</a> <span if={ I.twitter }> or </span> - <a href={ CONFIG.apiUrl + '/disconnect/twitter' } target="_blank" if={ I.twitter } onclick={ disconnect }>%i18n:common.tags.mk-twitter-setting.disconnect%</a> + <a href={ _API_URL_ + '/disconnect/twitter' } target="_blank" if={ I.twitter } onclick={ disconnect }>%i18n:common.tags.mk-twitter-setting.disconnect%</a> </p> <p class="id" if={ I.twitter }>Twitter ID: { I.twitter.user_id }</p> <style> @@ -25,8 +25,6 @@ color #8899a6 </style> <script> - import CONFIG from '../scripts/config'; - this.mixin('i'); this.form = null; @@ -47,7 +45,7 @@ this.connect = e => { e.preventDefault(); - this.form = window.open(CONFIG.apiUrl + '/connect/twitter', + this.form = window.open(_API_URL_ + '/connect/twitter', 'twitter_connect_window', 'height=570,width=520'); return false; @@ -55,7 +53,7 @@ this.disconnect = e => { e.preventDefault(); - window.open(CONFIG.apiUrl + '/disconnect/twitter', + window.open(_API_URL_ + '/disconnect/twitter', 'twitter_disconnect_window', 'height=570,width=520'); return false; diff --git a/src/web/app/common/tags/uploader.tag b/src/web/app/common/tags/uploader.tag index da97957a2c..1453391696 100644 --- a/src/web/app/common/tags/uploader.tag +++ b/src/web/app/common/tags/uploader.tag @@ -172,7 +172,7 @@ if (folder) data.append('folder_id', folder); const xhr = new XMLHttpRequest(); - xhr.open('POST', this.CONFIG.apiUrl + '/drive/files/create', true); + xhr.open('POST', _API_URL_ + '/drive/files/create', true); xhr.onload = e => { const driveFile = JSON.parse(e.target.response); diff --git a/src/web/app/desktop/assets/grid.svg b/src/web/app/desktop/assets/grid.svg new file mode 100644 index 0000000000..d1d72cd8ce --- /dev/null +++ b/src/web/app/desktop/assets/grid.svg @@ -0,0 +1,150 @@ +<?xml version="1.0" encoding="UTF-8" standalone="no"?> +<!-- Created with Inkscape (http://www.inkscape.org/) --> + +<svg + xmlns:dc="http://purl.org/dc/elements/1.1/" + xmlns:cc="http://creativecommons.org/ns#" + xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" + xmlns:svg="http://www.w3.org/2000/svg" + xmlns="http://www.w3.org/2000/svg" + xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" + xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" + width="32" + height="32" + viewBox="0 0 8.4666665 8.4666669" + version="1.1" + id="svg8" + inkscape:version="0.92.1 r15371" + sodipodi:docname="grid.svg"> + <defs + id="defs2" /> + <sodipodi:namedview + id="base" + pagecolor="#ffffff" + bordercolor="#666666" + borderopacity="1.0" + inkscape:pageopacity="0.0" + inkscape:pageshadow="2" + inkscape:zoom="22.4" + inkscape:cx="14.687499" + inkscape:cy="14.558219" + inkscape:document-units="px" + inkscape:current-layer="layer1" + showgrid="true" + units="px" + showguides="true" + inkscape:window-width="1920" + inkscape:window-height="1017" + inkscape:window-x="-8" + inkscape:window-y="1072" + inkscape:window-maximized="1"> + <inkscape:grid + type="xygrid" + id="grid3680" + empspacing="8" + empcolor="#ff3fff" + empopacity="0.41176471" /> + </sodipodi:namedview> + <metadata + id="metadata5"> + <rdf:RDF> + <cc:Work + rdf:about=""> + <dc:format>image/svg+xml</dc:format> + <dc:type + rdf:resource="http://purl.org/dc/dcmitype/StillImage" /> + <dc:title></dc:title> + </cc:Work> + </rdf:RDF> + </metadata> + <g + inkscape:label="レイヤー 1" + inkscape:groupmode="layer" + id="layer1" + transform="translate(0,-288.53331)"> + <path + style="fill:#000000;fill-opacity:0.05;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" + d="m 0,296.99998 v -8.46667 h 8.4666666 l 10e-8,0.26458 H 0.26458333 l 0,8.20209 z" + id="path3684" + inkscape:connector-curvature="0" + sodipodi:nodetypes="ccccccc" /> + <path + style="fill:#000000;fill-opacity:0.05;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" + d="m 4.2333334,292.23748 h 0.2645833 v 0.52916 h 0.5291667 l 0,0.26459 H 4.4979167 v 0.52917 H 4.2333334 v -0.52917 H 3.7041667 l 0,-0.26459 h 0.5291667 z" + id="path4491" + inkscape:connector-curvature="0" + sodipodi:nodetypes="ccccccccccccc" /> + <path + style="fill:#000000;fill-opacity:0.05;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" + d="m 3.4395833,292.76664 0,0.26459 H 2.38125 l 0,-0.26459 z" + id="path4493" + inkscape:connector-curvature="0" + sodipodi:nodetypes="ccccc" /> + <path + style="fill:#000000;fill-opacity:0.05;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" + d="m 6.3499999,292.76664 10e-8,0.26459 H 5.2916667 l -1e-7,-0.26459 z" + id="path4493-2" + inkscape:connector-curvature="0" + sodipodi:nodetypes="ccccc" /> + <path + style="fill:#000000;fill-opacity:0.05;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" + d="m 7.6729167,292.76664 v 0.26459 H 6.6145834 v -0.26459 z" + id="path4493-6" + inkscape:connector-curvature="0" + sodipodi:nodetypes="ccccc" /> + <path + style="fill:#000000;fill-opacity:0.05;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" + d="m 2.1166666,292.76664 1e-7,0.26459 H 1.0583334 l -1e-7,-0.26459 z" + id="path4493-1" + inkscape:connector-curvature="0" + sodipodi:nodetypes="ccccc" /> + <path + style="fill:#000000;fill-opacity:0.05;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" + d="m 4.2333333,291.97289 0.2645834,0 v -1.05833 l -0.2645834,0 z" + id="path4522" + inkscape:connector-curvature="0" + sodipodi:nodetypes="ccccc" /> + <path + style="fill:#000000;fill-opacity:0.05;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" + d="m 4.2333334,290.64997 0.2645833,1e-5 v -1.05833 l -0.2645833,-1e-5 z" + id="path4522-7" + inkscape:connector-curvature="0" + sodipodi:nodetypes="ccccc" /> + <path + style="fill:#000000;fill-opacity:0.05;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" + d="m 4.2333334,294.88331 h 0.2645833 v -1.05833 H 4.2333334 Z" + id="path4522-5" + inkscape:connector-curvature="0" + sodipodi:nodetypes="ccccc" /> + <path + style="fill:#000000;fill-opacity:0.05;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" + d="m 4.2333333,296.20622 h 0.2645833 v -1.05833 H 4.2333333 Z" + id="path4522-74" + inkscape:connector-curvature="0" + sodipodi:nodetypes="ccccc" /> + <path + style="fill:#000000;fill-opacity:0.05;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" + d="m 4.2333334,289.32706 0.2645834,10e-6 -10e-8,-0.52918 -0.2645834,-10e-6 z" + id="path4522-7-4" + inkscape:connector-curvature="0" + sodipodi:nodetypes="ccccc" /> + <path + style="fill:#000000;fill-opacity:0.05;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" + d="m 4.2333332,296.99998 h 0.2645835 l 0,-0.52917 H 4.2333333 Z" + id="path4522-7-4-4" + inkscape:connector-curvature="0" + sodipodi:nodetypes="ccccc" /> + <path + style="fill:#000000;fill-opacity:0.05;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" + d="m 0.79375,292.76664 -3e-8,0.26459 -0.52916667,0 3e-8,-0.26459 z" + id="path4493-1-7" + inkscape:connector-curvature="0" + sodipodi:nodetypes="ccccc" /> + <path + style="fill:#000000;fill-opacity:0.05;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" + d="m 8.4666667,292.76664 v 0.26459 l -0.5291667,0 v -0.26459 z" + id="path4493-1-7-2" + inkscape:connector-curvature="0" + sodipodi:nodetypes="ccccc" /> + </g> +</svg> diff --git a/src/web/app/desktop/assets/index.jpg b/src/web/app/desktop/assets/index.jpg Binary files differnew file mode 100644 index 0000000000..10c412efe2 --- /dev/null +++ b/src/web/app/desktop/assets/index.jpg diff --git a/src/web/app/desktop/mixins/index.js b/src/web/app/desktop/mixins/index.ts index a7a3eb9485..e0c94ec5ee 100644 --- a/src/web/app/desktop/mixins/index.js +++ b/src/web/app/desktop/mixins/index.ts @@ -1 +1,2 @@ require('./user-preview'); +require('./widget'); diff --git a/src/web/app/desktop/mixins/user-preview.js b/src/web/app/desktop/mixins/user-preview.ts index 3f483beb3a..614de72bea 100644 --- a/src/web/app/desktop/mixins/user-preview.js +++ b/src/web/app/desktop/mixins/user-preview.ts @@ -52,7 +52,7 @@ function attach(el) { clearTimeout(showTimer); hideTimer = setTimeout(close, 500); }); - tag = riot.mount(document.body.appendChild(preview), { + tag = (riot as any).mount(document.body.appendChild(preview), { user: user })[0]; }; diff --git a/src/web/app/desktop/mixins/widget.ts b/src/web/app/desktop/mixins/widget.ts new file mode 100644 index 0000000000..04131cd8f0 --- /dev/null +++ b/src/web/app/desktop/mixins/widget.ts @@ -0,0 +1,31 @@ +import * as riot from 'riot'; + +// ミックスインにオプションを渡せないのアレ +// SEE: https://github.com/riot/riot/issues/2434 + +(riot as any).mixin('widget', { + init: function() { + this.mixin('i'); + this.mixin('api'); + + this.id = this.opts.id; + this.place = this.opts.place; + + if (this.data) { + Object.keys(this.data).forEach(prop => { + this.data[prop] = this.opts.data.hasOwnProperty(prop) ? this.opts.data[prop] : this.data[prop]; + }); + } + }, + + save: function() { + this.update(); + this.api('i/update_home', { + id: this.id, + data: this.data + }).then(() => { + this.I.client_settings.home.find(w => w.id == this.id).data = this.data; + this.I.update(); + }); + } +}); diff --git a/src/web/app/desktop/router.js b/src/web/app/desktop/router.ts index 977e3fa9a6..27b63ab2ef 100644 --- a/src/web/app/desktop/router.js +++ b/src/web/app/desktop/router.ts @@ -3,28 +3,37 @@ */ import * as riot from 'riot'; -const route = require('page'); +import * as route from 'page'; +import MiOS from '../common/mios'; let page = null; -export default me => { - 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); +export default (mios: MiOS) => { + route('/', index); + route('/selectdrive', selectDrive); + route('/i/customize-home', customizeHome); + route('/i/drive', drive); + route('/i/drive/folder/:folder', drive); + route('/i/messaging/:user', messaging); + 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(); + mios.isSignedin ? home() : entrance(); } function home() { mount(document.createElement('mk-home-page')); } + function customizeHome() { + mount(document.createElement('mk-home-customize-page')); + } + function entrance() { mount(document.createElement('mk-entrance')); document.documentElement.setAttribute('data-page', 'entrance'); @@ -59,20 +68,31 @@ export default me => { mount(document.createElement('mk-selectdrive-page')); } + function drive(ctx) { + const el = document.createElement('mk-drive-page'); + if (ctx.params.folder) el.setAttribute('folder', ctx.params.folder); + mount(el); + } + + function messaging(ctx) { + const el = document.createElement('mk-messaging-room-page'); + el.setAttribute('user', ctx.params.user); + mount(el); + } + function notFound() { mount(document.createElement('mk-not-found')); } - riot.mixin('page', { + (riot as any).mixin('page', { page: route }); // EXEC - route(); + (route as any)(); }; 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/script.js b/src/web/app/desktop/script.js deleted file mode 100644 index 46a7fce700..0000000000 --- a/src/web/app/desktop/script.js +++ /dev/null @@ -1,91 +0,0 @@ -/** - * Desktop Client - */ - -// Style -import './style.styl'; - -require('./tags'); -require('./mixins'); -import * as riot from 'riot'; -import init from '../init'; -import route from './router'; -import fuckAdBlock from './scripts/fuck-ad-block'; -import getPostSummary from '../../../common/get-post-summary.ts'; - -/** - * init - */ -init(async (me, stream) => { - /** - * Fuck AD Block - */ - fuckAdBlock(); - - /** - * Init Notification - */ - if ('Notification' in window) { - // 許可を得ていなかったらリクエスト - if (Notification.permission == 'default') { - await Notification.requestPermission(); - } - - if (Notification.permission == 'granted') { - registerNotifications(stream); - } - } - - // Start routing - route(me); -}); - -function registerNotifications(stream) { - if (stream == null) return; - - stream.on('drive_file_created', file => { - const n = new Notification('ファイルがアップロードされました', { - body: file.name, - icon: file.url + '?thumbnail&size=64' - }); - setTimeout(n.close.bind(n), 5000); - }); - - stream.on('mention', post => { - const n = new Notification(`${post.user.name}さんから:`, { - body: getPostSummary(post), - icon: post.user.avatar_url + '?thumbnail&size=64' - }); - setTimeout(n.close.bind(n), 6000); - }); - - stream.on('reply', post => { - const n = new Notification(`${post.user.name}さんから返信:`, { - body: getPostSummary(post), - icon: post.user.avatar_url + '?thumbnail&size=64' - }); - setTimeout(n.close.bind(n), 6000); - }); - - stream.on('quote', post => { - const n = new Notification(`${post.user.name}さんが引用:`, { - body: getPostSummary(post), - icon: post.user.avatar_url + '?thumbnail&size=64' - }); - setTimeout(n.close.bind(n), 6000); - }); - - stream.on('unread_messaging_message', message => { - const n = new Notification(`${message.user.name}さんからメッセージ:`, { - body: message.text, // TODO: getMessagingMessageSummary(message), - icon: message.user.avatar_url + '?thumbnail&size=64' - }); - n.onclick = () => { - n.close(); - riot.mount(document.body.appendChild(document.createElement('mk-messaging-room-window')), { - user: message.user - }); - }; - setTimeout(n.close.bind(n), 7000); - }); -} diff --git a/src/web/app/desktop/script.ts b/src/web/app/desktop/script.ts new file mode 100644 index 0000000000..b06cb180e1 --- /dev/null +++ b/src/web/app/desktop/script.ts @@ -0,0 +1,108 @@ +/** + * Desktop Client + */ + +// Style +import './style.styl'; + +require('./tags'); +require('./mixins'); +import * as riot from 'riot'; +import init from '../init'; +import route from './router'; +import fuckAdBlock from './scripts/fuck-ad-block'; +import MiOS from '../common/mios'; +import HomeStreamManager from '../common/scripts/streaming/home-stream-manager'; +import composeNotification from '../common/scripts/compose-notification'; + +/** + * init + */ +init(async (mios: MiOS) => { + /** + * Fuck AD Block + */ + fuckAdBlock(); + + /** + * Init Notification + */ + if ('Notification' in window) { + // 許可を得ていなかったらリクエスト + if ((Notification as any).permission == 'default') { + await Notification.requestPermission(); + } + + if ((Notification as any).permission == 'granted') { + registerNotifications(mios.stream); + } + } + + // Start routing + route(mios); +}, true); + +function registerNotifications(stream: HomeStreamManager) { + if (stream == null) return; + + if (stream.hasConnection) { + attach(stream.borrow()); + } + + stream.on('connected', connection => { + attach(connection); + }); + + function attach(connection) { + connection.on('drive_file_created', file => { + const _n = composeNotification('drive_file_created', file); + const n = new Notification(_n.title, { + body: _n.body, + icon: _n.icon + }); + setTimeout(n.close.bind(n), 5000); + }); + + connection.on('mention', post => { + const _n = composeNotification('mention', post); + const n = new Notification(_n.title, { + body: _n.body, + icon: _n.icon + }); + setTimeout(n.close.bind(n), 6000); + }); + + connection.on('reply', post => { + const _n = composeNotification('reply', post); + const n = new Notification(_n.title, { + body: _n.body, + icon: _n.icon + }); + setTimeout(n.close.bind(n), 6000); + }); + + connection.on('quote', post => { + const _n = composeNotification('quote', post); + const n = new Notification(_n.title, { + body: _n.body, + icon: _n.icon + }); + setTimeout(n.close.bind(n), 6000); + }); + + connection.on('unread_messaging_message', message => { + const _n = composeNotification('unread_messaging_message', message); + const n = new Notification(_n.title, { + body: _n.body, + icon: _n.icon + }); + n.onclick = () => { + n.close(); + (riot as any).mount(document.body.appendChild(document.createElement('mk-messaging-room-window')), { + user: message.user + }); + }; + setTimeout(n.close.bind(n), 7000); + }); + } +} diff --git a/src/web/app/desktop/scripts/autocomplete.js b/src/web/app/desktop/scripts/autocomplete.ts index 8ca516e2a9..9df7aae08d 100644 --- a/src/web/app/desktop/scripts/autocomplete.js +++ b/src/web/app/desktop/scripts/autocomplete.ts @@ -1,10 +1,12 @@ -const getCaretCoordinates = require('textarea-caret'); +import getCaretCoordinates = require('textarea-caret'); import * as riot from 'riot'; /** * オートコンプリートを管理するクラス。 */ class Autocomplete { + private suggestion: any; + private textarea: any; /** * 対象のテキストエリアを与えてインスタンスを初期化します。 @@ -23,22 +25,22 @@ class Autocomplete { /** * このインスタンスにあるテキストエリアの入力のキャプチャを開始します。 */ - attach() { + public attach() { this.textarea.addEventListener('input', this.onInput); } /** * このインスタンスにあるテキストエリアの入力のキャプチャを解除します。 */ - detach() { + public detach() { this.textarea.removeEventListener('input', this.onInput); this.close(); } /** - * [Private] テキスト入力時 + * テキスト入力時 */ - onInput() { + private onInput() { this.close(); const caret = this.textarea.selectionStart; @@ -56,9 +58,9 @@ class Autocomplete { } /** - * [Private] サジェストを提示します。 + * サジェストを提示します。 */ - open(type, q) { + private open(type, q) { // 既に開いているサジェストは閉じる this.close(); @@ -81,7 +83,7 @@ class Autocomplete { const el = document.body.appendChild(tag); // マウント - this.suggestion = riot.mount(el, { + this.suggestion = (riot as any).mount(el, { textarea: this.textarea, complete: this.complete, close: this.close, @@ -91,9 +93,9 @@ class Autocomplete { } /** - * [Private] サジェストを閉じます。 + * サジェストを閉じます。 */ - close() { + private close() { if (this.suggestion == null) return; this.suggestion.unmount(); @@ -103,9 +105,9 @@ class Autocomplete { } /** - * [Private] オートコンプリートする + * オートコンプリートする */ - complete(user) { + private complete(user) { this.close(); const value = user.username; diff --git a/src/web/app/desktop/scripts/dialog.js b/src/web/app/desktop/scripts/dialog.ts index c502d3fcb8..816ba4b5f5 100644 --- a/src/web/app/desktop/scripts/dialog.js +++ b/src/web/app/desktop/scripts/dialog.ts @@ -1,9 +1,9 @@ import * as riot from 'riot'; -export default (title, text, buttons, canThrough, onThrough) => { +export default (title, text, buttons, canThrough?, onThrough?) => { const dialog = document.body.appendChild(document.createElement('mk-dialog')); const controller = riot.observable(); - riot.mount(dialog, { + (riot as any).mount(dialog, { controller: controller, title: title, text: text, diff --git a/src/web/app/desktop/scripts/fuck-ad-block.js b/src/web/app/desktop/scripts/fuck-ad-block.ts index ccfc43ce6e..8be3c80ea1 100644 --- a/src/web/app/desktop/scripts/fuck-ad-block.js +++ b/src/web/app/desktop/scripts/fuck-ad-block.ts @@ -1,6 +1,8 @@ require('fuckadblock'); import dialog from './dialog'; +declare const fuckAdBlock: any; + export default () => { if (fuckAdBlock === undefined) { adBlockDetected(); diff --git a/src/web/app/desktop/scripts/input-dialog.js b/src/web/app/desktop/scripts/input-dialog.ts index 954fabfb67..b06d011c6b 100644 --- a/src/web/app/desktop/scripts/input-dialog.js +++ b/src/web/app/desktop/scripts/input-dialog.ts @@ -2,7 +2,7 @@ import * as riot from 'riot'; export default (title, placeholder, defaultValue, onOk, onCancel) => { const dialog = document.body.appendChild(document.createElement('mk-input-dialog')); - return riot.mount(dialog, { + return (riot as any).mount(dialog, { title: title, placeholder: placeholder, 'default': defaultValue, diff --git a/src/web/app/desktop/scripts/not-implemented-exception.js b/src/web/app/desktop/scripts/not-implemented-exception.ts index dd00c7662f..dd00c7662f 100644 --- a/src/web/app/desktop/scripts/not-implemented-exception.js +++ b/src/web/app/desktop/scripts/not-implemented-exception.ts diff --git a/src/web/app/desktop/scripts/notify.js b/src/web/app/desktop/scripts/notify.ts index e58a8e4d36..2e6cbdeed8 100644 --- a/src/web/app/desktop/scripts/notify.js +++ b/src/web/app/desktop/scripts/notify.ts @@ -2,7 +2,7 @@ import * as riot from 'riot'; export default message => { const notification = document.body.appendChild(document.createElement('mk-ui-notification')); - riot.mount(notification, { + (riot as any).mount(notification, { message: message }); }; diff --git a/src/web/app/desktop/scripts/password-dialog.js b/src/web/app/desktop/scripts/password-dialog.ts index 2bdc93e421..39d7f3db7a 100644 --- a/src/web/app/desktop/scripts/password-dialog.js +++ b/src/web/app/desktop/scripts/password-dialog.ts @@ -2,7 +2,7 @@ import * as riot from 'riot'; export default (title, onOk, onCancel) => { const dialog = document.body.appendChild(document.createElement('mk-input-dialog')); - return riot.mount(dialog, { + return (riot as any).mount(dialog, { title: title, type: 'password', onOk: onOk, diff --git a/src/web/app/desktop/scripts/scroll-follower.ts b/src/web/app/desktop/scripts/scroll-follower.ts new file mode 100644 index 0000000000..05072958ce --- /dev/null +++ b/src/web/app/desktop/scripts/scroll-follower.ts @@ -0,0 +1,61 @@ +/** + * 要素をスクロールに追従させる + */ +export default class ScrollFollower { + private follower: Element; + private containerTop: number; + private topPadding: number; + + constructor(follower: Element, topPadding: number) { + //#region + this.follow = this.follow.bind(this); + //#endregion + + this.follower = follower; + this.containerTop = follower.getBoundingClientRect().top; + this.topPadding = topPadding; + + window.addEventListener('scroll', this.follow); + window.addEventListener('resize', this.follow); + } + + /** + * 追従解除 + */ + public dispose() { + window.removeEventListener('scroll', this.follow); + window.removeEventListener('resize', this.follow); + } + + private follow() { + const windowBottom = window.scrollY + window.innerHeight; + const windowTop = window.scrollY + this.topPadding; + + const rect = this.follower.getBoundingClientRect(); + const followerBottom = (rect.top + window.scrollY) + rect.height; + const screenHeight = window.innerHeight - this.topPadding; + + // スクロールの上部(+余白)がフォロワーコンテナの上部よりも上方にある + if (window.scrollY + this.topPadding < this.containerTop) { + // フォロワーをコンテナの最上部に合わせる + (this.follower.parentNode as any).style.marginTop = '0px'; + return; + } + + // スクロールの下部がフォロワーの下部よりも下方にある かつ 表示領域の縦幅がフォロワーの縦幅よりも狭い + if (windowBottom > followerBottom && rect.height > screenHeight) { + // フォロワーの下部をスクロール下部に合わせる + const top = (windowBottom - rect.height) - this.containerTop; + (this.follower.parentNode as any).style.marginTop = `${top}px`; + return; + } + + // スクロールの上部(+余白)がフォロワーの上部よりも上方にある または 表示領域の縦幅がフォロワーの縦幅よりも広い + if (windowTop < rect.top + window.scrollY || rect.height < screenHeight) { + // フォロワーの上部をスクロール上部(+余白)に合わせる + const top = windowTop - this.containerTop; + (this.follower.parentNode as any).style.marginTop = `${top}px`; + return; + } + } +} diff --git a/src/web/app/desktop/scripts/update-avatar.js b/src/web/app/desktop/scripts/update-avatar.ts index 165c90567c..356f4e6f9d 100644 --- a/src/web/app/desktop/scripts/update-avatar.js +++ b/src/web/app/desktop/scripts/update-avatar.ts @@ -1,11 +1,12 @@ +declare const _API_URL_: string; + import * as riot from 'riot'; -import CONFIG from '../../common/scripts/config'; import dialog from './dialog'; import api from '../../common/scripts/api'; export default (I, cb, file = null) => { const fileSelected = file => { - const cropper = riot.mount(document.body.appendChild(document.createElement('mk-crop-window')), { + const cropper = (riot as any).mount(document.body.appendChild(document.createElement('mk-crop-window')), { file: file, title: 'アバターとして表示する部分を選択', aspectRatio: 1 / 1 @@ -37,16 +38,16 @@ export default (I, cb, file = null) => { }; const upload = (data, folder) => { - const progress = riot.mount(document.body.appendChild(document.createElement('mk-progress-dialog')), { + const progress = (riot as any).mount(document.body.appendChild(document.createElement('mk-progress-dialog')), { title: '新しいアバターをアップロードしています' })[0]; if (folder) data.append('folder_id', folder.id); const xhr = new XMLHttpRequest(); - xhr.open('POST', CONFIG.apiUrl + '/drive/files/create', true); + xhr.open('POST', _API_URL_ + '/drive/files/create', true); xhr.onload = e => { - const file = JSON.parse(e.target.response); + const file = JSON.parse((e.target as any).response); progress.close(); set(file); }; @@ -75,7 +76,7 @@ export default (I, cb, file = null) => { if (file) { fileSelected(file); } else { - const browser = riot.mount(document.body.appendChild(document.createElement('mk-select-file-from-drive-window')), { + const browser = (riot as any).mount(document.body.appendChild(document.createElement('mk-select-file-from-drive-window')), { multiple: false, title: '<i class="fa fa-picture-o"></i>アバターにする画像を選択' })[0]; diff --git a/src/web/app/desktop/scripts/update-banner.js b/src/web/app/desktop/scripts/update-banner.ts index d83b2bf1b1..1996b75642 100644 --- a/src/web/app/desktop/scripts/update-banner.js +++ b/src/web/app/desktop/scripts/update-banner.ts @@ -1,11 +1,12 @@ +declare const _API_URL_: string; + import * as riot from 'riot'; -import CONFIG from '../../common/scripts/config'; import dialog from './dialog'; import api from '../../common/scripts/api'; export default (I, cb, file = null) => { const fileSelected = file => { - const cropper = riot.mount(document.body.appendChild(document.createElement('mk-crop-window')), { + const cropper = (riot as any).mount(document.body.appendChild(document.createElement('mk-crop-window')), { file: file, title: 'バナーとして表示する部分を選択', aspectRatio: 16 / 9 @@ -37,16 +38,16 @@ export default (I, cb, file = null) => { }; const upload = (data, folder) => { - const progress = riot.mount(document.body.appendChild(document.createElement('mk-progress-dialog')), { + const progress = (riot as any).mount(document.body.appendChild(document.createElement('mk-progress-dialog')), { title: '新しいバナーをアップロードしています' })[0]; if (folder) data.append('folder_id', folder.id); const xhr = new XMLHttpRequest(); - xhr.open('POST', CONFIG.apiUrl + '/drive/files/create', true); + xhr.open('POST', _API_URL_ + '/drive/files/create', true); xhr.onload = e => { - const file = JSON.parse(e.target.response); + const file = JSON.parse((e.target as any).response); progress.close(); set(file); }; @@ -75,7 +76,7 @@ export default (I, cb, file = null) => { if (file) { fileSelected(file); } else { - const browser = riot.mount(document.body.appendChild(document.createElement('mk-select-file-from-drive-window')), { + const browser = (riot as any).mount(document.body.appendChild(document.createElement('mk-select-file-from-drive-window')), { multiple: false, title: '<i class="fa fa-picture-o"></i>バナーにする画像を選択' })[0]; diff --git a/src/web/app/desktop/style.styl b/src/web/app/desktop/style.styl index 4597dffdb3..d99e5df2b4 100644 --- a/src/web/app/desktop/style.styl +++ b/src/web/app/desktop/style.styl @@ -40,8 +40,7 @@ background rgba(0, 0, 0, 0.2) html - //background #2f3e42 - background #313a42 + background #f7f7f7 // ↓ workaround of https://github.com/riot/riot/issues/2134 &[data-page='entrance'] @@ -49,9 +48,6 @@ html right auto left 15px -html[theme='dark'] - background #100f0f - button font-family sans-serif diff --git a/src/web/app/desktop/tags/analog-clock.tag b/src/web/app/desktop/tags/analog-clock.tag index 6cd7103c6e..c0489d3feb 100644 --- a/src/web/app/desktop/tags/analog-clock.tag +++ b/src/web/app/desktop/tags/analog-clock.tag @@ -72,7 +72,7 @@ const length = Math.min(canvW, canvH) / 4; const uv = new Vec2(Math.sin(angle), -Math.cos(angle)); ctx.beginPath(); - ctx.strokeStyle = THEME_COLOR; + ctx.strokeStyle = _THEME_COLOR_; ctx.lineWidth = 2; ctx.moveTo(canvW / 2 - uv.x * length / 5, canvH / 2 - uv.y * length / 5); ctx.lineTo(canvW / 2 + uv.x * length, canvH / 2 + uv.y * length); diff --git a/src/web/app/desktop/tags/autocomplete-suggestion.tag b/src/web/app/desktop/tags/autocomplete-suggestion.tag index b936360402..7311606694 100644 --- a/src/web/app/desktop/tags/autocomplete-suggestion.tag +++ b/src/web/app/desktop/tags/autocomplete-suggestion.tag @@ -177,7 +177,7 @@ }; this.applySelect = () => { - this.refs.users.children.forEach(el => { + Array.from(this.refs.users.children).forEach(el => { el.removeAttribute('data-selected'); }); diff --git a/src/web/app/desktop/tags/big-follow-button.tag b/src/web/app/desktop/tags/big-follow-button.tag index 86df2d4924..8897748ae1 100644 --- a/src/web/app/desktop/tags/big-follow-button.tag +++ b/src/web/app/desktop/tags/big-follow-button.tag @@ -74,7 +74,10 @@ this.mixin('i'); this.mixin('api'); + this.mixin('stream'); + this.connection = this.stream.getConnection(); + this.connectionId = this.stream.use(); this.user = null; this.userPromise = isPromise(this.opts.user) @@ -89,14 +92,15 @@ init: false, user: user }); - this.stream.on('follow', this.onStreamFollow); - this.stream.on('unfollow', this.onStreamUnfollow); + this.connection.on('follow', this.onStreamFollow); + this.connection.on('unfollow', this.onStreamUnfollow); }); }); this.on('unmount', () => { - this.stream.off('follow', this.onStreamFollow); - this.stream.off('unfollow', this.onStreamUnfollow); + this.connection.off('follow', this.onStreamFollow); + this.connection.off('unfollow', this.onStreamUnfollow); + this.stream.dispose(this.connectionId); }); this.onStreamFollow = user => { diff --git a/src/web/app/desktop/tags/donation.tag b/src/web/app/desktop/tags/donation.tag index 33f377a192..1c19fac1f5 100644 --- a/src/web/app/desktop/tags/donation.tag +++ b/src/web/app/desktop/tags/donation.tag @@ -54,11 +54,10 @@ e.preventDefault(); e.stopPropagation(); - this.I.data.no_donation = 'true'; + this.I.client_settings.show_donation = false; this.I.update(); - this.api('i/appdata/set', { - key: 'no_donation', - value: 'true' + this.api('i/update', { + show_donation: false }); this.unmount(); diff --git a/src/web/app/desktop/tags/drive/browser-window.tag b/src/web/app/desktop/tags/drive/browser-window.tag index dc55371da6..7cd24fc4ad 100644 --- a/src/web/app/desktop/tags/drive/browser-window.tag +++ b/src/web/app/desktop/tags/drive/browser-window.tag @@ -1,11 +1,11 @@ <mk-drive-browser-window> - <mk-window ref="window" is-modal={ false } width={ '800px' } height={ '500px' }> + <mk-window ref="window" is-modal={ false } width={ '800px' } height={ '500px' } popout={ popout }> <yield to="header"> <p class="info" if={ parent.usage }><b>{ parent.usage.toFixed(1) }%</b> %i18n:desktop.tags.mk-drive-browser-window.used%</p> <i class="fa fa-cloud"></i>%i18n:desktop.tags.mk-drive-browser-window.drive% </yield> <yield to="content"> - <mk-drive-browser multiple={ true } folder={ parent.folder }/> + <mk-drive-browser multiple={ true } folder={ parent.folder } ref="browser"/> </yield> </mk-window> <style> @@ -32,6 +32,15 @@ this.folder = this.opts.folder ? this.opts.folder : null; + this.popout = () => { + const folder = this.refs.window.refs.browser.folder; + if (folder) { + return `${_URL_}/i/drive/folder/${folder.id}`; + } else { + return `${_URL_}/i/drive`; + } + }; + this.on('mount', () => { this.refs.window.on('closed', () => { this.unmount(); diff --git a/src/web/app/desktop/tags/drive/browser.tag b/src/web/app/desktop/tags/drive/browser.tag index 93db0a04d7..6b756b9952 100644 --- a/src/web/app/desktop/tags/drive/browser.tag +++ b/src/web/app/desktop/tags/drive/browser.tag @@ -2,7 +2,8 @@ <nav> <div class="path" oncontextmenu={ pathOncontextmenu }> <mk-drive-browser-nav-folder class={ current: folder == null } folder={ null }/> - <virtual each={ folder in hierarchyFolders }><span class="separator"><i class="fa fa-angle-right"></i></span> + <virtual each={ folder in hierarchyFolders }> + <span class="separator"><i class="fa fa-angle-right"></i></span> <mk-drive-browser-nav-folder folder={ folder }/> </virtual> <span class="separator" if={ folder != null }><i class="fa fa-angle-right"></i></span> @@ -17,12 +18,14 @@ <virtual each={ folder in folders }> <mk-drive-browser-folder class="folder" folder={ folder }/> </virtual> + <div class="padding" each={ folders }></div> <button if={ moreFolders }>%i18n:desktop.tags.mk-drive-browser.load-more%</button> </div> <div class="files" ref="filesContainer" if={ files.length > 0 }> <virtual each={ file in files }> <mk-drive-browser-file class="file" file={ file }/> </virtual> + <div class="padding" each={ files }></div> <button if={ moreFiles } onclick={ fetchMoreFiles }>%i18n:desktop.tags.mk-drive-browser.load-more%</button> </div> <div class="empty" if={ files.length == 0 && folders.length == 0 && !fetching }> @@ -160,22 +163,20 @@ > .contents > .folders - &:after - content "" - display block - clear both - - > .folder - float left - > .files - &:after - content "" - display block - clear both + display flex + flex-wrap wrap + > .folder > .file - float left + flex-grow 1 + width 144px + margin 4px + + > .padding + flex-grow 1 + pointer-events none + width 144px + 8px // 8px is margin > .empty padding 16px @@ -246,7 +247,10 @@ this.mixin('i'); this.mixin('api'); - this.mixin('stream'); + + this.mixin('drive-stream'); + this.connection = this.driveStream.getConnection(); + this.connectionId = this.driveStream.use(); this.files = []; this.folders = []; @@ -279,10 +283,10 @@ }); }); - this.stream.on('drive_file_created', this.onStreamDriveFileCreated); - this.stream.on('drive_file_updated', this.onStreamDriveFileUpdated); - this.stream.on('drive_folder_created', this.onStreamDriveFolderCreated); - this.stream.on('drive_folder_updated', this.onStreamDriveFolderUpdated); + this.connection.on('file_created', this.onStreamDriveFileCreated); + this.connection.on('file_updated', this.onStreamDriveFileUpdated); + this.connection.on('folder_created', this.onStreamDriveFolderCreated); + this.connection.on('folder_updated', this.onStreamDriveFolderUpdated); if (this.opts.folder) { this.move(this.opts.folder); @@ -292,10 +296,11 @@ }); this.on('unmount', () => { - this.stream.off('drive_file_created', this.onStreamDriveFileCreated); - this.stream.off('drive_file_updated', this.onStreamDriveFileUpdated); - this.stream.off('drive_folder_created', this.onStreamDriveFolderCreated); - this.stream.off('drive_folder_updated', this.onStreamDriveFolderUpdated); + this.connection.off('file_created', this.onStreamDriveFileCreated); + this.connection.off('file_updated', this.onStreamDriveFileUpdated); + this.connection.off('folder_created', this.onStreamDriveFolderCreated); + this.connection.off('folder_updated', this.onStreamDriveFolderUpdated); + this.driveStream.dispose(this.connectionId); }); this.onStreamDriveFileCreated = file => { @@ -407,7 +412,7 @@ // ドロップされてきたものがファイルだったら if (e.dataTransfer.files.length > 0) { - e.dataTransfer.files.forEach(file => { + Array.from(e.dataTransfer.files).forEach(file => { this.upload(file, this.folder); }); return false; @@ -509,7 +514,7 @@ }; this.changeFileInput = () => { - this.refs.fileInput.files.forEach(file => { + Array.from(this.refs.fileInput.files).forEach(file => { this.upload(file, this.folder); }); }; @@ -571,6 +576,7 @@ if (folder.parent) dive(folder.parent); this.update(); + this.trigger('open-folder', folder); this.fetch(); }); }; @@ -640,6 +646,7 @@ folder: null, hierarchyFolders: [] }); + this.trigger('move-root'); this.fetch(); }; diff --git a/src/web/app/desktop/tags/drive/file.tag b/src/web/app/desktop/tags/drive/file.tag index 64838d6814..0f019d95bf 100644 --- a/src/web/app/desktop/tags/drive/file.tag +++ b/src/web/app/desktop/tags/drive/file.tag @@ -5,17 +5,12 @@ <div class="label" if={ I.banner_id == file.id }><img src="/assets/label.svg"/> <p>%i18n:desktop.tags.mk-drive-browser-file.banner%</p> </div> - <div class="label" if={ I.data.wallpaper == file.id }><img src="/assets/label.svg"/> - <p>%i18n:desktop.tags.mk-drive-browser-file.wallpaper%</p> - </div> <div class="thumbnail"><img src={ file.url + '?thumbnail&size=128' } alt=""/></div> <p class="name"><span>{ file.name.lastIndexOf('.') != -1 ? file.name.substr(0, file.name.lastIndexOf('.')) : file.name }</span><span class="ext" if={ file.name.lastIndexOf('.') != -1 }>{ file.name.substr(file.name.lastIndexOf('.')) }</span></p> <style> :scope display block - margin 4px padding 8px 0 0 0 - width 144px height 180px border-radius 4px @@ -116,7 +111,7 @@ > .thumbnail width 128px height 128px - left 8px + margin auto > img display block diff --git a/src/web/app/desktop/tags/drive/folder.tag b/src/web/app/desktop/tags/drive/folder.tag index e03c4e3534..6d2d196258 100644 --- a/src/web/app/desktop/tags/drive/folder.tag +++ b/src/web/app/desktop/tags/drive/folder.tag @@ -3,9 +3,7 @@ <style> :scope display block - margin 4px padding 8px - width 144px height 64px background lighten($theme-color, 95%) border-radius 4px @@ -109,7 +107,7 @@ // ファイルだったら if (e.dataTransfer.files.length > 0) { - e.dataTransfer.files.forEach(file => { + Array.from(e.dataTransfer.files).forEach(file => { this.browser.upload(file, this.folder); }); return false; diff --git a/src/web/app/desktop/tags/drive/nav-folder.tag b/src/web/app/desktop/tags/drive/nav-folder.tag index c89d9edc1c..0a9421353c 100644 --- a/src/web/app/desktop/tags/drive/nav-folder.tag +++ b/src/web/app/desktop/tags/drive/nav-folder.tag @@ -55,7 +55,7 @@ // ファイルだったら if (e.dataTransfer.files.length > 0) { - e.dataTransfer.files.forEach(file => { + Array.from(e.dataTransfer.files).forEach(file => { this.browser.upload(file, this.folder); }); return false; diff --git a/src/web/app/desktop/tags/follow-button.tag b/src/web/app/desktop/tags/follow-button.tag index 00ff686f69..a1cbc191d8 100644 --- a/src/web/app/desktop/tags/follow-button.tag +++ b/src/web/app/desktop/tags/follow-button.tag @@ -71,7 +71,10 @@ this.mixin('i'); this.mixin('api'); + this.mixin('stream'); + this.connection = this.stream.getConnection(); + this.connectionId = this.stream.use(); this.user = null; this.userPromise = isPromise(this.opts.user) @@ -86,14 +89,15 @@ init: false, user: user }); - this.stream.on('follow', this.onStreamFollow); - this.stream.on('unfollow', this.onStreamUnfollow); + this.connection.on('follow', this.onStreamFollow); + this.connection.on('unfollow', this.onStreamUnfollow); }); }); this.on('unmount', () => { - this.stream.off('follow', this.onStreamFollow); - this.stream.off('unfollow', this.onStreamUnfollow); + this.connection.off('follow', this.onStreamFollow); + this.connection.off('unfollow', this.onStreamUnfollow); + this.stream.dispose(this.connectionId); }); this.onStreamFollow = user => { diff --git a/src/web/app/desktop/tags/home-widgets/access-log.tag b/src/web/app/desktop/tags/home-widgets/access-log.tag new file mode 100644 index 0000000000..44f1cadf4b --- /dev/null +++ b/src/web/app/desktop/tags/home-widgets/access-log.tag @@ -0,0 +1,95 @@ +<mk-access-log-home-widget> + <virtual if={ data.design == 0 }> + <p class="title"><i class="fa fa-server"></i>%i18n:desktop.tags.mk-access-log-home-widget.title%</p> + </virtual> + <div ref="log"> + <p each={ requests }> + <span class="ip" style="color:{ fg }; background:{ bg }">{ ip }</span> + <span>{ method }</span> + <span>{ path }</span> + </p> + </div> + <style> + :scope + display block + overflow hidden + background #fff + border solid 1px rgba(0, 0, 0, 0.075) + border-radius 6px + + > .title + z-index 1 + margin 0 + padding 0 16px + line-height 42px + font-size 0.9em + font-weight bold + color #888 + box-shadow 0 1px rgba(0, 0, 0, 0.07) + + > i + margin-right 4px + + > div + max-height 250px + overflow auto + + > p + margin 0 + padding 8px + font-size 0.8em + color #555 + + &:nth-child(odd) + background rgba(0, 0, 0, 0.025) + + > .ip + margin-right 4px + + </style> + <script> + import seedrandom from 'seedrandom'; + + this.data = { + design: 0 + }; + + this.mixin('widget'); + + this.mixin('requests-stream'); + this.connection = this.requestsStream.getConnection(); + this.connectionId = this.requestsStream.use(); + + this.requests = []; + + this.on('mount', () => { + this.connection.on('request', this.onRequest); + }); + + this.on('unmount', () => { + this.connection.off('request', this.onRequest); + this.requestsStream.dispose(this.connectionId); + }); + + this.onRequest = request => { + const random = seedrandom(request.ip); + const r = Math.floor(random() * 255); + const g = Math.floor(random() * 255); + const b = Math.floor(random() * 255); + const luma = (0.2126 * r) + (0.7152 * g) + (0.0722 * b); // SMPTE C, Rec. 709 weightings + request.bg = `rgb(${r}, ${g}, ${b})`; + request.fg = luma >= 165 ? '#000' : '#fff'; + + this.requests.push(request); + if (this.requests.length > 30) this.requests.shift(); + this.update(); + + this.refs.log.scrollTop = this.refs.log.scrollHeight; + }; + + this.func = () => { + if (++this.data.design == 2) this.data.design = 0; + this.save(); + }; + </script> +</mk-access-log-home-widget> diff --git a/src/web/app/desktop/tags/home-widgets/activity.tag b/src/web/app/desktop/tags/home-widgets/activity.tag index 8bd8bfb2aa..2274e84162 100644 --- a/src/web/app/desktop/tags/home-widgets/activity.tag +++ b/src/web/app/desktop/tags/home-widgets/activity.tag @@ -1,234 +1,32 @@ <mk-activity-home-widget> - <p class="title"><i class="fa fa-bar-chart"></i>%i18n:desktop.tags.mk-activity-home-widget.title%</p> - <button onclick={ toggle } title="%i18n:desktop.tags.mk-activity-home-widget.toggle%"><i class="fa fa-sort"></i></button> - <p class="initializing" if={ initializing }><i class="fa fa-spinner fa-pulse fa-fw"></i>%i18n:common.loading%<mk-ellipsis/></p> - <mk-activity-home-widget-calender if={ !initializing && view == 0 } data={ [].concat(data) }/> - <mk-activity-home-widget-chart if={ !initializing && view == 1 } data={ [].concat(data) }/> + <mk-activity-widget design={ data.design } view={ data.view } user={ I } ref="activity"/> <style> :scope display block - background #fff - - > .title - z-index 1 - margin 0 - padding 0 16px - line-height 42px - font-size 0.9em - font-weight bold - color #888 - box-shadow 0 1px rgba(0, 0, 0, 0.07) - - > i - margin-right 4px - - > button - position absolute - z-index 2 - top 0 - right 0 - padding 0 - width 42px - font-size 0.9em - line-height 42px - color #ccc - - &:hover - color #aaa - - &:active - color #999 - - > .initializing - margin 0 - padding 16px - text-align center - color #aaa - - > i - margin-right 4px - </style> <script> - this.mixin('i'); - this.mixin('api'); - - this.initializing = true; - this.view = 0; - - this.on('mount', () => { - this.api('aggregation/users/activity', { - user_id: this.I.id, - limit: 20 * 7 - }).then(data => { - this.update({ - initializing: false, - data - }); - }); - }); - - this.toggle = () => { - this.view++; - if (this.view == 2) this.view = 0; + this.data = { + view: 0, + design: 0 }; - </script> -</mk-activity-home-widget> -<mk-activity-home-widget-calender> - <svg viewBox="0 0 21 7" preserveAspectRatio="none"> - <rect each={ data } class="day" - width="1" height="1" - riot-x={ x } riot-y={ date.weekday } - rx="1" ry="1" - fill="transparent"> - <title>{ date.year }/{ date.month }/{ date.day }<br/>Post: { posts }, Reply: { replies }, Repost: { reposts }</title> - </rect> - <rect each={ data } - width="1" height="1" - riot-x={ x } riot-y={ date.weekday } - rx="1" ry="1" - fill={ color } - style="pointer-events: none; transform: scale({ v });"/> - <rect class="today" - width="1" height="1" - riot-x={ data[data.length - 1].x } riot-y={ data[data.length - 1].date.weekday } - rx="1" ry="1" - fill="none" - stroke-width="0.1" - stroke="#f73520"/> - </svg> - <style> - :scope - display block - - > svg - display block - padding 10px - width 100% - - > rect - transform-origin center - - &.day - &:hover - fill rgba(0, 0, 0, 0.05) - - </style> - <script> - this.data = this.opts.data; - this.data.forEach(d => d.total = d.posts + d.replies + d.reposts); - const peak = Math.max.apply(null, this.data.map(d => d.total)); + this.mixin('widget'); - let x = 0; - this.data.reverse().forEach(d => { - d.x = x; - d.date.weekday = (new Date(d.date.year, d.date.month - 1, d.date.day)).getDay(); - - d.v = d.total / (peak / 2); - if (d.v > 1) d.v = 1; - const ch = d.date.weekday == 0 || d.date.weekday == 6 ? 275 : 170; - const cs = d.v * 100; - const cl = 15 + ((1 - d.v) * 80); - d.color = `hsl(${ch}, ${cs}%, ${cl}%)`; - - if (d.date.weekday == 6) x++; - }); - </script> -</mk-activity-home-widget-calender> - -<mk-activity-home-widget-chart> - <svg riot-viewBox="0 0 { viewBoxX } { viewBoxY }" preserveAspectRatio="none" onmousedown={ onMousedown }> - <title>Black ... Total<br/>Blue ... Posts<br/>Red ... Replies<br/>Green ... Reposts</title> - <polyline - riot-points={ pointsPost } - fill="none" - stroke-width="1" - stroke="#41ddde"/> - <polyline - riot-points={ pointsReply } - fill="none" - stroke-width="1" - stroke="#f7796c"/> - <polyline - riot-points={ pointsRepost } - fill="none" - stroke-width="1" - stroke="#a1de41"/> - <polyline - riot-points={ pointsTotal } - fill="none" - stroke-width="1" - stroke="#555" - stroke-dasharray="2 2"/> - </svg> - <style> - :scope - display block - - > svg - display block - padding 10px - width 100% - cursor all-scroll - </style> - <script> - this.viewBoxX = 140; - this.viewBoxY = 60; - this.zoom = 1; - this.pos = 0; - - this.data = this.opts.data.reverse(); - this.data.forEach(d => d.total = d.posts + d.replies + d.reposts); - const peak = Math.max.apply(null, this.data.map(d => d.total)); + this.initializing = true; this.on('mount', () => { - this.render(); - }); - - this.render = () => { - this.update({ - pointsPost: this.data.map((d, i) => `${(i * this.zoom) + this.pos},${(1 - (d.posts / peak)) * this.viewBoxY}`).join(' '), - pointsReply: this.data.map((d, i) => `${(i * this.zoom) + this.pos},${(1 - (d.replies / peak)) * this.viewBoxY}`).join(' '), - pointsRepost: this.data.map((d, i) => `${(i * this.zoom) + this.pos},${(1 - (d.reposts / peak)) * this.viewBoxY}`).join(' '), - pointsTotal: this.data.map((d, i) => `${(i * this.zoom) + this.pos},${(1 - (d.total / peak)) * this.viewBoxY}`).join(' ') + this.refs.activity.on('view-changed', view => { + this.data.view = view; + this.save(); }); - }; - - this.onMousedown = e => { - e.preventDefault(); - - const clickX = e.clientX; - const clickY = e.clientY; - const baseZoom = this.zoom; - const basePos = this.pos; - - // 動かした時 - dragListen(me => { - let moveLeft = me.clientX - clickX; - let moveTop = me.clientY - clickY; - - this.zoom = baseZoom + (-moveTop / 20); - this.pos = basePos + moveLeft; - if (this.zoom < 1) this.zoom = 1; - if (this.pos > 0) this.pos = 0; - if (this.pos < -(((this.data.length - 1) * this.zoom) - this.viewBoxX)) this.pos = -(((this.data.length - 1) * this.zoom) - this.viewBoxX); + }); - this.render(); + this.func = () => { + if (++this.data.design == 3) this.data.design = 0; + this.refs.activity.update({ + design: this.data.design }); + this.save(); }; - - function dragListen(fn) { - window.addEventListener('mousemove', fn); - window.addEventListener('mouseleave', dragClear.bind(null, fn)); - window.addEventListener('mouseup', dragClear.bind(null, fn)); - } - - function dragClear(fn) { - window.removeEventListener('mousemove', fn); - window.removeEventListener('mouseleave', dragClear); - window.removeEventListener('mouseup', dragClear); - } </script> -</mk-activity-home-widget-chart> - +</mk-activity-home-widget> diff --git a/src/web/app/desktop/tags/home-widgets/broadcast.tag b/src/web/app/desktop/tags/home-widgets/broadcast.tag index 1102e22c7f..6f4bb0756d 100644 --- a/src/web/app/desktop/tags/home-widgets/broadcast.tag +++ b/src/web/app/desktop/tags/home-widgets/broadcast.tag @@ -1,4 +1,4 @@ -<mk-broadcast-home-widget> +<mk-broadcast-home-widget data-found={ broadcasts.length != 0 } data-melt={ data.design == 1 }> <div class="icon"> <svg height="32" version="1.1" viewBox="0 0 32 32" width="32"> <path class="tower" d="M16.04,11.24c1.79,0,3.239-1.45,3.239-3.24S17.83,4.76,16.04,4.76c-1.79,0-3.24,1.45-3.24,3.24 C12.78,9.78,14.24,11.24,16.04,11.24z M16.04,13.84c-0.82,0-1.66-0.2-2.4-0.6L7.34,29.98h2.98l1.72-2h8l1.681,2H24.7L18.42,13.24 C17.66,13.64,16.859,13.84,16.04,13.84z M16.02,14.8l2.02,7.2h-4L16.02,14.8z M12.04,25.98l2-2h4l2,2H12.04z"></path> @@ -8,14 +8,27 @@ <path class="wave d" d="M29.18,1.06c-0.479-0.502-1.273-0.522-1.775-0.044c-0.016,0.015-0.029,0.029-0.045,0.044c-0.5,0.52-0.5,1.36,0,1.88 c1.361,1.4,2.041,3.24,2.041,5.08s-0.68,3.66-2.041,5.08c-0.5,0.52-0.5,1.36,0,1.88c0.509,0.508,1.332,0.508,1.841,0 c1.86-1.92,2.8-4.44,2.8-6.96C31.99,5.424,30.98,2.931,29.18,1.06z"></path> </svg> </div> - <h1>開発者募集中!</h1> - <p><a href="https://github.com/syuilo/misskey" target="_blank">Misskeyはオープンソースで開発されています。リポジトリはこちら。</a></p> + <p class="fetching" if={ fetching }>%i18n:desktop.tags.mk-broadcast-home-widget.fetching%<mk-ellipsis/></p> + <h1 if={ !fetching }>{ + broadcasts.length == 0 ? '%i18n:desktop.tags.mk-broadcast-home-widget.no-broadcasts%' : broadcasts[i].title + }</h1> + <p if={ !fetching }><mk-raw if={ broadcasts.length != 0 } content={ broadcasts[i].text }/><virtual if={ broadcasts.length == 0 }>%i18n:desktop.tags.mk-broadcast-home-widget.have-a-nice-day%</virtual></p> + <a if={ broadcasts.length > 1 } onclick={ next }>%i18n:desktop.tags.mk-broadcast-home-widget.next% >></a> <style> :scope display block - padding 10px 10px 10px 50px - background transparent - border-color #4078c0 !important + padding 10px + border solid 1px #4078c0 + border-radius 6px + + &[data-melt] + border none + + &[data-found] + padding-left 50px + + > .icon + display block &:after content "" @@ -23,7 +36,7 @@ clear both > .icon - display block + display none float left margin-left -40px @@ -72,12 +85,59 @@ font-size 0.7em color #555 + &.fetching + text-align center + a color #555 + text-decoration underline - - - + > a + display block + font-size 0.7em </style> + <script> + this.data = { + design: 0 + }; + + this.mixin('widget'); + this.mixin('os'); + + this.i = 0; + this.fetching = true; + this.broadcasts = []; + + this.on('mount', () => { + this.mios.getMeta().then(meta => { + let broadcasts = []; + if (meta.broadcasts) { + meta.broadcasts.forEach(broadcast => { + if (broadcast[_LANG_]) { + broadcasts.push(broadcast[_LANG_]); + } + }); + } + this.update({ + fetching: false, + broadcasts: broadcasts + }); + }); + }); + + this.next = () => { + if (this.i == this.broadcasts.length - 1) { + this.i = 0; + } else { + this.i++; + } + this.update(); + }; + + this.func = () => { + if (++this.data.design == 2) this.data.design = 0; + this.save(); + }; + </script> </mk-broadcast-home-widget> diff --git a/src/web/app/desktop/tags/home-widgets/calendar.tag b/src/web/app/desktop/tags/home-widgets/calendar.tag index 9aa4ac6326..fded57e07a 100644 --- a/src/web/app/desktop/tags/home-widgets/calendar.tag +++ b/src/web/app/desktop/tags/home-widgets/calendar.tag @@ -1,4 +1,4 @@ -<mk-calendar-home-widget data-special={ special }> +<mk-calendar-home-widget data-melt={ data.design == 1 } data-special={ special }> <div class="calendar" data-is-holiday={ isHoliday }> <p class="month-and-year"><span class="year">{ year }年</span><span class="month">{ month }月</span></p> <p class="day">{ day }日</p> @@ -30,9 +30,15 @@ padding 16px 0 color #777 background #fff + border solid 1px rgba(0, 0, 0, 0.075) + border-radius 6px &[data-special='on-new-years-day'] - border-color #ef95a0 !important + border-color #ef95a0 + + &[data-melt] + background transparent + border none &:after content "" @@ -106,6 +112,12 @@ </style> <script> + this.data = { + design: 0 + }; + + this.mixin('widget'); + this.draw = () => { const now = new Date(); const nd = now.getDate(); @@ -130,7 +142,7 @@ this.isHoliday = now.getDay() == 0 || now.getDay() == 6; - this.special = + this.special = nm == 0 && nd == 1 ? 'on-new-years-day' : false; @@ -146,5 +158,10 @@ this.on('unmount', () => { clearInterval(this.clock); }); + + this.func = () => { + if (++this.data.design == 2) this.data.design = 0; + this.save(); + }; </script> </mk-calendar-home-widget> diff --git a/src/web/app/desktop/tags/home-widgets/channel.tag b/src/web/app/desktop/tags/home-widgets/channel.tag new file mode 100644 index 0000000000..f22a5f76ef --- /dev/null +++ b/src/web/app/desktop/tags/home-widgets/channel.tag @@ -0,0 +1,318 @@ +<mk-channel-home-widget> + <virtual if={ !data.compact }> + <p class="title"><i class="fa fa-television"></i>{ + channel ? channel.title : '%i18n:desktop.tags.mk-channel-home-widget.title%' + }</p> + <button onclick={ settings } title="%i18n:desktop.tags.mk-channel-home-widget.settings%"><i class="fa fa-cog"></i></button> + </virtual> + <p class="get-started" if={ this.data.channel == null }>%i18n:desktop.tags.mk-channel-home-widget.get-started%</p> + <mk-channel ref="channel" show={ this.data.channel }/> + <style> + :scope + display block + background #fff + border solid 1px rgba(0, 0, 0, 0.075) + border-radius 6px + overflow hidden + + > .title + z-index 2 + margin 0 + padding 0 16px + line-height 42px + font-size 0.9em + font-weight bold + color #888 + box-shadow 0 1px rgba(0, 0, 0, 0.07) + + > i + margin-right 4px + + > button + position absolute + z-index 2 + top 0 + right 0 + padding 0 + width 42px + font-size 0.9em + line-height 42px + color #ccc + + &:hover + color #aaa + + &:active + color #999 + + > .get-started + margin 0 + padding 16px + text-align center + color #aaa + + > mk-channel + height 200px + + </style> + <script> + this.data = { + channel: null, + compact: false + }; + + this.mixin('widget'); + + this.on('mount', () => { + if (this.data.channel) { + this.zap(); + } + }); + + this.zap = () => { + this.update({ + fetching: true + }); + + this.api('channels/show', { + channel_id: this.data.channel + }).then(channel => { + this.update({ + fetching: false, + channel: channel + }); + + this.refs.channel.zap(channel); + }); + }; + + this.settings = () => { + const id = window.prompt('チャンネルID'); + if (!id) return; + this.data.channel = id; + this.zap(); + + // Save state + this.save(); + }; + + this.func = () => { + this.data.compact = !this.data.compact; + this.save(); + }; + </script> +</mk-channel-home-widget> + +<mk-channel> + <p if={ fetching }>読み込み中<mk-ellipsis/></p> + <div if={ !fetching } ref="posts"> + <p if={ posts.length == 0 }>まだ投稿がありません</p> + <mk-channel-post each={ post in posts.slice().reverse() } post={ post } form={ parent.refs.form }/> + </div> + <mk-channel-form ref="form"/> + <style> + :scope + display block + + > p + margin 0 + padding 16px + text-align center + color #aaa + + > div + height calc(100% - 38px) + overflow auto + font-size 0.9em + + > mk-channel-post + border-bottom solid 1px #eee + + &:last-child + border-bottom none + + > mk-channel-form + position absolute + left 0 + bottom 0 + + </style> + <script> + import ChannelStream from '../../../common/scripts/streaming/channel-stream'; + + this.mixin('api'); + + this.fetching = true; + this.channel = null; + this.posts = []; + + this.on('unmount', () => { + if (this.connection) { + this.connection.off('post', this.onPost); + this.connection.close(); + } + }); + + this.zap = channel => { + this.update({ + fetching: true, + channel: channel + }); + + this.api('channels/posts', { + channel_id: channel.id + }).then(posts => { + this.update({ + fetching: false, + posts: posts + }); + + this.scrollToBottom(); + + if (this.connection) { + this.connection.off('post', this.onPost); + this.connection.close(); + } + this.connection = new ChannelStream(this.channel.id); + this.connection.on('post', this.onPost); + }); + }; + + this.onPost = post => { + this.posts.unshift(post); + this.update(); + this.scrollToBottom(); + }; + + this.scrollToBottom = () => { + this.refs.posts.scrollTop = this.refs.posts.scrollHeight; + }; + </script> +</mk-channel> + +<mk-channel-post> + <header> + <a class="index" onclick={ reply }>{ post.index }:</a> + <a class="name" href={ _URL_ + '/' + post.user.username }><b>{ post.user.name }</b></a> + <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 + color #444 + + > header + position -webkit-sticky + position sticky + z-index 1 + top 0 + padding 8px 4px 4px 16px + background rgba(255, 255, 255, 0.9) + + > .index + margin-right 0.25em + + > .name + margin-right 0.5em + color #008000 + + > div + padding 0 16px 16px 16px + + > .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.refs.text.value = `>>${ this.post.index } `; + }; + </script> +</mk-channel-post> + +<mk-channel-form> + <input ref="text" disabled={ wait } onkeydown={ onkeydown } placeholder="書いて"> + <style> + :scope + display block + width 100% + height 38px + padding 4px + border-top solid 1px #ddd + + > input + padding 0 8px + width 100% + height 100% + font-size 14px + color #55595c + border solid 1px #dadada + border-radius 4px + + &:hover + &:focus + border-color #aeaeae + + </style> + <script> + this.mixin('api'); + + this.clear = () => { + this.refs.text.value = ''; + }; + + this.onkeydown = e => { + if (e.which == 10 || e.which == 13) this.post(); + }; + + this.post = () => { + this.update({ + wait: true + }); + + let text = this.refs.text.value; + let reply = null; + + if (/^>>([0-9]+) /.test(text)) { + const index = text.match(/^>>([0-9]+) /)[1]; + reply = this.parent.posts.find(p => p.index.toString() == index); + text = text.replace(/^>>([0-9]+) /, ''); + } + + this.api('posts/create', { + text: text, + reply_id: reply ? reply.id : undefined, + channel_id: this.parent.channel.id + }).then(data => { + this.clear(); + }).catch(err => { + alert('失敗した'); + }).then(() => { + this.update({ + wait: false + }); + }); + }; + </script> +</mk-channel-form> diff --git a/src/web/app/desktop/tags/home-widgets/donation.tag b/src/web/app/desktop/tags/home-widgets/donation.tag index d533e82831..99ded1b5d4 100644 --- a/src/web/app/desktop/tags/home-widgets/donation.tag +++ b/src/web/app/desktop/tags/home-widgets/donation.tag @@ -7,7 +7,8 @@ :scope display block background #fff - border-color #ead8bb !important + border solid 1px #ead8bb + border-radius 6px > article padding 20px @@ -28,5 +29,8 @@ color #999 </style> - <script>this.mixin('user-preview');</script> + <script> + this.mixin('widget'); + this.mixin('user-preview'); + </script> </mk-donation-home-widget> diff --git a/src/web/app/desktop/tags/home-widgets/mentions.tag b/src/web/app/desktop/tags/home-widgets/mentions.tag index b94e9b04c5..257afc4a8c 100644 --- a/src/web/app/desktop/tags/home-widgets/mentions.tag +++ b/src/web/app/desktop/tags/home-widgets/mentions.tag @@ -9,6 +9,8 @@ :scope display block background #fff + border solid 1px rgba(0, 0, 0, 0.075) + border-radius 6px > header padding 8px 16px diff --git a/src/web/app/desktop/tags/home-widgets/messaging.tag b/src/web/app/desktop/tags/home-widgets/messaging.tag new file mode 100644 index 0000000000..52251aa539 --- /dev/null +++ b/src/web/app/desktop/tags/home-widgets/messaging.tag @@ -0,0 +1,52 @@ +<mk-messaging-home-widget> + <virtual if={ data.design == 0 }> + <p class="title"><i class="fa fa-comments"></i>%i18n:desktop.tags.mk-messaging-home-widget.title%</p> + </virtual> + <mk-messaging ref="index" compact={ true }/> + <style> + :scope + display block + overflow hidden + background #fff + border solid 1px rgba(0, 0, 0, 0.075) + border-radius 6px + + > .title + z-index 2 + margin 0 + padding 0 16px + line-height 42px + font-size 0.9em + font-weight bold + color #888 + box-shadow 0 1px rgba(0, 0, 0, 0.07) + + > i + margin-right 4px + + > mk-messaging + max-height 250px + overflow auto + + </style> + <script> + this.data = { + design: 0 + }; + + this.mixin('widget'); + + this.on('mount', () => { + this.refs.index.on('navigate-user', user => { + riot.mount(document.body.appendChild(document.createElement('mk-messaging-room-window')), { + user: user + }); + }); + }); + + this.func = () => { + if (++this.data.design == 2) this.data.design = 0; + this.save(); + }; + </script> +</mk-messaging-home-widget> diff --git a/src/web/app/desktop/tags/home-widgets/nav.tag b/src/web/app/desktop/tags/home-widgets/nav.tag index 54bfb87a11..61c0b4cb55 100644 --- a/src/web/app/desktop/tags/home-widgets/nav.tag +++ b/src/web/app/desktop/tags/home-widgets/nav.tag @@ -1,4 +1,5 @@ -<mk-nav-home-widget><a href={ CONFIG.aboutUrl }>%i18n:desktop.tags.mk-nav-home-widget.about%</a><i>・</i><a href={ CONFIG.statsUrl }>%i18n:desktop.tags.mk-nav-home-widget.stats%</a><i>・</i><a href={ CONFIG.statusUrl }>%i18n:desktop.tags.mk-nav-home-widget.status%</a><i>・</i><a href="http://zawazawa.jp/misskey/">%i18n:desktop.tags.mk-nav-home-widget.wiki%</a><i>・</i><a href="https://github.com/syuilo/misskey/blob/master/DONORS.md">%i18n:desktop.tags.mk-nav-home-widget.donors%</a><i>・</i><a href="https://github.com/syuilo/misskey">%i18n:desktop.tags.mk-nav-home-widget.repository%</a><i>・</i><a href={ CONFIG.devUrl }>%i18n:desktop.tags.mk-nav-home-widget.develop%</a><i>・</i><a href="https://twitter.com/misskey_xyz" target="_blank">Follow us on <i class="fa fa-twitter"></i></a> +<mk-nav-home-widget> + <mk-nav-links/> <style> :scope display block @@ -6,6 +7,8 @@ font-size 12px color #aaa background #fff + border solid 1px rgba(0, 0, 0, 0.075) + border-radius 6px a color #999 @@ -14,4 +17,7 @@ color #ccc </style> + <script> + this.mixin('widget'); + </script> </mk-nav-home-widget> diff --git a/src/web/app/desktop/tags/home-widgets/notifications.tag b/src/web/app/desktop/tags/home-widgets/notifications.tag index b1170855ac..dadafa660a 100644 --- a/src/web/app/desktop/tags/home-widgets/notifications.tag +++ b/src/web/app/desktop/tags/home-widgets/notifications.tag @@ -1,11 +1,15 @@ <mk-notifications-home-widget> - <p class="title"><i class="fa fa-bell-o"></i>%i18n:desktop.tags.mk-notifications-home-widget.title%</p> - <button onclick={ settings } title="%i18n:desktop.tags.mk-notifications-home-widget.settings%"><i class="fa fa-cog"></i></button> + <virtual if={ !data.compact }> + <p class="title"><i class="fa fa-bell-o"></i>%i18n:desktop.tags.mk-notifications-home-widget.title%</p> + <button onclick={ settings } title="%i18n:desktop.tags.mk-notifications-home-widget.settings%"><i class="fa fa-cog"></i></button> + </virtual> <mk-notifications/> <style> :scope display block background #fff + border solid 1px rgba(0, 0, 0, 0.075) + border-radius 6px > .title z-index 1 @@ -43,9 +47,20 @@ </style> <script> + this.data = { + compact: false + }; + + this.mixin('widget'); + this.settings = () => { const w = riot.mount(document.body.appendChild(document.createElement('mk-settings-window')))[0]; w.switch('notification'); }; + + this.func = () => { + this.data.compact = !this.data.compact; + this.save(); + }; </script> </mk-notifications-home-widget> diff --git a/src/web/app/desktop/tags/home-widgets/photo-stream.tag b/src/web/app/desktop/tags/home-widgets/photo-stream.tag index d1f29589f3..05658c9025 100644 --- a/src/web/app/desktop/tags/home-widgets/photo-stream.tag +++ b/src/web/app/desktop/tags/home-widgets/photo-stream.tag @@ -1,5 +1,7 @@ -<mk-photo-stream-home-widget> - <p class="title"><i class="fa fa-camera"></i>%i18n:desktop.tags.mk-photo-stream-home-widget.title%</p> +<mk-photo-stream-home-widget data-melt={ data.design == 2 }> + <virtual if={ data.design == 0 }> + <p class="title"><i class="fa fa-camera"></i>%i18n:desktop.tags.mk-photo-stream-home-widget.title%</p> + </virtual> <p class="initializing" if={ initializing }><i class="fa fa-spinner fa-pulse fa-fw"></i>%i18n:common.loading%<mk-ellipsis/></p> <div class="stream" if={ !initializing && images.length > 0 }> <virtual each={ image in images }> @@ -11,6 +13,19 @@ :scope display block background #fff + border solid 1px rgba(0, 0, 0, 0.075) + border-radius 6px + + &[data-melt] + background transparent !important + border none !important + + > .stream + padding 0 + + > .img + border solid 4px transparent + border-radius 8px > .title z-index 1 @@ -55,15 +70,21 @@ </style> <script> - this.mixin('i'); - this.mixin('api'); + this.data = { + design: 0 + }; + + this.mixin('widget'); + this.mixin('stream'); + this.connection = this.stream.getConnection(); + this.connectionId = this.stream.use(); this.images = []; this.initializing = true; this.on('mount', () => { - this.stream.on('drive_file_created', this.onStreamDriveFileCreated); + this.connection.on('drive_file_created', this.onStreamDriveFileCreated); this.api('drive/stream', { type: 'image/*', @@ -77,7 +98,8 @@ }); this.on('unmount', () => { - this.stream.off('drive_file_created', this.onStreamDriveFileCreated); + this.connection.off('drive_file_created', this.onStreamDriveFileCreated); + this.stream.dispose(this.connectionId); }); this.onStreamDriveFileCreated = file => { @@ -87,5 +109,10 @@ this.update(); } }; + + this.func = () => { + if (++this.data.design == 3) this.data.design = 0; + this.save(); + }; </script> </mk-photo-stream-home-widget> diff --git a/src/web/app/desktop/tags/home-widgets/post-form.tag b/src/web/app/desktop/tags/home-widgets/post-form.tag new file mode 100644 index 0000000000..9ca7fecfe7 --- /dev/null +++ b/src/web/app/desktop/tags/home-widgets/post-form.tag @@ -0,0 +1,103 @@ +<mk-post-form-home-widget> + <mk-post-form if={ place == 'main' }/> + <virtual if={ place != 'main' }> + <virtual if={ data.design == 0 }> + <p class="title"><i class="fa fa-pencil"></i>%i18n:desktop.tags.mk-post-form-home-widget.title%</p> + </virtual> + <textarea disabled={ posting } ref="text" onkeydown={ onkeydown } placeholder="%i18n:desktop.tags.mk-post-form-home-widget.placeholder%"></textarea> + <button onclick={ post } disabled={ posting }>%i18n:desktop.tags.mk-post-form-home-widget.post%</button> + </virtual> + <style> + :scope + display block + background #fff + overflow hidden + border solid 1px rgba(0, 0, 0, 0.075) + border-radius 6px + + > .title + z-index 1 + margin 0 + padding 0 16px + line-height 42px + font-size 0.9em + font-weight bold + color #888 + box-shadow 0 1px rgba(0, 0, 0, 0.07) + + > i + margin-right 4px + + > textarea + display block + width 100% + max-width 100% + min-width 100% + padding 16px + margin-bottom 28px + 16px + border none + border-bottom solid 1px #eee + + > button + display block + position absolute + bottom 8px + right 8px + margin 0 + padding 0 10px + height 28px + color $theme-color-foreground + background $theme-color !important + outline none + border none + border-radius 4px + transition background 0.1s ease + cursor pointer + + &:hover + background lighten($theme-color, 10%) !important + + &:active + background darken($theme-color, 10%) !important + transition background 0s ease + + </style> + <script> + this.data = { + design: 0 + }; + + this.mixin('widget'); + + this.func = () => { + if (++this.data.design == 2) this.data.design = 0; + this.save(); + }; + + this.onkeydown = e => { + if ((e.which == 10 || e.which == 13) && (e.ctrlKey || e.metaKey)) this.post(); + }; + + this.post = () => { + this.update({ + posting: true + }); + + this.api('posts/create', { + text: this.refs.text.value + }).then(data => { + this.clear(); + }).catch(err => { + alert('失敗した'); + }).then(() => { + this.update({ + posting: false + }); + }); + }; + + this.clear = () => { + this.refs.text.value = ''; + }; + </script> +</mk-post-form-home-widget> diff --git a/src/web/app/desktop/tags/home-widgets/profile.tag b/src/web/app/desktop/tags/home-widgets/profile.tag index e6a8752113..eb8ba52e84 100644 --- a/src/web/app/desktop/tags/home-widgets/profile.tag +++ b/src/web/app/desktop/tags/home-widgets/profile.tag @@ -1,11 +1,56 @@ -<mk-profile-home-widget> - <div class="banner" style={ I.banner_url ? 'background-image: url(' + I.banner_url + '?thumbnail&size=256)' : '' } title="クリックでバナー編集" onclick={ setBanner }></div><img class="avatar" src={ I.avatar_url + '?thumbnail&size=64' } onclick={ setAvatar } alt="avatar" title="クリックでアバター編集" data-user-preview={ I.id }/><a class="name" href={ '/' + I.username }>{ I.name }</a> +<mk-profile-home-widget data-compact={ data.design == 1 || data.design == 2 } data-melt={ data.design == 2 }> + <div class="banner" style={ I.banner_url ? 'background-image: url(' + I.banner_url + '?thumbnail&size=256)' : '' } title="クリックでバナー編集" onclick={ setBanner }></div> + <img class="avatar" src={ I.avatar_url + '?thumbnail&size=96' } onclick={ setAvatar } alt="avatar" title="クリックでアバター編集" data-user-preview={ I.id }/> + <a class="name" href={ '/' + I.username }>{ I.name }</a> <p class="username">@{ I.username }</p> <style> :scope display block overflow hidden background #fff + border solid 1px rgba(0, 0, 0, 0.075) + border-radius 6px + + &[data-compact] + > .banner:before + content "" + display block + width 100% + height 100% + background rgba(0, 0, 0, 0.5) + + > .avatar + top ((100px - 58px) / 2) + left ((100px - 58px) / 2) + border none + border-radius 100% + box-shadow 0 0 16px rgba(0, 0, 0, 0.5) + + > .name + position absolute + top 0 + left 92px + margin 0 + line-height 100px + color #fff + text-shadow 0 0 8px rgba(0, 0, 0, 0.5) + + > .username + display none + + &[data-melt] + background transparent !important + border none !important + + > .banner + visibility hidden + + > .avatar + box-shadow none + + > .name + color #666 + text-shadow none > .banner height 100px @@ -47,7 +92,12 @@ import updateAvatar from '../../scripts/update-avatar'; import updateBanner from '../../scripts/update-banner'; - this.mixin('i'); + this.data = { + design: 0 + }; + + this.mixin('widget'); + this.mixin('user-preview'); this.setAvatar = () => { @@ -57,5 +107,10 @@ this.setBanner = () => { updateBanner(this.I); }; + + this.func = () => { + if (++this.data.design == 3) this.data.design = 0; + this.save(); + }; </script> </mk-profile-home-widget> diff --git a/src/web/app/desktop/tags/home-widgets/recommended-polls.tag b/src/web/app/desktop/tags/home-widgets/recommended-polls.tag index b724718af7..5bfa839820 100644 --- a/src/web/app/desktop/tags/home-widgets/recommended-polls.tag +++ b/src/web/app/desktop/tags/home-widgets/recommended-polls.tag @@ -1,6 +1,8 @@ <mk-recommended-polls-home-widget> - <p class="title"><i class="fa fa-pie-chart"></i>%i18n:desktop.tags.mk-recommended-polls-home-widget.title%</p> - <button onclick={ fetch } title="%i18n:desktop.tags.mk-recommended-polls-home-widget.refresh%"><i class="fa fa-refresh"></i></button> + <virtual if={ !data.compact }> + <p class="title"><i class="fa fa-pie-chart"></i>%i18n:desktop.tags.mk-recommended-polls-home-widget.title%</p> + <button onclick={ fetch } title="%i18n:desktop.tags.mk-recommended-polls-home-widget.refresh%"><i class="fa fa-refresh"></i></button> + </virtual> <div class="poll" if={ !loading && poll != null }> <p if={ poll.text }><a href="/{ poll.user.username }/{ poll.id }">{ poll.text }</a></p> <p if={ !poll.text }><a href="/{ poll.user.username }/{ poll.id }"><i class="fa fa-link"></i></a></p> @@ -12,6 +14,8 @@ :scope display block background #fff + border solid 1px rgba(0, 0, 0, 0.075) + border-radius 6px > .title margin 0 @@ -70,7 +74,11 @@ </style> <script> - this.mixin('api'); + this.data = { + compact: false + }; + + this.mixin('widget'); this.poll = null; this.loading = true; @@ -102,5 +110,10 @@ }); }); }; + + this.func = () => { + this.data.compact = !this.data.compact; + this.save(); + }; </script> </mk-recommended-polls-home-widget> 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 e9b740762e..fe04ee0e20 100644 --- a/src/web/app/desktop/tags/home-widgets/rss-reader.tag +++ b/src/web/app/desktop/tags/home-widgets/rss-reader.tag @@ -1,6 +1,8 @@ <mk-rss-reader-home-widget> - <p class="title"><i class="fa fa-rss-square"></i>RSS</p> - <button onclick={ settings } title="設定"><i class="fa fa-cog"></i></button> + <virtual if={ !data.compact }> + <p class="title"><i class="fa fa-rss-square"></i>RSS</p> + <button onclick={ settings } title="設定"><i class="fa fa-cog"></i></button> + </virtual> <div class="feed" if={ !initializing }> <virtual each={ item in items }><a href={ item.link } target="_blank">{ item.title }</a></virtual> </div> @@ -9,6 +11,8 @@ :scope display block background #fff + border solid 1px rgba(0, 0, 0, 0.075) + border-radius 6px > .title margin 0 @@ -62,6 +66,12 @@ </style> <script> + this.data = { + compact: false + }; + + this.mixin('widget'); + this.url = 'http://news.yahoo.co.jp/pickup/rss.xml'; this.items = []; this.initializing = true; @@ -88,5 +98,10 @@ this.settings = () => { }; + + this.func = () => { + this.data.compact = !this.data.compact; + this.save(); + }; </script> </mk-rss-reader-home-widget> diff --git a/src/web/app/desktop/tags/home-widgets/server.tag b/src/web/app/desktop/tags/home-widgets/server.tag index bc8f313d53..b37d347361 100644 --- a/src/web/app/desktop/tags/home-widgets/server.tag +++ b/src/web/app/desktop/tags/home-widgets/server.tag @@ -1,17 +1,25 @@ -<mk-server-home-widget> - <p class="title"><i class="fa fa-server"></i>%i18n:desktop.tags.mk-server-home-widget.title%</p> - <button onclick={ toggle } title="%i18n:desktop.tags.mk-server-home-widget.toggle%"><i class="fa fa-sort"></i></button> +<mk-server-home-widget data-melt={ data.design == 2 }> + <virtual if={ data.design == 0 }> + <p class="title"><i class="fa fa-server"></i>%i18n:desktop.tags.mk-server-home-widget.title%</p> + <button onclick={ toggle } title="%i18n:desktop.tags.mk-server-home-widget.toggle%"><i class="fa fa-sort"></i></button> + </virtual> <p class="initializing" if={ initializing }><i class="fa fa-spinner fa-pulse fa-fw"></i>%i18n:common.loading%<mk-ellipsis/></p> - <mk-server-home-widget-cpu-and-memory-usage if={ !initializing } show={ view == 0 } connection={ connection }/> - <mk-server-home-widget-cpu if={ !initializing } show={ view == 1 } connection={ connection } meta={ meta }/> - <mk-server-home-widget-memory if={ !initializing } show={ view == 2 } connection={ connection }/> - <mk-server-home-widget-disk if={ !initializing } show={ view == 3 } connection={ connection }/> - <mk-server-home-widget-uptimes if={ !initializing } show={ view == 4 } connection={ connection }/> - <mk-server-home-widget-info if={ !initializing } show={ view == 5 } connection={ connection } meta={ meta }/> + <mk-server-home-widget-cpu-and-memory-usage if={ !initializing } show={ data.view == 0 } connection={ connection }/> + <mk-server-home-widget-cpu if={ !initializing } show={ data.view == 1 } connection={ connection } meta={ meta }/> + <mk-server-home-widget-memory if={ !initializing } show={ data.view == 2 } connection={ connection }/> + <mk-server-home-widget-disk if={ !initializing } show={ data.view == 3 } connection={ connection }/> + <mk-server-home-widget-uptimes if={ !initializing } show={ data.view == 4 } connection={ connection }/> + <mk-server-home-widget-info if={ !initializing } show={ data.view == 5 } connection={ connection } meta={ meta }/> <style> :scope display block background #fff + border solid 1px rgba(0, 0, 0, 0.075) + border-radius 6px + + &[data-melt] + background transparent !important + border none !important > .title z-index 1 @@ -54,16 +62,23 @@ </style> <script> - import Connection from '../../../common/scripts/server-stream'; + this.mixin('os'); + + this.data = { + view: 0, + design: 0 + }; + + this.mixin('widget'); - this.mixin('api'); + this.mixin('server-stream'); + this.connection = this.serverStream.getConnection(); + this.connectionId = this.serverStream.use(); this.initializing = true; - this.view = 0; - this.connection = new Connection(); this.on('mount', () => { - this.api('meta').then(meta => { + this.mios.getMeta().then(meta => { this.update({ initializing: false, meta @@ -72,12 +87,20 @@ }); this.on('unmount', () => { - this.connection.close(); + this.serverStream.dispose(this.connectionId); }); this.toggle = () => { - this.view++; - if (this.view == 6) this.view = 0; + this.data.view++; + if (this.data.view == 6) this.data.view = 0; + + // Save widget state + this.save(); + }; + + this.func = () => { + if (++this.data.design == 3) this.data.design = 0; + this.save(); }; </script> </mk-server-home-widget> @@ -164,7 +187,7 @@ clear both </style> <script> - import uuid from '../../../common/scripts/uuid'; + import uuid from 'uuid'; this.viewBoxX = 50; this.viewBoxY = 30; diff --git a/src/web/app/desktop/tags/home-widgets/slideshow.tag b/src/web/app/desktop/tags/home-widgets/slideshow.tag new file mode 100644 index 0000000000..4acb680e42 --- /dev/null +++ b/src/web/app/desktop/tags/home-widgets/slideshow.tag @@ -0,0 +1,151 @@ +<mk-slideshow-home-widget> + <div onclick={ choose }> + <p if={ data.folder === undefined }>クリックしてフォルダを指定してください</p> + <p if={ data.folder !== undefined && images.length == 0 && !fetching }>このフォルダには画像がありません</p> + <div ref="slideA" class="slide a"></div> + <div ref="slideB" class="slide b"></div> + </div> + <button onclick={ resize }><i class="fa fa-expand"></i></button> + <style> + :scope + display block + overflow hidden + background #fff + border solid 1px rgba(0, 0, 0, 0.075) + border-radius 6px + + &:hover > button + display block + + > button + position absolute + left 0 + bottom 0 + display none + padding 4px + font-size 24px + color #fff + text-shadow 0 0 8px #000 + + > div + width 100% + height 100% + cursor pointer + + > * + pointer-events none + + > .slide + position absolute + top 0 + left 0 + width 100% + height 100% + background-size cover + background-position center + + &.b + opacity 0 + + </style> + <script> + import anime from 'animejs'; + + this.data = { + folder: undefined, + size: 0 + }; + + this.mixin('widget'); + + this.images = []; + this.fetching = true; + + this.on('mount', () => { + this.applySize(); + + if (this.data.folder !== undefined) { + this.fetch(); + } + + this.clock = setInterval(this.change, 10000); + }); + + this.on('unmount', () => { + clearInterval(this.clock); + }); + + this.applySize = () => { + let h; + + if (this.data.size == 1) { + h = 250; + } else { + h = 170; + } + + this.root.style.height = `${h}px`; + }; + + this.resize = () => { + this.data.size++; + if (this.data.size == 2) this.data.size = 0; + + this.applySize(); + this.save(); + }; + + this.change = () => { + if (this.images.length == 0) return; + + const index = Math.floor(Math.random() * this.images.length); + const img = `url(${ this.images[index].url }?thumbnail&size=1024)`; + + this.refs.slideB.style.backgroundImage = img; + + anime({ + targets: this.refs.slideB, + opacity: 1, + duration: 1000, + easing: 'linear', + complete: () => { + this.refs.slideA.style.backgroundImage = img; + anime({ + targets: this.refs.slideB, + opacity: 0, + duration: 0 + }); + } + }); + }; + + this.fetch = () => { + this.update({ + fetching: true + }); + + this.api('drive/files', { + folder_id: this.data.folder, + type: 'image/*', + limit: 100 + }).then(images => { + this.update({ + fetching: false, + images: images + }); + this.refs.slideA.style.backgroundImage = ''; + this.refs.slideB.style.backgroundImage = ''; + this.change(); + }); + }; + + this.choose = () => { + const i = riot.mount(document.body.appendChild(document.createElement('mk-select-folder-from-drive-window')))[0]; + i.one('selected', folder => { + this.data.folder = folder ? folder.id : null; + this.fetch(); + this.save(); + }); + }; + </script> +</mk-slideshow-home-widget> diff --git a/src/web/app/desktop/tags/home-widgets/timeline.tag b/src/web/app/desktop/tags/home-widgets/timeline.tag index 08d96ad715..c751069f74 100644 --- a/src/web/app/desktop/tags/home-widgets/timeline.tag +++ b/src/web/app/desktop/tags/home-widgets/timeline.tag @@ -3,12 +3,18 @@ <div class="loading" if={ isLoading }> <mk-ellipsis-icon/> </div> - <p class="empty" if={ isEmpty }><i class="fa fa-comments-o"></i>自分の投稿や、自分がフォローしているユーザーの投稿が表示されます。</p> - <mk-timeline ref="timeline"><yield to="footer"><i class="fa fa-moon-o" if={ !parent.moreLoading }></i><i class="fa fa-spinner fa-pulse fa-fw" if={ parent.moreLoading }></i></yield/> + <p class="empty" if={ isEmpty && !isLoading }><i class="fa fa-comments-o"></i>自分の投稿や、自分がフォローしているユーザーの投稿が表示されます。</p> + <mk-timeline ref="timeline" hide={ isLoading }> + <yield to="footer"> + <i class="fa fa-moon-o" if={ !parent.moreLoading }></i><i class="fa fa-spinner fa-pulse fa-fw" if={ parent.moreLoading }></i> + </yield/> + </mk-timeline> <style> :scope display block background #fff + border solid 1px rgba(0, 0, 0, 0.075) + border-radius 6px > mk-following-setuper border-bottom solid 1px #eee @@ -34,7 +40,10 @@ <script> this.mixin('i'); this.mixin('api'); + this.mixin('stream'); + this.connection = this.stream.getConnection(); + this.connectionId = this.stream.use(); this.isLoading = true; this.isEmpty = false; @@ -42,9 +51,9 @@ this.noFollowing = this.I.following_count == 0; this.on('mount', () => { - this.stream.on('post', this.onStreamPost); - this.stream.on('follow', this.onStreamFollow); - this.stream.on('unfollow', this.onStreamUnfollow); + this.connection.on('post', this.onStreamPost); + this.connection.on('follow', this.onStreamFollow); + this.connection.on('unfollow', this.onStreamUnfollow); document.addEventListener('keydown', this.onDocumentKeydown); window.addEventListener('scroll', this.onScroll); @@ -53,9 +62,10 @@ }); this.on('unmount', () => { - this.stream.off('post', this.onStreamPost); - this.stream.off('follow', this.onStreamFollow); - this.stream.off('unfollow', this.onStreamUnfollow); + this.connection.off('post', this.onStreamPost); + this.connection.off('follow', this.onStreamFollow); + this.connection.off('unfollow', this.onStreamUnfollow); + this.stream.dispose(this.connectionId); document.removeEventListener('keydown', this.onDocumentKeydown); window.removeEventListener('scroll', this.onScroll); @@ -70,7 +80,13 @@ }; this.load = (cb) => { - this.api('posts/timeline').then(posts => { + this.update({ + isLoading: true + }); + + this.api('posts/timeline', { + max_date: this.date ? this.date.getTime() : undefined + }).then(posts => { this.update({ isLoading: false, isEmpty: posts.length == 0 @@ -114,5 +130,13 @@ const current = window.scrollY + window.innerHeight; if (current > document.body.offsetHeight - 8) this.more(); }; + + this.warp = date => { + this.update({ + date: date + }); + + this.load(); + }; </script> </mk-timeline-home-widget> diff --git a/src/web/app/desktop/tags/home-widgets/timemachine.tag b/src/web/app/desktop/tags/home-widgets/timemachine.tag new file mode 100644 index 0000000000..3cddf53551 --- /dev/null +++ b/src/web/app/desktop/tags/home-widgets/timemachine.tag @@ -0,0 +1,23 @@ +<mk-timemachine-home-widget> + <mk-calendar-widget design={ data.design } warp={ warp }/> + <style> + :scope + display block + </style> + <script> + this.data = { + design: 0 + }; + + this.mixin('widget'); + + this.warp = date => { + this.opts.tl.warp(date); + }; + + this.func = () => { + if (++this.data.design == 6) this.data.design = 0; + this.save(); + }; + </script> +</mk-timemachine-home-widget> diff --git a/src/web/app/desktop/tags/home-widgets/tips.tag b/src/web/app/desktop/tags/home-widgets/tips.tag index 5a535099ab..81cea64643 100644 --- a/src/web/app/desktop/tags/home-widgets/tips.tag +++ b/src/web/app/desktop/tags/home-widgets/tips.tag @@ -3,8 +3,6 @@ <style> :scope display block - background transparent !important - border none !important overflow visible !important > p @@ -31,6 +29,8 @@ <script> import anime from 'animejs'; + this.mixin('widget'); + this.tips = [ '<kbd>t</kbd>でタイムラインにフォーカスできます', '<kbd>p</kbd>または<kbd>n</kbd>で投稿フォームを開きます', @@ -39,8 +39,24 @@ 'ドライブにファイルをドラッグ&ドロップしてアップロードできます', 'ドライブでファイルをドラッグしてフォルダ移動できます', 'ドライブでフォルダをドラッグしてフォルダ移動できます', - 'ホームをカスタマイズできます(準備中)', - 'MisskeyはMIT Licenseです' + 'ホームは設定からカスタマイズできます', + 'MisskeyはMIT Licenseです', + 'タイムマシンウィジェットを利用すると、簡単に過去のタイムラインに遡れます', + '投稿の ... をクリックして、投稿をユーザーページにピン留めできます', + 'ドライブの容量は(デフォルトで)1GBです', + '投稿に添付したファイルは全てドライブに保存されます', + 'ホームのカスタマイズ中、ウィジェットを右クリックしてデザインを変更できます', + 'タイムライン上部にもウィジェットを設置できます', + '投稿をダブルクリックすると詳細が見れます', + '「**」でテキストを囲むと**強調表示**されます', + 'チャンネルウィジェットを利用すると、よく利用するチャンネルを素早く確認できます', + 'いくつかのウィンドウはブラウザの外に切り離すことができます', + 'カレンダーウィジェットのパーセンテージは、経過の割合を示しています', + 'APIを利用してbotの開発なども行えます', + 'MisskeyはLINEを通じてでも利用できます', + 'まゆかわいいよまゆ', + 'Misskeyは2014年にサービスを開始しました', + '対応ブラウザではMisskeyを開いていなくても通知を受け取れます' ] this.on('mount', () => { diff --git a/src/web/app/desktop/tags/home-widgets/trends.tag b/src/web/app/desktop/tags/home-widgets/trends.tag index 021df3f728..8713c68746 100644 --- a/src/web/app/desktop/tags/home-widgets/trends.tag +++ b/src/web/app/desktop/tags/home-widgets/trends.tag @@ -1,6 +1,8 @@ <mk-trends-home-widget> - <p class="title"><i class="fa fa-fire"></i>%i18n:desktop.tags.mk-trends-home-widget.title%</p> - <button onclick={ fetch } title="%i18n:desktop.tags.mk-trends-home-widget.refresh%"><i class="fa fa-refresh"></i></button> + <virtual if={ !data.compact }> + <p class="title"><i class="fa fa-fire"></i>%i18n:desktop.tags.mk-trends-home-widget.title%</p> + <button onclick={ fetch } title="%i18n:desktop.tags.mk-trends-home-widget.refresh%"><i class="fa fa-refresh"></i></button> + </virtual> <div class="post" if={ !loading && post != null }> <p class="text"><a href="/{ post.user.username }/{ post.id }">{ post.text }</a></p> <p class="author">―<a href="/{ post.user.username }">@{ post.user.username }</a></p> @@ -11,6 +13,8 @@ :scope display block background #fff + border solid 1px rgba(0, 0, 0, 0.075) + border-radius 6px > .title margin 0 @@ -72,7 +76,11 @@ </style> <script> - this.mixin('api'); + this.data = { + compact: false + }; + + this.mixin('widget'); this.post = null; this.loading = true; @@ -108,5 +116,10 @@ }); }); }; + + this.func = () => { + this.data.compact = !this.data.compact; + this.save(); + }; </script> </mk-trends-home-widget> diff --git a/src/web/app/desktop/tags/home-widgets/user-recommendation.tag b/src/web/app/desktop/tags/home-widgets/user-recommendation.tag index f78d7944f1..cf563db535 100644 --- a/src/web/app/desktop/tags/home-widgets/user-recommendation.tag +++ b/src/web/app/desktop/tags/home-widgets/user-recommendation.tag @@ -1,6 +1,8 @@ <mk-user-recommendation-home-widget> - <p class="title"><i class="fa fa-users"></i>%i18n:desktop.tags.mk-user-recommendation-home-widget.title%</p> - <button onclick={ refresh } title="%i18n:desktop.tags.mk-user-recommendation-home-widget.refresh%"><i class="fa fa-refresh"></i></button> + <virtual if={ !data.compact }> + <p class="title"><i class="fa fa-users"></i>%i18n:desktop.tags.mk-user-recommendation-home-widget.title%</p> + <button onclick={ refresh } title="%i18n:desktop.tags.mk-user-recommendation-home-widget.refresh%"><i class="fa fa-refresh"></i></button> + </virtual> <div class="user" if={ !loading && users.length != 0 } each={ _user in users }> <a class="avatar-anchor" href={ '/' + _user.username }> <img class="avatar" src={ _user.avatar_url + '?thumbnail&size=42' } alt="" data-user-preview={ _user.id }/> @@ -17,6 +19,8 @@ :scope display block background #fff + border solid 1px rgba(0, 0, 0, 0.075) + border-radius 6px > .title margin 0 @@ -111,7 +115,11 @@ </style> <script> - this.mixin('api'); + this.data = { + compact: false + }; + + this.mixin('widget'); this.mixin('user-preview'); this.users = null; @@ -148,5 +156,10 @@ } this.fetch(); }; + + this.func = () => { + this.data.compact = !this.data.compact; + this.save(); + }; </script> </mk-user-recommendation-home-widget> diff --git a/src/web/app/desktop/tags/home-widgets/version.tag b/src/web/app/desktop/tags/home-widgets/version.tag index ea5307061c..2b66b0490e 100644 --- a/src/web/app/desktop/tags/home-widgets/version.tag +++ b/src/web/app/desktop/tags/home-widgets/version.tag @@ -1,10 +1,8 @@ <mk-version-home-widget> - <p>ver { version } (葵 aoi)</p> + <p>ver { _VERSION_ } (葵 aoi)</p> <style> :scope display block - background transparent !important - border none !important overflow visible !important > p @@ -17,6 +15,6 @@ </style> <script> - this.version = VERSION; + this.mixin('widget'); </script> </mk-version-home-widget> diff --git a/src/web/app/desktop/tags/home.tag b/src/web/app/desktop/tags/home.tag index 37b2d3cf7e..55f36e0977 100644 --- a/src/web/app/desktop/tags/home.tag +++ b/src/web/app/desktop/tags/home.tag @@ -1,50 +1,173 @@ -<mk-home> +<mk-home data-customize={ opts.customize }> + <div class="customize" if={ opts.customize }> + <a href="/"><i class="fa fa-check"></i>完了</a> + <div> + <div class="adder"> + <p>ウィジェットを追加:</p> + <select ref="widgetSelector"> + <option value="profile">プロフィール</option> + <option value="calendar">カレンダー</option> + <option value="timemachine">カレンダー(タイムマシン)</option> + <option value="activity">アクティビティ</option> + <option value="rss-reader">RSSリーダー</option> + <option value="trends">トレンド</option> + <option value="photo-stream">フォトストリーム</option> + <option value="slideshow">スライドショー</option> + <option value="version">バージョン</option> + <option value="broadcast">ブロードキャスト</option> + <option value="notifications">通知</option> + <option value="user-recommendation">おすすめユーザー</option> + <option value="recommended-polls">投票</option> + <option value="post-form">投稿フォーム</option> + <option value="messaging">メッセージ</option> + <option value="channel">チャンネル</option> + <option value="access-log">アクセスログ</option> + <option value="server">サーバー情報</option> + <option value="donation">寄付のお願い</option> + <option value="nav">ナビゲーション</option> + <option value="tips">ヒント</option> + </select> + <button onclick={ addWidget }>追加</button> + </div> + <div class="trash"> + <div ref="trash"></div> + <p>ゴミ箱</p> + </div> + </div> + </div> <div class="main"> - <div class="left" ref="left"></div> - <main> + <div class="left"> + <div ref="left" data-place="left"></div> + </div> + <main ref="main"> + <div class="maintop" ref="maintop" data-place="main" if={ opts.customize }></div> <mk-timeline-home-widget ref="tl" if={ mode == 'timeline' }/> <mk-mentions-home-widget ref="tl" if={ mode == 'mentions' }/> </main> - <div class="right" ref="right"></div> + <div class="right"> + <div ref="right" data-place="right"></div> + </div> </div> <style> :scope display block + &[data-customize] + padding-top 48px + background-image url('/assets/desktop/grid.svg') + + > .main > main > *:not(.maintop) + cursor not-allowed + + > * + pointer-events none + + &:not([data-customize]) + > .main > *:empty + display none + + > .customize + position fixed + z-index 1000 + top 0 + left 0 + width 100% + height 48px + background #f7f7f7 + box-shadow 0 1px 1px rgba(0, 0, 0, 0.075) + + > a + display block + position absolute + z-index 1001 + top 0 + right 0 + padding 0 16px + line-height 48px + text-decoration none + color $theme-color-foreground + background $theme-color + transition background 0.1s ease + + &:hover + background lighten($theme-color, 10%) + + &:active + background darken($theme-color, 10%) + transition background 0s ease + + > i + margin-right 8px + + > div + display flex + margin 0 auto + max-width 1200px - 32px + + > div + width 50% + + &.adder + > p + display inline + line-height 48px + + &.trash + border-left solid 1px #ddd + + > div + width 100% + height 100% + + > p + position absolute + top 0 + left 0 + width 100% + line-height 48px + margin 0 + text-align center + pointer-events none + > .main + display flex + justify-content center margin 0 auto max-width 1200px - &:after - content "" - display block - clear both - > * - float left + .customize-container + cursor move - > * - display block - //border solid 1px #eaeaea - border solid 1px rgba(0, 0, 0, 0.075) - border-radius 6px - //box-shadow 0px 2px 16px rgba(0, 0, 0, 0.2) - - &:not(:last-child) - margin-bottom 16px + > * + pointer-events none > main padding 16px width calc(100% - 275px * 2) + > *:not(.maintop):not(:last-child) + > .maintop > *:not(:last-child) + margin-bottom 16px + + > .maintop + min-height 64px + margin-bottom 16px + > *:not(main) width 275px + > * + padding 16px 0 16px 0 + + > *:not(:last-child) + margin-bottom 16px + > .left - padding 16px 0 16px 16px + padding-left 16px > .right - padding 16px 16px 16px 0 + padding-right 16px @media (max-width 1100px) > *:not(main) @@ -58,72 +181,208 @@ </style> <script> + import uuid from 'uuid'; + import Sortable from 'sortablejs'; + import dialog from '../scripts/dialog'; + import ScrollFollower from '../scripts/scroll-follower'; + this.mixin('i'); + this.mixin('api'); this.mode = this.opts.mode || 'timeline'; - const _home = { - left: [ - 'profile', - 'calendar', - 'activity', - 'rss-reader', - 'trends', - 'photo-stream', - 'version' - ], - right: [ - 'broadcast', - 'notifications', - 'user-recommendation', - 'recommended-polls', - 'server', - 'donation', - 'nav', - 'tips' - ] - }; - this.home = []; + this.bakeHomeData = () => JSON.stringify(this.I.client_settings.home); + this.bakedHomeData = this.bakeHomeData(); + this.on('mount', () => { this.refs.tl.on('loaded', () => { this.trigger('loaded'); }); -/* - this.I.data.home.forEach(widget => { + + this.I.on('refreshed', this.onMeRefreshed); + + this.I.client_settings.home.forEach(widget => { try { - const el = document.createElement(`mk-${widget.name}-home-widget`); - switch (widget.place) { - case 'left': this.refs.left.appendChild(el); break; - case 'right': this.refs.right.appendChild(el); break; - } - this.home.push(riot.mount(el, { - id: widget.id, - data: widget.data - })[0]); + this.setWidget(widget); } catch (e) { // noop } }); -*/ - _home.left.forEach(widget => { - const el = document.createElement(`mk-${widget}-home-widget`); - this.refs.left.appendChild(el); - this.home.push(riot.mount(el)[0]); - }); - _home.right.forEach(widget => { - const el = document.createElement(`mk-${widget}-home-widget`); - this.refs.right.appendChild(el); - this.home.push(riot.mount(el)[0]); - }); + if (!this.opts.customize) { + if (this.refs.left.children.length == 0) { + this.refs.left.parentNode.removeChild(this.refs.left); + } + if (this.refs.right.children.length == 0) { + this.refs.right.parentNode.removeChild(this.refs.right); + } + } + + if (this.opts.customize) { + dialog('<i class="fa fa-info-circle"></i>カスタマイズのヒント', + '<p>ホームのカスタマイズでは、ウィジェットを追加/削除したり、ドラッグ&ドロップして並べ替えたりすることができます。</p>' + + '<p>一部のウィジェットは、<strong><strong>右</strong>クリック</strong>することで表示を変更することができます。</p>' + + '<p>ウィジェットを削除するには、ヘッダーの<strong>「ゴミ箱」</strong>と書かれたエリアにウィジェットをドラッグ&ドロップします。</p>' + + '<p>カスタマイズを終了するには、右上の「完了」をクリックします。</p>', + [{ + text: 'Got it!' + }]); + + const sortableOption = { + group: 'kyoppie', + animation: 150, + onMove: evt => { + const id = evt.dragged.getAttribute('data-widget-id'); + this.home.find(tag => tag.id == id).update({ place: evt.to.getAttribute('data-place') }); + }, + onSort: () => { + this.saveHome(); + } + }; + + new Sortable(this.refs.left, sortableOption); + new Sortable(this.refs.right, sortableOption); + new Sortable(this.refs.maintop, sortableOption); + new Sortable(this.refs.trash, Object.assign({}, sortableOption, { + onAdd: evt => { + const el = evt.item; + const id = el.getAttribute('data-widget-id'); + el.parentNode.removeChild(el); + this.I.client_settings.home = this.I.client_settings.home.filter(w => w.id != id); + this.saveHome(); + } + })); + } + + if (!this.opts.customize) { + this.scrollFollowerLeft = this.refs.left.parentNode ? new ScrollFollower(this.refs.left, this.root.getBoundingClientRect().top) : null; + this.scrollFollowerRight = this.refs.right.parentNode ? new ScrollFollower(this.refs.right, this.root.getBoundingClientRect().top) : null; + } }); this.on('unmount', () => { + this.I.off('refreshed', this.onMeRefreshed); + this.home.forEach(widget => { widget.unmount(); }); + + if (!this.opts.customize) { + if (this.scrollFollowerLeft) this.scrollFollowerLeft.dispose(); + if (this.scrollFollowerRight) this.scrollFollowerRight.dispose(); + } }); + + this.onMeRefreshed = () => { + if (this.bakedHomeData != this.bakeHomeData()) { + alert('別の場所でホームが編集されました。ページを再度読み込みすると編集が反映されます。'); + } + }; + + this.setWidget = (widget, prepend = false) => { + const el = document.createElement(`mk-${widget.name}-home-widget`); + + let actualEl; + + if (this.opts.customize) { + const container = document.createElement('div'); + container.classList.add('customize-container'); + container.setAttribute('data-widget-id', widget.id); + container.appendChild(el); + actualEl = container; + } else { + actualEl = el; + } + + switch (widget.place) { + case 'left': + if (prepend) { + this.refs.left.insertBefore(actualEl, this.refs.left.firstChild); + } else { + this.refs.left.appendChild(actualEl); + } + break; + case 'right': + if (prepend) { + this.refs.right.insertBefore(actualEl, this.refs.right.firstChild); + } else { + this.refs.right.appendChild(actualEl); + } + break; + case 'main': + if (this.opts.customize) { + this.refs.maintop.appendChild(actualEl); + } else { + this.refs.main.insertBefore(actualEl, this.refs.tl.root); + } + break; + } + + const tag = riot.mount(el, { + id: widget.id, + data: widget.data, + place: widget.place, + tl: this.refs.tl + })[0]; + + this.home.push(tag); + + if (this.opts.customize) { + actualEl.oncontextmenu = e => { + e.preventDefault(); + e.stopImmediatePropagation(); + if (tag.func) tag.func(); + return false; + }; + } + }; + + this.addWidget = () => { + const widget = { + name: this.refs.widgetSelector.options[this.refs.widgetSelector.selectedIndex].value, + id: uuid(), + place: 'left', + data: {} + }; + + this.I.client_settings.home.unshift(widget); + + this.setWidget(widget, true); + + this.saveHome(); + }; + + this.saveHome = () => { + const data = []; + + Array.from(this.refs.left.children).forEach(el => { + const id = el.getAttribute('data-widget-id'); + const widget = this.I.client_settings.home.find(w => w.id == id); + widget.place = 'left'; + data.push(widget); + }); + + Array.from(this.refs.right.children).forEach(el => { + const id = el.getAttribute('data-widget-id'); + const widget = this.I.client_settings.home.find(w => w.id == id); + widget.place = 'right'; + data.push(widget); + }); + + Array.from(this.refs.maintop.children).forEach(el => { + const id = el.getAttribute('data-widget-id'); + const widget = this.I.client_settings.home.find(w => w.id == id); + widget.place = 'main'; + data.push(widget); + }); + + this.api('i/update_home', { + home: data + }).then(() => { + this.I.update(); + }); + }; </script> </mk-home> diff --git a/src/web/app/desktop/tags/index.js b/src/web/app/desktop/tags/index.ts index 37fdfe37e4..3ec1d108aa 100644 --- a/src/web/app/desktop/tags/index.js +++ b/src/web/app/desktop/tags/index.ts @@ -12,6 +12,7 @@ require('./drive/nav-folder.tag'); require('./drive/browser-window.tag'); require('./drive/browser.tag'); require('./select-file-from-drive-window.tag'); +require('./select-folder-from-drive-window.tag'); require('./crop-window.tag'); require('./settings.tag'); require('./settings-window.tag'); @@ -38,6 +39,12 @@ require('./home-widgets/recommended-polls.tag'); require('./home-widgets/trends.tag'); require('./home-widgets/activity.tag'); require('./home-widgets/server.tag'); +require('./home-widgets/slideshow.tag'); +require('./home-widgets/channel.tag'); +require('./home-widgets/timemachine.tag'); +require('./home-widgets/post-form.tag'); +require('./home-widgets/access-log.tag'); +require('./home-widgets/messaging.tag'); require('./timeline.tag'); require('./messaging/window.tag'); require('./messaging/room-window.tag'); @@ -45,23 +52,19 @@ require('./following-setuper.tag'); require('./ellipsis-icon.tag'); require('./ui.tag'); require('./home.tag'); -require('./user-header.tag'); -require('./user-profile.tag'); require('./user-timeline.tag'); require('./user.tag'); -require('./user-home.tag'); -require('./user-graphs.tag'); -require('./user-photos.tag'); require('./big-follow-button.tag'); require('./pages/entrance.tag'); -require('./pages/entrance/signin.tag'); -require('./pages/entrance/signup.tag'); require('./pages/home.tag'); +require('./pages/home-customize.tag'); require('./pages/user.tag'); require('./pages/post.tag'); require('./pages/search.tag'); require('./pages/not-found.tag'); require('./pages/selectdrive.tag'); +require('./pages/drive.tag'); +require('./pages/messaging-room.tag'); require('./autocomplete-suggestion.tag'); require('./progress-dialog.tag'); require('./user-preview.tag'); @@ -83,3 +86,5 @@ require('./user-following-window.tag'); require('./user-followers-window.tag'); require('./list-user.tag'); require('./detailed-post-window.tag'); +require('./widgets/calendar.tag'); +require('./widgets/activity.tag'); diff --git a/src/web/app/desktop/tags/messaging/room-window.tag b/src/web/app/desktop/tags/messaging/room-window.tag index 5d8a4303a5..1c6ff7c4bc 100644 --- a/src/web/app/desktop/tags/messaging/room-window.tag +++ b/src/web/app/desktop/tags/messaging/room-window.tag @@ -1,5 +1,5 @@ <mk-messaging-room-window> - <mk-window ref="window" is-modal={ false } width={ '500px' } height={ '560px' }> + <mk-window ref="window" is-modal={ false } width={ '500px' } height={ '560px' } popout={ popout }> <yield to="header"><i class="fa fa-comments"></i>メッセージ: { parent.user.name }</yield> <yield to="content"> <mk-messaging-room user={ parent.user }/> @@ -21,6 +21,8 @@ <script> this.user = this.opts.user; + this.popout = `${_URL_}/i/messaging/${this.user.username}`; + this.on('mount', () => { this.refs.window.on('closed', () => { this.unmount(); diff --git a/src/web/app/desktop/tags/notifications.tag b/src/web/app/desktop/tags/notifications.tag index a4f66105a8..d7855363ea 100644 --- a/src/web/app/desktop/tags/notifications.tag +++ b/src/web/app/desktop/tags/notifications.tag @@ -212,9 +212,12 @@ this.mixin('i'); this.mixin('api'); - this.mixin('stream'); this.mixin('user-preview'); + this.mixin('stream'); + this.connection = this.stream.getConnection(); + this.connectionId = this.stream.use(); + this.notifications = []; this.loading = true; @@ -235,11 +238,12 @@ }); }); - this.stream.on('notification', this.onNotification); + this.connection.on('notification', this.onNotification); }); this.on('unmount', () => { - this.stream.off('notification', this.onNotification); + this.connection.off('notification', this.onNotification); + this.stream.dispose(this.connectionId); }); this.on('update', () => { @@ -253,7 +257,7 @@ this.onNotification = notification => { // TODO: ユーザーが画面を見てないと思われるとき(ブラウザやタブがアクティブじゃないなど)は送信しない - this.stream.send({ + this.connection.send({ type: 'read_notification', id: notification.id }); diff --git a/src/web/app/desktop/tags/pages/drive.tag b/src/web/app/desktop/tags/pages/drive.tag new file mode 100644 index 0000000000..9f3e75ab21 --- /dev/null +++ b/src/web/app/desktop/tags/pages/drive.tag @@ -0,0 +1,37 @@ +<mk-drive-page> + <mk-drive-browser ref="browser" folder={ opts.folder }/> + <style> + :scope + display block + position fixed + width 100% + height 100% + background #fff + + > mk-drive-browser + height 100% + </style> + <script> + this.on('mount', () => { + document.title = 'Misskey Drive'; + + this.refs.browser.on('move-root', () => { + const title = 'Misskey Drive'; + + // Rewrite URL + history.pushState(null, title, '/i/drive'); + + document.title = title; + }); + + this.refs.browser.on('open-folder', folder => { + const title = folder.name + ' | Misskey Drive'; + + // Rewrite URL + history.pushState(null, title, '/i/drive/folder/' + folder.id); + + document.title = title; + }); + }); + </script> +</mk-drive-page> diff --git a/src/web/app/desktop/tags/pages/entrance.tag b/src/web/app/desktop/tags/pages/entrance.tag index 7ad19c073e..02aeb922fe 100644 --- a/src/web/app/desktop/tags/pages/entrance.tag +++ b/src/web/app/desktop/tags/pages/entrance.tag @@ -1,16 +1,25 @@ <mk-entrance> <main> - <img src="/assets/title.svg" alt="Misskey"/> - <mk-entrance-signin if={ mode == 'signin' }/> - <mk-entrance-signup if={ mode == 'signup' }/> - <div class="introduction" if={ mode == 'introduction' }> - <mk-introduction/> - <button onclick={ signin }>わかった</button> + <div> + <h1>どこにいても、ここにあります</h1> + <p>ようこそ! MisskeyはTwitter風ミニブログSNSです――思ったこと、共有したいことをシンプルに書き残せます。タイムラインを見れば、皆の反応や皆がどう思っているのかもすぐにわかります。</p> + <p if={ stats }>これまでに{ stats.posts_count }投稿されました</p> + </div> + <div> + <mk-entrance-signin if={ mode == 'signin' }/> + <mk-entrance-signup if={ mode == 'signup' }/> + <div class="introduction" if={ mode == 'introduction' }> + <mk-introduction/> + <button onclick={ signin }>わかった</button> + </div> </div> </main> <mk-forkit/> <footer> - <mk-copyright/> + <div> + <mk-nav-links/> + <mk-copyright/> + </div> </footer> <!-- ↓ https://github.com/riot/riot/issues/2134 (将来的)--> <style data-disable-scope="data-disable-scope"> @@ -21,66 +30,105 @@ </style> <style> :scope + $width = 1000px + display block - height 100% + + &:before + content "" + display block + position fixed + width 100% + height 100% + background rgba(0, 0, 0, 0.3) > main display block + max-width $width + margin 0 auto + padding 64px 0 0 0 padding-bottom 16px - > img + &:after + content "" display block - width 160px - height 170px - margin 0 auto - pointer-events none - user-select none + clear both - > .introduction - max-width 360px - margin 0 auto - color #777 + > div:first-child + position absolute + top 64px + left 0 + width calc(100% - 500px) + color #fff + text-shadow 0 0 32px rgba(0, 0, 0, 0.5) + font-weight bold - > mk-introduction - padding 32px - background #fff - box-shadow 0 4px 16px rgba(0, 0, 0, 0.2) + > p:last-child + padding 1em 0 0 0 + border-top solid 1px #fff - > button - display block - margin 16px auto 0 auto - color #666 + > div:last-child + float right - &:hover - text-decoration underline + > .introduction + max-width 360px + margin 0 auto + color #777 - > .tl - padding 32px 0 - background #fff + > mk-introduction + padding 32px + background #fff + box-shadow 0 4px 16px rgba(0, 0, 0, 0.2) - > h2 - display block - margin 0 - padding 0 - text-align center - font-size 20px - color #5b6b73 + > button + display block + margin 16px auto 0 auto + color #666 + + &:hover + text-decoration underline - > mk-public-timeline - max-width 500px - margin 0 auto > footer - > mk-copyright - margin 0 + * + color #fff !important + text-shadow 0 0 8px #000 + font-weight bold + + > div + max-width $width + margin 0 auto + padding 16px 0 text-align center - line-height 64px - font-size 10px - color rgba(#000, 0.5) + border-top solid 1px #fff + + > mk-copyright + margin 0 + line-height 64px + font-size 10px </style> <script> + this.mixin('api'); + this.mode = 'signin'; + this.on('mount', () => { + document.documentElement.style.backgroundColor = '#444'; + + this.api('meta').then(meta => { + const img = meta.top_image ? meta.top_image : '/assets/desktop/index.jpg'; + document.documentElement.style.backgroundImage = `url("${ img }")`; + document.documentElement.style.backgroundSize = 'cover'; + document.documentElement.style.backgroundPosition = 'center'; + }); + + this.api('stats').then(stats => { + this.update({ + stats + }); + }); + }); + this.signup = () => { this.update({ mode: 'signup' @@ -100,3 +148,195 @@ }; </script> </mk-entrance> + +<mk-entrance-signin> + <a class="help" href={ _ABOUT_URL_ + '/help' } title="お困りですか?"><i class="fa fa-question"></i></a> + <div class="form"> + <h1><img if={ user } src={ user.avatar_url + '?thumbnail&size=32' }/> + <p>{ user ? user.name : 'アカウント' }</p> + </h1> + <mk-signin ref="signin"/> + </div> + <a href={ _API_URL_ + '/signin/twitter' }>Twitterでサインイン</a> + <div class="divider"><span>or</span></div> + <button class="signup" onclick={ parent.signup }>新規登録</button><a class="introduction" onclick={ introduction }>Misskeyについて</a> + <style> + :scope + display block + width 290px + margin 0 auto + text-align center + + &:hover + > .help + opacity 1 + + > .help + cursor pointer + display block + position absolute + top 0 + right 0 + z-index 1 + margin 0 + padding 0 + font-size 1.2em + color #999 + border none + outline none + background transparent + opacity 0 + transition opacity 0.1s ease + + &:hover + color #444 + + &:active + color #222 + + > i + padding 14px + + > .form + padding 10px 28px 16px 28px + background #fff + box-shadow 0px 4px 16px rgba(0, 0, 0, 0.2) + + > h1 + display block + margin 0 + padding 0 + height 54px + line-height 54px + text-align center + text-transform uppercase + font-size 1em + font-weight bold + color rgba(0, 0, 0, 0.5) + border-bottom solid 1px rgba(0, 0, 0, 0.1) + + > p + display inline + margin 0 + padding 0 + + > img + display inline-block + top 10px + width 32px + height 32px + margin-right 8px + border-radius 100% + + &[src=''] + display none + + > .divider + padding 16px 0 + text-align center + + &:before + &:after + content "" + display block + position absolute + top 50% + width 45% + height 1px + border-top solid 1px rgba(0, 0, 0, 0.1) + + &:before + left 0 + + &:after + right 0 + + > * + z-index 1 + padding 0 8px + color #fff + text-shadow 0 0 8px rgba(0, 0, 0, 0.5) + + > .signup + width 100% + line-height 56px + font-size 1em + color #fff + background $theme-color + border-radius 64px + + &:hover + background lighten($theme-color, 5%) + + &:active + background darken($theme-color, 5%) + + > .introduction + display inline-block + margin-top 16px + font-size 12px + color #666 + + </style> + <script> + this.on('mount', () => { + this.refs.signin.on('user', user => { + this.update({ + user: user + }); + }); + }); + + this.introduction = () => { + this.parent.introduction(); + }; + </script> +</mk-entrance-signin> + +<mk-entrance-signup> + <mk-signup/> + <button class="cancel" type="button" onclick={ parent.signin } title="キャンセル"><i class="fa fa-times"></i></button> + <style> + :scope + display block + width 368px + margin 0 auto + + &:hover + > .cancel + opacity 1 + + > mk-signup + padding 18px 32px 0 32px + background #fff + box-shadow 0px 4px 16px rgba(0, 0, 0, 0.2) + + > .cancel + cursor pointer + display block + position absolute + top 0 + right 0 + z-index 1 + margin 0 + padding 0 + font-size 1.2em + color #999 + border none + outline none + box-shadow none + background transparent + opacity 0 + transition opacity 0.1s ease + + &:hover + color #555 + + &:active + color #222 + + > i + padding 14px + + </style> +</mk-entrance-signup> diff --git a/src/web/app/desktop/tags/pages/entrance/signin.tag b/src/web/app/desktop/tags/pages/entrance/signin.tag deleted file mode 100644 index 6caa747c1c..0000000000 --- a/src/web/app/desktop/tags/pages/entrance/signin.tag +++ /dev/null @@ -1,134 +0,0 @@ -<mk-entrance-signin><a class="help" href={ CONFIG.aboutUrl + '/help' } title="お困りですか?"><i class="fa fa-question"></i></a> - <div class="form"> - <h1><img if={ user } src={ user.avatar_url + '?thumbnail&size=32' }/> - <p>{ user ? user.name : 'アカウント' }</p> - </h1> - <mk-signin ref="signin"/> - </div> - <div class="divider"><span>or</span></div> - <button class="signup" onclick={ parent.signup }>新規登録</button><a class="introduction" onclick={ introduction }>Misskeyについて</a> - <style> - :scope - display block - width 290px - margin 0 auto - text-align center - - &:hover - > .help - opacity 1 - - > .help - cursor pointer - display block - position absolute - top 0 - right 0 - z-index 1 - margin 0 - padding 0 - font-size 1.2em - color #999 - border none - outline none - background transparent - opacity 0 - transition opacity 0.1s ease - - &:hover - color #444 - - &:active - color #222 - - > i - padding 14px - - > .form - padding 10px 28px 16px 28px - background #fff - box-shadow 0px 4px 16px rgba(0, 0, 0, 0.2) - - > h1 - display block - margin 0 - padding 0 - height 54px - line-height 54px - text-align center - text-transform uppercase - font-size 1em - font-weight bold - color rgba(0, 0, 0, 0.5) - border-bottom solid 1px rgba(0, 0, 0, 0.1) - - > p - display inline - margin 0 - padding 0 - - > img - display inline-block - top 10px - width 32px - height 32px - margin-right 8px - border-radius 100% - - &[src=''] - display none - - > .divider - padding 16px 0 - text-align center - - &:after - content "" - display block - position absolute - top 50% - width 100% - height 1px - border-top solid 1px rgba(0, 0, 0, 0.1) - - > * - z-index 1 - padding 0 8px - color rgba(0, 0, 0, 0.5) - background #fdfdfd - - > .signup - width 100% - line-height 56px - font-size 1em - color #fff - background $theme-color - border-radius 64px - - &:hover - background lighten($theme-color, 5%) - - &:active - background darken($theme-color, 5%) - - > .introduction - display inline-block - margin-top 16px - font-size 12px - color #666 - - </style> - <script> - this.on('mount', () => { - this.refs.signin.on('user', user => { - this.update({ - user: user - }); - }); - }); - - this.introduction = () => { - this.parent.introduction(); - }; - </script> -</mk-entrance-signin> diff --git a/src/web/app/desktop/tags/pages/entrance/signup.tag b/src/web/app/desktop/tags/pages/entrance/signup.tag deleted file mode 100644 index 0722d82a65..0000000000 --- a/src/web/app/desktop/tags/pages/entrance/signup.tag +++ /dev/null @@ -1,47 +0,0 @@ -<mk-entrance-signup> - <mk-signup/> - <button class="cancel" type="button" onclick={ parent.signin } title="キャンセル"><i class="fa fa-times"></i></button> - <style> - :scope - display block - width 368px - margin 0 auto - - &:hover - > .cancel - opacity 1 - - > mk-signup - padding 18px 32px 0 32px - background #fff - box-shadow 0px 4px 16px rgba(0, 0, 0, 0.2) - - > .cancel - cursor pointer - display block - position absolute - top 0 - right 0 - z-index 1 - margin 0 - padding 0 - font-size 1.2em - color #999 - border none - outline none - box-shadow none - background transparent - opacity 0 - transition opacity 0.1s ease - - &:hover - color #555 - - &:active - color #222 - - > i - padding 14px - - </style> -</mk-entrance-signup> diff --git a/src/web/app/desktop/tags/pages/home-customize.tag b/src/web/app/desktop/tags/pages/home-customize.tag new file mode 100644 index 0000000000..457b8390e7 --- /dev/null +++ b/src/web/app/desktop/tags/pages/home-customize.tag @@ -0,0 +1,12 @@ +<mk-home-customize-page> + <mk-home ref="home" mode="timeline" customize={ true }/> + <style> + :scope + display block + </style> + <script> + this.on('mount', () => { + document.title = 'Misskey - ホームのカスタマイズ'; + }); + </script> +</mk-home-customize-page> diff --git a/src/web/app/desktop/tags/pages/home.tag b/src/web/app/desktop/tags/pages/home.tag index e8ba4023de..3c8f4ec570 100644 --- a/src/web/app/desktop/tags/pages/home.tag +++ b/src/web/app/desktop/tags/pages/home.tag @@ -12,10 +12,12 @@ this.mixin('i'); this.mixin('api'); + this.mixin('stream'); + this.connection = this.stream.getConnection(); + this.connectionId = this.stream.use(); this.unreadCount = 0; - this.page = this.opts.mode || 'timeline'; this.on('mount', () => { @@ -24,12 +26,14 @@ }); document.title = 'Misskey'; Progress.start(); - this.stream.on('post', this.onStreamPost); + + this.connection.on('post', this.onStreamPost); document.addEventListener('visibilitychange', this.windowOnVisibilitychange, false); }); this.on('unmount', () => { - this.stream.off('post', this.onStreamPost); + this.connection.off('post', this.onStreamPost); + this.stream.dispose(this.connectionId); document.removeEventListener('visibilitychange', this.windowOnVisibilitychange); }); diff --git a/src/web/app/desktop/tags/pages/messaging-room.tag b/src/web/app/desktop/tags/pages/messaging-room.tag new file mode 100644 index 0000000000..3c21b97501 --- /dev/null +++ b/src/web/app/desktop/tags/pages/messaging-room.tag @@ -0,0 +1,37 @@ +<mk-messaging-room-page> + <mk-messaging-room if={ user } user={ user } is-naked={ true }/> + + <style> + :scope + display block + background #fff + + </style> + <script> + import Progress from '../../../common/scripts/loading'; + + this.mixin('api'); + + this.fetching = true; + this.user = null; + + this.on('mount', () => { + Progress.start(); + + document.documentElement.style.background = '#fff'; + + this.api('users/show', { + username: this.opts.user + }).then(user => { + this.update({ + fetching: false, + user: user + }); + + document.title = 'メッセージ: ' + this.user.name; + + Progress.done(); + }); + }); + </script> +</mk-messaging-room-page> diff --git a/src/web/app/desktop/tags/pages/post.tag b/src/web/app/desktop/tags/pages/post.tag index f270b43ac2..4a9672c1ef 100644 --- a/src/web/app/desktop/tags/pages/post.tag +++ b/src/web/app/desktop/tags/pages/post.tag @@ -28,6 +28,7 @@ > mk-post-detail margin 0 auto + width 640px </style> <script> diff --git a/src/web/app/desktop/tags/pages/selectdrive.tag b/src/web/app/desktop/tags/pages/selectdrive.tag index 63fc588fac..9c3ac16eb1 100644 --- a/src/web/app/desktop/tags/pages/selectdrive.tag +++ b/src/web/app/desktop/tags/pages/selectdrive.tag @@ -1,15 +1,16 @@ <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> + <button class="upload" title="%i18n:desktop.tags.mk-selectdrive-page.upload%" onclick={ upload }><i class="fa fa-upload"></i></button> + <button class="cancel" onclick={ close }>%i18n:desktop.tags.mk-selectdrive-page.cancel%</button> + <button class="ok" onclick={ ok }>%i18n:desktop.tags.mk-selectdrive-page.ok%</button> </div> <style> :scope display block position fixed + width 100% height 100% background #fff @@ -130,7 +131,7 @@ this.multiple = q.get('multiple') == 'true' ? true : false; this.on('mount', () => { - document.documentElement.style.background = '#fff'; + document.title = '%i18n:desktop.tags.mk-selectdrive-page.title%'; this.refs.browser.on('selected', file => { this.files = [file]; diff --git a/src/web/app/desktop/tags/post-detail-sub.tag b/src/web/app/desktop/tags/post-detail-sub.tag index 8a0ada5f2a..e22386df91 100644 --- a/src/web/app/desktop/tags/post-detail-sub.tag +++ b/src/web/app/desktop/tags/post-detail-sub.tag @@ -129,7 +129,7 @@ this.refs.text.innerHTML = compile(tokens); - this.refs.text.children.forEach(e => { + Array.from(this.refs.text.children).forEach(e => { if (e.tagName == 'MK-URL') riot.mount(e); }); } diff --git a/src/web/app/desktop/tags/post-detail.tag b/src/web/app/desktop/tags/post-detail.tag index ce7f81e32c..585b1b8280 100644 --- a/src/web/app/desktop/tags/post-detail.tag +++ b/src/web/app/desktop/tags/post-detail.tag @@ -57,7 +57,7 @@ </button> </footer> </article> - <div class="replies"> + <div class="replies" if={ !compact }> <virtual each={ post in replies }> <mk-post-detail-sub post={ post }/> </virtual> @@ -68,7 +68,6 @@ display block margin 0 padding 0 - width 640px overflow hidden text-align left background #fff @@ -259,6 +258,7 @@ this.mixin('api'); this.mixin('user-preview'); + this.compact = this.opts.compact; this.contextFetching = false; this.context = null; this.post = this.opts.post; @@ -273,7 +273,7 @@ this.refs.text.innerHTML = compile(tokens); - this.refs.text.children.forEach(e => { + Array.from(this.refs.text.children).forEach(e => { if (e.tagName == 'MK-URL') riot.mount(e); }); @@ -288,14 +288,16 @@ } // Get replies - this.api('posts/replies', { - post_id: this.p.id, - limit: 8 - }).then(replies => { - this.update({ - replies: replies + if (!this.compact) { + this.api('posts/replies', { + post_id: this.p.id, + limit: 8 + }).then(replies => { + this.update({ + replies: replies + }); }); - }); + } }); this.reply = () => { diff --git a/src/web/app/desktop/tags/post-form.tag b/src/web/app/desktop/tags/post-form.tag index 5041078bee..f6d9ee3779 100644 --- a/src/web/app/desktop/tags/post-form.tag +++ b/src/web/app/desktop/tags/post-form.tag @@ -405,7 +405,22 @@ // ファイルだったら if (e.dataTransfer.files.length > 0) { - e.dataTransfer.files.forEach(this.upload); + Array.from(e.dataTransfer.files).forEach(this.upload); + return; + } + + // データ取得 + const data = e.dataTransfer.getData('text'); + if (data == null) return false; + + // パース + // TODO: Validate JSON + const obj = JSON.parse(data); + + // (ドライブの)ファイルだったら + if (obj.type == 'file') { + this.files.push(obj.file); + this.update(); } }; @@ -414,7 +429,7 @@ }; this.onpaste = e => { - e.clipboardData.items.forEach(item => { + Array.from(e.clipboardData.items).forEach(item => { if (item.kind == 'file') { this.upload(item.getAsFile()); } @@ -435,7 +450,7 @@ }; this.changeFile = () => { - this.refs.file.files.forEach(this.upload); + Array.from(this.refs.file.files).forEach(this.upload); }; this.upload = file => { diff --git a/src/web/app/desktop/tags/select-folder-from-drive-window.tag b/src/web/app/desktop/tags/select-folder-from-drive-window.tag new file mode 100644 index 0000000000..375f428bfc --- /dev/null +++ b/src/web/app/desktop/tags/select-folder-from-drive-window.tag @@ -0,0 +1,112 @@ +<mk-select-folder-from-drive-window> + <mk-window ref="window" is-modal={ true } width={ '800px' } height={ '500px' }> + <yield to="header"> + <mk-raw content={ parent.title }/> + </yield> + <yield to="content"> + <mk-drive-browser ref="browser"/> + <div> + <button class="cancel" onclick={ parent.close }>キャンセル</button> + <button class="ok" onclick={ parent.ok }>決定</button> + </div> + </yield> + </mk-window> + <style> + :scope + > mk-window + [data-yield='header'] + > mk-raw + > i + margin-right 4px + + [data-yield='content'] + > mk-drive-browser + height calc(100% - 72px) + + > div + height 72px + background lighten($theme-color, 95%) + + .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> + this.files = []; + + this.title = this.opts.title || '<i class="fa fa-folder-o"></i>フォルダを選択'; + + this.on('mount', () => { + this.refs.window.on('closed', () => { + this.unmount(); + }); + }); + + this.close = () => { + this.refs.window.close(); + }; + + this.ok = () => { + this.trigger('selected', this.refs.window.refs.browser.folder); + this.refs.window.close(); + }; + </script> +</mk-select-folder-from-drive-window> diff --git a/src/web/app/desktop/tags/settings.tag b/src/web/app/desktop/tags/settings.tag index eabddfb432..4c16f9eaa8 100644 --- a/src/web/app/desktop/tags/settings.tag +++ b/src/web/app/desktop/tags/settings.tag @@ -38,6 +38,7 @@ <section class="web" show={ page == 'web' }> <h1>デザイン</h1> + <a href="/i/customize-home">ホームをカスタマイズ</a> </section> <section class="web" show={ page == 'web' }> diff --git a/src/web/app/desktop/tags/sub-post-content.tag b/src/web/app/desktop/tags/sub-post-content.tag index c75ae2911c..86269fdbe9 100644 --- a/src/web/app/desktop/tags/sub-post-content.tag +++ b/src/web/app/desktop/tags/sub-post-content.tag @@ -45,7 +45,7 @@ const tokens = this.post.ast; this.refs.text.innerHTML = compile(tokens, false); - this.refs.text.children.forEach(e => { + Array.from(this.refs.text.children).forEach(e => { if (e.tagName == 'MK-URL') riot.mount(e); }); } diff --git a/src/web/app/desktop/tags/timeline.tag b/src/web/app/desktop/tags/timeline.tag index 44f3d5d8ec..13651dfa5f 100644 --- a/src/web/app/desktop/tags/timeline.tag +++ b/src/web/app/desktop/tags/timeline.tag @@ -112,7 +112,7 @@ </header> <div class="body"> <div class="text" ref="text"> - <p class="channel" if={ p.channel != null }><a href={ CONFIG.chUrl + '/' + p.channel.id } target="_blank">{ p.channel.title }</a>:</p> + <p class="channel" if={ p.channel != null }><a href={ _CH_URL_ + '/' + p.channel.id } target="_blank">{ p.channel.title }</a>:</p> <a class="reply" if={ p.reply }> <i class="fa fa-reply"></i> </a> @@ -430,9 +430,12 @@ this.mixin('i'); this.mixin('api'); - this.mixin('stream'); this.mixin('user-preview'); + this.mixin('stream'); + this.connection = this.stream.getConnection(); + this.connectionId = this.stream.use(); + this.isDetailOpened = false; this.set = post => { @@ -468,21 +471,21 @@ this.capture = withHandler => { if (this.SIGNIN) { - this.stream.send({ + this.connection.send({ type: 'capture', id: this.post.id }); - if (withHandler) this.stream.on('post-updated', this.onStreamPostUpdated); + if (withHandler) this.connection.on('post-updated', this.onStreamPostUpdated); } }; this.decapture = withHandler => { if (this.SIGNIN) { - this.stream.send({ + this.connection.send({ type: 'decapture', id: this.post.id }); - if (withHandler) this.stream.off('post-updated', this.onStreamPostUpdated); + if (withHandler) this.connection.off('post-updated', this.onStreamPostUpdated); } }; @@ -490,7 +493,7 @@ this.capture(true); if (this.SIGNIN) { - this.stream.on('_connected_', this.onStreamConnected); + this.connection.on('_connected_', this.onStreamConnected); } if (this.p.text) { @@ -498,7 +501,7 @@ this.refs.text.innerHTML = this.refs.text.innerHTML.replace('<p class="dummy"></p>', compile(tokens)); - this.refs.text.children.forEach(e => { + Array.from(this.refs.text.children).forEach(e => { if (e.tagName == 'MK-URL') riot.mount(e); }); @@ -515,7 +518,8 @@ this.on('unmount', () => { this.decapture(true); - this.stream.off('_connected_', this.onStreamConnected); + this.connection.off('_connected_', this.onStreamConnected); + this.stream.dispose(this.connectionId); }); this.reply = () => { diff --git a/src/web/app/desktop/tags/ui.tag b/src/web/app/desktop/tags/ui.tag index 3123c34f4f..047964fab4 100644 --- a/src/web/app/desktop/tags/ui.tag +++ b/src/web/app/desktop/tags/ui.tag @@ -37,7 +37,7 @@ </mk-ui> <mk-ui-header> - <mk-donation if={ SIGNIN && I.data.no_donation != 'true' }/> + <mk-donation if={ SIGNIN && I.client_settings.show_donation }/> <mk-special-message/> <div class="main"> <div class="backdrop"></div> @@ -75,8 +75,7 @@ width 100% height 48px backdrop-filter blur(12px) - //background-color rgba(255, 255, 255, 0.75) - background #1d2429 + background #f7f7f7 &:after content "" @@ -138,23 +137,28 @@ > input user-select text cursor auto - margin 0 + margin 8px 0 0 0 padding 6px 18px width 14em - height 48px + height 32px font-size 1em - line-height calc(48px - 12px) - background transparent + background rgba(0, 0, 0, 0.05) outline none //border solid 1px #ddd border none - border-radius 0 + border-radius 16px transition color 0.5s ease, border 0.5s ease font-family FontAwesome, sans-serif &::-webkit-input-placeholder color #9eaba8 + &:hover + background rgba(0, 0, 0, 0.08) + + &:focus + box-shadow 0 0 0 2px rgba($theme-color, 0.5) !important + </style> <script> this.mixin('page'); @@ -167,7 +171,7 @@ </mk-ui-header-search> <mk-ui-header-post-button> - <button onclick={ post } title="新規投稿"><i class="fa fa-pencil-square-o"></i></button> + <button onclick={ post } title="%i18n:desktop.tags.mk-ui-header-post-button.post%"><i class="fa fa-pencil"></i></button> <style> :scope display inline-block @@ -187,7 +191,7 @@ background $theme-color !important outline none border none - border-radius 2px + border-radius 4px transition background 0.1s ease cursor pointer @@ -210,7 +214,9 @@ </mk-ui-header-post-button> <mk-ui-header-notifications> - <button class="header" data-active={ isOpen } onclick={ toggle }><i class="fa fa-bell-o"></i></button> + <button data-active={ isOpen } onclick={ toggle } title="%i18n:desktop.tags.mk-ui-header-notifications.title%"> + <i class="fa fa-bell-o icon"></i><i class="fa fa-circle badge" if={ hasUnreadNotifications }></i> + </button> <div class="notifications" if={ isOpen }> <mk-notifications/> </div> @@ -219,7 +225,7 @@ display block float left - > .header + > button display block margin 0 padding 0 @@ -239,10 +245,16 @@ &:active color darken(#9eaba8, 30%) - > i + > .icon font-size 1.2em line-height 48px + > .badge + margin-left -5px + vertical-align super + font-size 10px + color $theme-color + > .notifications display block position absolute @@ -286,8 +298,53 @@ <script> import contains from '../../common/scripts/contains'; + this.mixin('i'); + this.mixin('api'); + + if (this.SIGNIN) { + this.mixin('stream'); + this.connection = this.stream.getConnection(); + this.connectionId = this.stream.use(); + } + this.isOpen = false; + this.on('mount', () => { + if (this.SIGNIN) { + this.connection.on('read_all_notifications', this.onReadAllNotifications); + this.connection.on('unread_notification', this.onUnreadNotification); + + // Fetch count of unread notifications + this.api('notifications/get_unread_count').then(res => { + if (res.count > 0) { + this.update({ + hasUnreadNotifications: true + }); + } + }); + } + }); + + this.on('unmount', () => { + if (this.SIGNIN) { + this.connection.off('read_all_notifications', this.onReadAllNotifications); + this.connection.off('unread_notification', this.onUnreadNotification); + this.stream.dispose(this.connectionId); + } + }); + + this.onReadAllNotifications = () => { + this.update({ + hasUnreadNotifications: false + }); + }; + + this.onUnreadNotification = () => { + this.update({ + hasUnreadNotifications: true + }); + }; + this.toggle = () => { this.isOpen ? this.close() : this.open(); }; @@ -322,7 +379,7 @@ <ul> <virtual if={ SIGNIN }> <li class="home { active: page == 'home' }"> - <a href={ CONFIG.url }> + <a href={ _URL_ }> <i class="fa fa-home"></i> <p>%i18n:desktop.tags.mk-ui-header-nav.home%</p> </a> @@ -336,7 +393,7 @@ </li> </virtual> <li class="ch"> - <a href={ CONFIG.chUrl } target="_blank"> + <a href={ _CH_URL_ } target="_blank"> <i class="fa fa-television"></i> <p>%i18n:desktop.tags.mk-ui-header-nav.ch%</p> </a> @@ -419,14 +476,19 @@ <script> this.mixin('i'); this.mixin('api'); - this.mixin('stream'); + + if (this.SIGNIN) { + this.mixin('stream'); + this.connection = this.stream.getConnection(); + this.connectionId = this.stream.use(); + } this.page = this.opts.page; this.on('mount', () => { if (this.SIGNIN) { - this.stream.on('read_all_messaging_messages', this.onReadAllMessagingMessages); - this.stream.on('unread_messaging_message', this.onUnreadMessagingMessage); + this.connection.on('read_all_messaging_messages', this.onReadAllMessagingMessages); + this.connection.on('unread_messaging_message', this.onUnreadMessagingMessage); // Fetch count of unread messaging messages this.api('messaging/unread').then(res => { @@ -441,8 +503,9 @@ this.on('unmount', () => { if (this.SIGNIN) { - this.stream.off('read_all_messaging_messages', this.onReadAllMessagingMessages); - this.stream.off('unread_messaging_message', this.onUnreadMessagingMessage); + this.connection.off('read_all_messaging_messages', this.onReadAllMessagingMessages); + this.connection.off('unread_messaging_message', this.onUnreadMessagingMessage); + this.stream.dispose(this.connectionId); } }); @@ -565,7 +628,7 @@ <p><i class="fa fa-cloud"></i>%i18n:desktop.tags.mk-ui-header-account.drive%<i class="fa fa-angle-right"></i></p> </li> <li> - <a href="/i>mentions"><i class="fa fa-at"></i>%i18n:desktop.tags.mk-ui-header-account.mentions%<i class="fa fa-angle-right"></i></a> + <a href="/i/mentions"><i class="fa fa-at"></i>%i18n:desktop.tags.mk-ui-header-account.mentions%<i class="fa fa-angle-right"></i></a> </li> </ul> <ul> diff --git a/src/web/app/desktop/tags/user-graphs.tag b/src/web/app/desktop/tags/user-graphs.tag deleted file mode 100644 index 0677d8c187..0000000000 --- a/src/web/app/desktop/tags/user-graphs.tag +++ /dev/null @@ -1,41 +0,0 @@ -<mk-user-graphs> - <section> - <h1>投稿</h1> - <mk-user-posts-graph user={ opts.user }/> - </section> - <section> - <h1>フォロー/フォロワー</h1> - <mk-user-friends-graph user={ opts.user }/> - </section> - <section> - <h1>いいね</h1> - <mk-user-likes-graph user={ opts.user }/> - </section> - <style> - :scope - display block - - > section - margin 16px 0 - background #fff - border solid 1px rgba(0, 0, 0, 0.1) - border-radius 4px - - > h1 - margin 0 0 8px 0 - padding 0 16px - line-height 40px - font-size 1em - color #666 - border-bottom solid 1px #eee - - > *:not(h1) - margin 0 auto 16px auto - - </style> - <script> - this.on('mount', () => { - this.trigger('loaded'); - }); - </script> -</mk-user-graphs> diff --git a/src/web/app/desktop/tags/user-header.tag b/src/web/app/desktop/tags/user-header.tag deleted file mode 100644 index ea7ea6bb37..0000000000 --- a/src/web/app/desktop/tags/user-header.tag +++ /dev/null @@ -1,147 +0,0 @@ -<mk-user-header data-is-dark-background={ user.banner_url != null }> - <div class="banner" ref="banner" style={ user.banner_url ? 'background-image: url(' + user.banner_url + '?thumbnail&size=1024)' : '' } onclick={ onUpdateBanner }></div><img class="avatar" src={ user.avatar_url + '?thumbnail&size=150' } alt="avatar"/> - <div class="title"> - <p class="name" href={ '/' + user.username }>{ user.name }</p> - <p class="username">@{ user.username }</p> - <p class="location" if={ user.profile.location }><i class="fa fa-map-marker"></i>{ user.profile.location }</p> - </div> - <footer> - <a href={ '/' + user.username }>投稿</a> - <a href={ '/' + user.username + '/media' }>メディア</a> - <a href={ '/' + user.username + '/graphs' }>グラフ</a> - </footer> - <style> - :scope - $footer-height = 58px - - display block - background #fff - - &[data-is-dark-background] - > .banner - background-color #383838 - - > .title - color #fff - background linear-gradient(transparent, rgba(0, 0, 0, 0.7)) - - > .name - text-shadow 0 0 8px #000 - - > .banner - height 280px - background-color #f5f5f5 - background-size cover - background-position center - - > .avatar - display block - position absolute - bottom 16px - left 16px - z-index 2 - width 150px - height 150px - margin 0 - border solid 3px #fff - border-radius 8px - box-shadow 1px 1px 3px rgba(0, 0, 0, 0.2) - - > .title - position absolute - bottom $footer-height - left 0 - width 100% - padding 0 0 8px 195px - color #656565 - font-family '游ゴシック', 'YuGothic', 'ヒラギノ角ゴ ProN W3', 'Hiragino Kaku Gothic ProN', 'Meiryo', 'メイリオ', sans-serif - - > .name - display block - margin 0 - line-height 40px - font-weight bold - font-size 2em - - > .username - > .location - display inline-block - margin 0 16px 0 0 - line-height 20px - opacity 0.8 - - > i - margin-right 4px - - > footer - z-index 1 - height $footer-height - padding-left 195px - background #fff - - > a - display inline-block - margin 0 - width 100px - line-height $footer-height - color #555 - - > button - display block - position absolute - top 0 - right 0 - margin 8px - padding 0 - width $footer-height - 16px - line-height $footer-height - 16px - 2px - font-size 1.2em - color #777 - border solid 1px #eee - border-radius 4px - - &:hover - color #555 - border solid 1px #ddd - - </style> - <script> - import updateBanner from '../scripts/update-banner'; - - this.mixin('i'); - - this.user = this.opts.user; - - this.on('mount', () => { - window.addEventListener('load', this.scroll); - window.addEventListener('scroll', this.scroll); - window.addEventListener('resize', this.scroll); - }); - - this.on('unmount', () => { - window.removeEventListener('load', this.scroll); - window.removeEventListener('scroll', this.scroll); - window.removeEventListener('resize', this.scroll); - }); - - this.scroll = () => { - const top = window.scrollY; - const height = 280/*px*/; - - const pos = 50 - ((top / height) * 50); - this.refs.banner.style.backgroundPosition = `center ${pos}%`; - - const blur = top / 32 - if (blur <= 10) this.refs.banner.style.filter = `blur(${blur}px)`; - }; - - this.onUpdateBanner = () => { - if (!this.SIGNIN || this.I.id != this.user.id) return; - - updateBanner(this.I, i => { - this.user.banner_url = i.banner_url; - this.update(); - }); - }; - </script> -</mk-user-header> diff --git a/src/web/app/desktop/tags/user-home.tag b/src/web/app/desktop/tags/user-home.tag deleted file mode 100644 index a879db5bb6..0000000000 --- a/src/web/app/desktop/tags/user-home.tag +++ /dev/null @@ -1,46 +0,0 @@ -<mk-user-home> - <div class="side"> - <mk-user-profile user={ user }/> - <mk-user-photos user={ user }/> - </div> - <main> - <mk-user-timeline ref="tl" user={ user }/> - </main> - <style> - :scope - display flex - justify-content center - - > * - > * - display block - //border solid 1px #eaeaea - border solid 1px rgba(0, 0, 0, 0.075) - border-radius 6px - - &:not(:last-child) - margin-bottom 16px - - > main - flex 1 1 560px - max-width 560px - margin 0 - padding 16px 0 16px 16px - - > .side - flex 1 1 270px - max-width 270px - margin 0 - padding 16px 0 16px 0 - - </style> - <script> - this.user = this.opts.user; - - this.on('mount', () => { - this.refs.tl.on('loaded', () => { - this.trigger('loaded'); - }); - }); - </script> -</mk-user-home> diff --git a/src/web/app/desktop/tags/user-photos.tag b/src/web/app/desktop/tags/user-photos.tag deleted file mode 100644 index dce1e50add..0000000000 --- a/src/web/app/desktop/tags/user-photos.tag +++ /dev/null @@ -1,91 +0,0 @@ -<mk-user-photos> - <p class="title"><i class="fa fa-camera"></i>フォト</p> - <p class="initializing" if={ initializing }><i class="fa fa-spinner fa-pulse fa-fw"></i>読み込んでいます<mk-ellipsis/></p> - <div class="stream" if={ !initializing && images.length > 0 }> - <virtual each={ image in images }> - <div class="img" style={ 'background-image: url(' + image.url + '?thumbnail&size=256)' }></div> - </virtual> - </div> - <p class="empty" if={ !initializing && images.length == 0 }>写真はありません</p> - <style> - :scope - display block - background #fff - - > .title - z-index 1 - margin 0 - padding 0 16px - line-height 42px - font-size 0.9em - font-weight bold - color #888 - box-shadow 0 1px rgba(0, 0, 0, 0.07) - - > i - margin-right 4px - - > .stream - display -webkit-flex - display -moz-flex - display -ms-flex - display flex - justify-content center - flex-wrap wrap - padding 8px - - > .img - flex 1 1 33% - width 33% - height 80px - background-position center center - background-size cover - background-clip content-box - border solid 2px transparent - - > .initializing - > .empty - margin 0 - padding 16px - text-align center - color #aaa - - > i - margin-right 4px - - </style> - <script> - import isPromise from '../../common/scripts/is-promise'; - - this.mixin('api'); - - this.images = []; - this.initializing = true; - this.user = null; - this.userPromise = isPromise(this.opts.user) - ? this.opts.user - : Promise.resolve(this.opts.user); - - this.on('mount', () => { - this.userPromise.then(user => { - this.update({ - user: user - }); - - this.api('users/posts', { - user_id: this.user.id, - with_media: true, - limit: 9 - }).then(posts => { - this.initializing = false; - posts.forEach(post => { - post.media.forEach(media => { - if (this.images.length < 9) this.images.push(media); - }); - }); - this.update(); - }); - }); - }); - </script> -</mk-user-photos> diff --git a/src/web/app/desktop/tags/user-profile.tag b/src/web/app/desktop/tags/user-profile.tag deleted file mode 100644 index 7472a47801..0000000000 --- a/src/web/app/desktop/tags/user-profile.tag +++ /dev/null @@ -1,102 +0,0 @@ -<mk-user-profile> - <div class="friend-form" if={ SIGNIN && I.id != user.id }> - <mk-big-follow-button user={ user }/> - <p class="followed" if={ user.is_followed }>フォローされています</p> - </div> - <div class="description" if={ user.description }>{ user.description }</div> - <div class="birthday" if={ user.profile.birthday }> - <p><i class="fa fa-birthday-cake"></i>{ user.profile.birthday.replace('-', '年').replace('-', '月') + '日' } ({ age(user.profile.birthday) }歳)</p> - </div> - <div class="twitter" if={ user.twitter }> - <p><i class="fa fa-twitter"></i><a href={ 'https://twitter.com/' + user.twitter.screen_name } target="_blank">@{ user.twitter.screen_name }</a></p> - </div> - <div class="status"> - <p class="posts-count"><i class="fa fa-angle-right"></i><a>{ user.posts_count }</a><b>ポスト</b></p> - <p class="following"><i class="fa fa-angle-right"></i><a onclick={ showFollowing }>{ user.following_count }</a>人を<b>フォロー</b></p> - <p class="followers"><i class="fa fa-angle-right"></i><a onclick={ showFollowers }>{ user.followers_count }</a>人の<b>フォロワー</b></p> - </div> - <style> - :scope - display block - background #fff - - > *:first-child - border-top none !important - - > .friend-form - padding 16px - border-top solid 1px #eee - - > mk-big-follow-button - width 100% - - > .followed - margin 12px 0 0 0 - padding 0 - text-align center - line-height 24px - font-size 0.8em - color #71afc7 - background #eefaff - border-radius 4px - - > .description - padding 16px - color #555 - border-top solid 1px #eee - - > .birthday - padding 16px - color #555 - border-top solid 1px #eee - - > p - margin 0 - - > i - margin-right 8px - - > .twitter - padding 16px - color #555 - border-top solid 1px #eee - - > p - margin 0 - - > i - margin-right 8px - - > .status - padding 16px - color #555 - border-top solid 1px #eee - - > p - margin 8px 0 - - > i - margin-left 8px - margin-right 8px - - </style> - <script> - this.age = require('s-age'); - - this.mixin('i'); - - this.user = this.opts.user; - - this.showFollowing = () => { - riot.mount(document.body.appendChild(document.createElement('mk-user-following-window')), { - user: this.user - }); - }; - - this.showFollowers = () => { - riot.mount(document.body.appendChild(document.createElement('mk-user-followers-window')), { - user: this.user - }); - }; - </script> -</mk-user-profile> diff --git a/src/web/app/desktop/tags/user-timeline.tag b/src/web/app/desktop/tags/user-timeline.tag index 08ab47b160..5df13c436c 100644 --- a/src/web/app/desktop/tags/user-timeline.tag +++ b/src/web/app/desktop/tags/user-timeline.tag @@ -91,6 +91,7 @@ this.fetch = cb => { this.api('users/posts', { user_id: this.user.id, + max_date: this.date ? this.date.getTime() : undefined, with_replies: this.mode == 'with-replies' }).then(posts => { this.update({ @@ -132,5 +133,13 @@ }); this.fetch(); }; + + this.warp = date => { + this.update({ + date: date + }); + + this.fetch(); + }; </script> </mk-user-timeline> diff --git a/src/web/app/desktop/tags/user.tag b/src/web/app/desktop/tags/user.tag index db4fd7cc73..5ec6ac7624 100644 --- a/src/web/app/desktop/tags/user.tag +++ b/src/web/app/desktop/tags/user.tag @@ -3,33 +3,18 @@ <header> <mk-user-header user={ user }/> </header> - <div class="body"> - <mk-user-home if={ page == 'home' } user={ user }/> - <mk-user-graphs if={ page == 'graphs' } user={ user }/> - </div> + <mk-user-home if={ page == 'home' } user={ user }/> + <mk-user-graphs if={ page == 'graphs' } user={ user }/> </div> <style> :scope display block - background #fff > .user > header - max-width 560px + 270px - margin 0 auto - padding 0 16px - > mk-user-header - border solid 1px rgba(0, 0, 0, 0.075) - border-top none - border-radius 0 0 6px 6px overflow hidden - > .body - max-width 560px + 270px - margin 0 auto - padding 0 16px - </style> <script> this.mixin('api'); @@ -52,3 +37,791 @@ }); </script> </mk-user> + +<mk-user-header data-is-dark-background={ user.banner_url != null }> + <div class="banner-container" style={ user.banner_url ? 'background-image: url(' + user.banner_url + '?thumbnail&size=2048)' : '' }> + <div class="banner" ref="banner" style={ user.banner_url ? 'background-image: url(' + user.banner_url + '?thumbnail&size=2048)' : '' } onclick={ onUpdateBanner }></div> + </div> + <div class="fade"></div> + <div class="container"> + <img class="avatar" src={ user.avatar_url + '?thumbnail&size=150' } alt="avatar"/> + <div class="title"> + <p class="name" href={ '/' + user.username }>{ user.name }</p> + <p class="username">@{ user.username }</p> + <p class="location" if={ user.profile.location }><i class="fa fa-map-marker"></i>{ user.profile.location }</p> + </div> + <footer> + <a href={ '/' + user.username } data-active={ parent.page == 'home' }><i class="fa fa-home"></i>概要</a> + <a href={ '/' + user.username + '/media' } data-active={ parent.page == 'media' }><i class="fa fa-picture-o"></i>メディア</a> + <a href={ '/' + user.username + '/graphs' } data-active={ parent.page == 'graphs' }><i class="fa fa-bar-chart"></i>グラフ</a> + </footer> + </div> + <style> + :scope + $banner-height = 320px + $footer-height = 58px + + display block + background #f7f7f7 + box-shadow 0 1px 1px rgba(0, 0, 0, 0.075) + + &[data-is-dark-background] + > .banner-container + > .banner + background-color #383838 + + > .fade + background linear-gradient(transparent, rgba(0, 0, 0, 0.7)) + + > .container + > .title + color #fff + + > .name + text-shadow 0 0 8px #000 + + > .banner-container + height $banner-height + overflow hidden + background-size cover + background-position center + + > .banner + height 100% + background-color #f5f5f5 + background-size cover + background-position center + + > .fade + $fade-hight = 78px + + position absolute + top ($banner-height - $fade-hight) + left 0 + width 100% + height $fade-hight + + > .container + max-width 1200px + margin 0 auto + + > .avatar + display block + position absolute + bottom 16px + left 16px + z-index 2 + width 160px + height 160px + margin 0 + border solid 3px #fff + border-radius 8px + box-shadow 1px 1px 3px rgba(0, 0, 0, 0.2) + + > .title + position absolute + bottom $footer-height + left 0 + width 100% + padding 0 0 8px 195px + color #656565 + font-family '游ゴシック', 'YuGothic', 'ヒラギノ角ゴ ProN W3', 'Hiragino Kaku Gothic ProN', 'Meiryo', 'メイリオ', sans-serif + + > .name + display block + margin 0 + line-height 40px + font-weight bold + font-size 2em + + > .username + > .location + display inline-block + margin 0 16px 0 0 + line-height 20px + opacity 0.8 + + > i + margin-right 4px + + > footer + z-index 1 + height $footer-height + padding-left 195px + + > a + display inline-block + margin 0 + padding 0 16px + height $footer-height + line-height $footer-height + color #555 + + &[data-active] + border-bottom solid 4px $theme-color + + > i + margin-right 6px + + > button + display block + position absolute + top 0 + right 0 + margin 8px + padding 0 + width $footer-height - 16px + line-height $footer-height - 16px - 2px + font-size 1.2em + color #777 + border solid 1px #eee + border-radius 4px + + &:hover + color #555 + border solid 1px #ddd + + </style> + <script> + import updateBanner from '../scripts/update-banner'; + + this.mixin('i'); + + this.user = this.opts.user; + + this.on('mount', () => { + window.addEventListener('load', this.scroll); + window.addEventListener('scroll', this.scroll); + window.addEventListener('resize', this.scroll); + }); + + this.on('unmount', () => { + window.removeEventListener('load', this.scroll); + window.removeEventListener('scroll', this.scroll); + window.removeEventListener('resize', this.scroll); + }); + + this.scroll = () => { + const top = window.scrollY; + + const z = 1.25; // 奥行き(小さいほど奥) + const pos = -(top / z); + this.refs.banner.style.backgroundPosition = `center calc(50% - ${pos}px)`; + + const blur = top / 32 + if (blur <= 10) this.refs.banner.style.filter = `blur(${blur}px)`; + }; + + this.onUpdateBanner = () => { + if (!this.SIGNIN || this.I.id != this.user.id) return; + + updateBanner(this.I, i => { + this.user.banner_url = i.banner_url; + this.update(); + }); + }; + </script> +</mk-user-header> + +<mk-user-profile> + <div class="friend-form" if={ SIGNIN && I.id != user.id }> + <mk-big-follow-button user={ user }/> + <p class="followed" if={ user.is_followed }>フォローされています</p> + </div> + <div class="description" if={ user.description }>{ user.description }</div> + <div class="birthday" if={ user.profile.birthday }> + <p><i class="fa fa-birthday-cake"></i>{ user.profile.birthday.replace('-', '年').replace('-', '月') + '日' } ({ age(user.profile.birthday) }歳)</p> + </div> + <div class="twitter" if={ user.twitter }> + <p><i class="fa fa-twitter"></i><a href={ 'https://twitter.com/' + user.twitter.screen_name } target="_blank">@{ user.twitter.screen_name }</a></p> + </div> + <div class="status"> + <p class="posts-count"><i class="fa fa-angle-right"></i><a>{ user.posts_count }</a><b>ポスト</b></p> + <p class="following"><i class="fa fa-angle-right"></i><a onclick={ showFollowing }>{ user.following_count }</a>人を<b>フォロー</b></p> + <p class="followers"><i class="fa fa-angle-right"></i><a onclick={ showFollowers }>{ user.followers_count }</a>人の<b>フォロワー</b></p> + </div> + <style> + :scope + display block + background #fff + border solid 1px rgba(0, 0, 0, 0.075) + border-radius 6px + + > *:first-child + border-top none !important + + > .friend-form + padding 16px + border-top solid 1px #eee + + > mk-big-follow-button + width 100% + + > .followed + margin 12px 0 0 0 + padding 0 + text-align center + line-height 24px + font-size 0.8em + color #71afc7 + background #eefaff + border-radius 4px + + > .description + padding 16px + color #555 + border-top solid 1px #eee + + > .birthday + padding 16px + color #555 + border-top solid 1px #eee + + > p + margin 0 + + > i + margin-right 8px + + > .twitter + padding 16px + color #555 + border-top solid 1px #eee + + > p + margin 0 + + > i + margin-right 8px + + > .status + padding 16px + color #555 + border-top solid 1px #eee + + > p + margin 8px 0 + + > i + margin-left 8px + margin-right 8px + + </style> + <script> + this.age = require('s-age'); + + this.mixin('i'); + + this.user = this.opts.user; + + this.showFollowing = () => { + riot.mount(document.body.appendChild(document.createElement('mk-user-following-window')), { + user: this.user + }); + }; + + this.showFollowers = () => { + riot.mount(document.body.appendChild(document.createElement('mk-user-followers-window')), { + user: this.user + }); + }; + </script> +</mk-user-profile> + +<mk-user-photos> + <p class="title"><i class="fa fa-camera"></i>%i18n:desktop.tags.mk-user.photos.title%</p> + <p class="initializing" if={ initializing }><i class="fa fa-spinner fa-pulse fa-fw"></i>%i18n:desktop.tags.mk-user.photos.loading%<mk-ellipsis/></p> + <div class="stream" if={ !initializing && images.length > 0 }> + <virtual each={ image in images }> + <div class="img" style={ 'background-image: url(' + image.url + '?thumbnail&size=256)' }></div> + </virtual> + </div> + <p class="empty" if={ !initializing && images.length == 0 }>%i18n:desktop.tags.mk-user.photos.no-photos%</p> + <style> + :scope + display block + background #fff + border solid 1px rgba(0, 0, 0, 0.075) + border-radius 6px + + > .title + z-index 1 + margin 0 + padding 0 16px + line-height 42px + font-size 0.9em + font-weight bold + color #888 + box-shadow 0 1px rgba(0, 0, 0, 0.07) + + > i + margin-right 4px + + > .stream + display -webkit-flex + display -moz-flex + display -ms-flex + display flex + justify-content center + flex-wrap wrap + padding 8px + + > .img + flex 1 1 33% + width 33% + height 80px + background-position center center + background-size cover + background-clip content-box + border solid 2px transparent + + > .initializing + > .empty + margin 0 + padding 16px + text-align center + color #aaa + + > i + margin-right 4px + + </style> + <script> + import isPromise from '../../common/scripts/is-promise'; + + this.mixin('api'); + + this.images = []; + this.initializing = true; + this.user = null; + this.userPromise = isPromise(this.opts.user) + ? this.opts.user + : Promise.resolve(this.opts.user); + + this.on('mount', () => { + this.userPromise.then(user => { + this.update({ + user: user + }); + + this.api('users/posts', { + user_id: this.user.id, + with_media: true, + limit: 9 + }).then(posts => { + this.initializing = false; + posts.forEach(post => { + post.media.forEach(media => { + if (this.images.length < 9) this.images.push(media); + }); + }); + this.update(); + }); + }); + }); + </script> +</mk-user-photos> + +<mk-user-frequently-replied-users> + <p class="title"><i class="fa fa-users"></i>%i18n:desktop.tags.mk-user.frequently-replied-users.title%</p> + <p class="initializing" if={ initializing }><i class="fa fa-spinner fa-pulse fa-fw"></i>%i18n:desktop.tags.mk-user.frequently-replied-users.loading%<mk-ellipsis/></p> + <div class="user" if={ !initializing && users.length != 0 } each={ _user in users }> + <a class="avatar-anchor" href={ '/' + _user.username }> + <img class="avatar" src={ _user.avatar_url + '?thumbnail&size=42' } alt="" data-user-preview={ _user.id }/> + </a> + <div class="body"> + <a class="name" href={ '/' + _user.username } data-user-preview={ _user.id }>{ _user.name }</a> + <p class="username">@{ _user.username }</p> + </div> + <mk-follow-button user={ _user }/> + </div> + <p class="empty" if={ !initializing && users.length == 0 }>%i18n:desktop.tags.mk-user.frequently-replied-users.no-users%</p> + <style> + :scope + display block + background #fff + border solid 1px rgba(0, 0, 0, 0.075) + border-radius 6px + + > .title + z-index 1 + margin 0 + padding 0 16px + line-height 42px + font-size 0.9em + font-weight bold + color #888 + box-shadow 0 1px rgba(0, 0, 0, 0.07) + + > i + margin-right 4px + + > .initializing + > .empty + margin 0 + padding 16px + text-align center + color #aaa + + > i + margin-right 4px + + > .user + padding 16px + border-bottom solid 1px #eee + + &:last-child + border-bottom none + + &:after + content "" + display block + clear both + + > .avatar-anchor + display block + float left + margin 0 12px 0 0 + + > .avatar + display block + width 42px + height 42px + margin 0 + border-radius 8px + vertical-align bottom + + > .body + float left + width calc(100% - 54px) + + > .name + margin 0 + font-size 16px + line-height 24px + color #555 + + > .username + display block + margin 0 + font-size 15px + line-height 16px + color #ccc + + > mk-follow-button + position absolute + top 16px + right 16px + + </style> + <script> + this.mixin('api'); + + this.user = this.opts.user; + this.initializing = true; + + this.on('mount', () => { + this.api('users/get_frequently_replied_users', { + user_id: this.user.id, + limit: 4 + }).then(docs => { + this.update({ + users: docs.map(doc => doc.user), + initializing: false + }); + }); + }); + </script> +</mk-user-frequently-replied-users> + +<mk-user-followers-you-know> + <p class="title"><i class="fa fa-users"></i>%i18n:desktop.tags.mk-user.followers-you-know.title%</p> + <p class="initializing" if={ initializing }><i class="fa fa-spinner fa-pulse fa-fw"></i>%i18n:desktop.tags.mk-user.followers-you-know.loading%<mk-ellipsis/></p> + <div if={ !initializing && users.length > 0 }> + <virtual each={ user in users }> + <a href={ '/' + user.username }><img src={ user.avatar_url + '?thumbnail&size=64' } alt={ user.name }/></a> + </virtual> + </div> + <p class="empty" if={ !initializing && users.length == 0 }>%i18n:desktop.tags.mk-user.followers-you-know.no-users%</p> + <style> + :scope + display block + background #fff + border solid 1px rgba(0, 0, 0, 0.075) + border-radius 6px + + > .title + z-index 1 + margin 0 + padding 0 16px + line-height 42px + font-size 0.9em + font-weight bold + color #888 + box-shadow 0 1px rgba(0, 0, 0, 0.07) + + > i + margin-right 4px + + > div + padding 8px + + > a + display inline-block + margin 4px + + > img + width 48px + height 48px + vertical-align bottom + border-radius 100% + + > .initializing + > .empty + margin 0 + padding 16px + text-align center + color #aaa + + > i + margin-right 4px + + </style> + <script> + this.mixin('api'); + + this.user = this.opts.user; + this.initializing = true; + + this.on('mount', () => { + this.api('users/followers', { + user_id: this.user.id, + iknow: true, + limit: 16 + }).then(x => { + this.update({ + users: x.users, + initializing: false + }); + }); + }); + </script> +</mk-user-followers-you-know> + +<mk-user-home> + <div> + <div ref="left"> + <mk-user-profile user={ user }/> + <mk-user-photos user={ user }/> + <mk-user-followers-you-know if={ SIGNIN && I.id !== user.id } user={ user }/> + <p>%i18n:desktop.tags.mk-user.last-used-at%: <b><mk-time time={ user.last_used_at }/></b></p> + </div> + </div> + <main> + <mk-post-detail if={ user.pinned_post } post={ user.pinned_post } compact={ true }/> + <mk-user-timeline ref="tl" user={ user }/> + </main> + <div> + <div ref="right"> + <mk-calendar-widget warp={ warp } start={ new Date(user.created_at) }/> + <mk-activity-widget user={ user }/> + <mk-user-frequently-replied-users user={ user }/> + <div class="nav"><mk-nav-links/></div> + </div> + </div> + <style> + :scope + display flex + justify-content center + margin 0 auto + max-width 1200px + + > main + > div > div + > *:not(:last-child) + margin-bottom 16px + + > main + padding 16px + width calc(100% - 275px * 2) + + > mk-user-timeline + border solid 1px rgba(0, 0, 0, 0.075) + border-radius 6px + + > div + width 275px + margin 0 + + &:first-child > div + padding 16px 0 16px 16px + + > p + display block + margin 0 + padding 0 12px + text-align center + font-size 0.8em + color #aaa + + &:last-child > div + padding 16px 16px 16px 0 + + > .nav + padding 16px + font-size 12px + color #aaa + background #fff + border solid 1px rgba(0, 0, 0, 0.075) + border-radius 6px + + a + color #999 + + i + color #ccc + + </style> + <script> + import ScrollFollower from '../scripts/scroll-follower'; + + this.mixin('i'); + + this.user = this.opts.user; + + this.on('mount', () => { + this.refs.tl.on('loaded', () => { + this.trigger('loaded'); + }); + + this.scrollFollowerLeft = new ScrollFollower(this.refs.left, this.parent.root.getBoundingClientRect().top); + this.scrollFollowerRight = new ScrollFollower(this.refs.right, this.parent.root.getBoundingClientRect().top); + }); + + this.on('unmount', () => { + this.scrollFollowerLeft.dispose(); + this.scrollFollowerRight.dispose(); + }); + + this.warp = date => { + this.refs.tl.warp(date); + }; + </script> +</mk-user-home> + +<mk-user-graphs> + <section> + <div> + <h1><i class="fa fa-pencil"></i>投稿</h1> + <mk-user-graphs-activity-chart user={ opts.user }/> + </div> + </section> + <section> + <div> + <h1>フォロー/フォロワー</h1> + <mk-user-friends-graph user={ opts.user }/> + </div> + </section> + <section> + <div> + <h1>いいね</h1> + <mk-user-likes-graph user={ opts.user }/> + </div> + </section> + <style> + :scope + display block + + > section + margin 16px 0 + color #666 + border-bottom solid 1px rgba(0, 0, 0, 0.1) + + > div + max-width 1200px + margin 0 auto + padding 0 16px + + > h1 + margin 0 0 16px 0 + padding 0 + font-size 1.3em + + > i + margin-right 8px + + </style> + <script> + this.on('mount', () => { + this.trigger('loaded'); + }); + </script> +</mk-user-graphs> + +<mk-user-graphs-activity-chart> + <svg if={ data } ref="canvas" viewBox="0 0 365 1" preserveAspectRatio="none"> + <g each={ d, i in data.reverse() }> + <rect width="0.8" riot-height={ d.postsH } + riot-x={ i + 0.1 } riot-y={ 1 - d.postsH - d.repliesH - d.repostsH } + fill="#41ddde"/> + <rect width="0.8" riot-height={ d.repliesH } + riot-x={ i + 0.1 } riot-y={ 1 - d.repliesH - d.repostsH } + fill="#f7796c"/> + <rect width="0.8" riot-height={ d.repostsH } + riot-x={ i + 0.1 } riot-y={ 1 - d.repostsH } + fill="#a1de41"/> + </g> + </svg> + <p>直近1年間分の統計です。一番右が現在で、一番左が1年前です。青は通常の投稿、赤は返信、緑はRepostをそれぞれ表しています。</p> + <p> + <span>だいたい*1日に<b>{ averageOfAllTypePostsEachDays }回</b>投稿(返信、Repost含む)しています。</span><br> + <span>だいたい*1日に<b>{ averageOfPostsEachDays }回</b>投稿(通常の)しています。</span><br> + <span>だいたい*1日に<b>{ averageOfRepliesEachDays }回</b>返信しています。</span><br> + <span>だいたい*1日に<b>{ averageOfRepostsEachDays }回</b>Repostしています。</span><br> + </p> + <p>* 中央値</p> + + <style> + :scope + display block + + > svg + display block + width 100% + height 180px + + > rect + transform-origin center + + </style> + <script> + import getMedian from '../../common/scripts/get-median'; + + this.mixin('api'); + + this.user = this.opts.user; + + this.on('mount', () => { + this.api('aggregation/users/activity', { + user_id: this.user.id, + limit: 365 + }).then(data => { + data.forEach(d => d.total = d.posts + d.replies + d.reposts); + this.peak = Math.max.apply(null, data.map(d => d.total)); + data.forEach(d => { + d.postsH = d.posts / this.peak; + d.repliesH = d.replies / this.peak; + d.repostsH = d.reposts / this.peak; + }); + + this.update({ + data, + averageOfAllTypePostsEachDays: getMedian(data.map(d => d.total)), + averageOfPostsEachDays: getMedian(data.map(d => d.posts)), + averageOfRepliesEachDays: getMedian(data.map(d => d.replies)), + averageOfRepostsEachDays: getMedian(data.map(d => d.reposts)) + }); + }); + }); + </script> +</mk-user-graphs-activity-chart> diff --git a/src/web/app/desktop/tags/widgets/activity.tag b/src/web/app/desktop/tags/widgets/activity.tag new file mode 100644 index 0000000000..baf385fe92 --- /dev/null +++ b/src/web/app/desktop/tags/widgets/activity.tag @@ -0,0 +1,246 @@ +<mk-activity-widget data-melt={ design == 2 }> + <virtual if={ design == 0 }> + <p class="title"><i class="fa fa-bar-chart"></i>%i18n:desktop.tags.mk-activity-widget.title%</p> + <button onclick={ toggle } title="%i18n:desktop.tags.mk-activity-widget.toggle%"><i class="fa fa-sort"></i></button> + </virtual> + <p class="initializing" if={ initializing }><i class="fa fa-spinner fa-pulse fa-fw"></i>%i18n:common.loading%<mk-ellipsis/></p> + <mk-activity-widget-calender if={ !initializing && view == 0 } data={ [].concat(activity) }/> + <mk-activity-widget-chart if={ !initializing && view == 1 } data={ [].concat(activity) }/> + <style> + :scope + display block + background #fff + border solid 1px rgba(0, 0, 0, 0.075) + border-radius 6px + + &[data-melt] + background transparent !important + border none !important + + > .title + z-index 1 + margin 0 + padding 0 16px + line-height 42px + font-size 0.9em + font-weight bold + color #888 + box-shadow 0 1px rgba(0, 0, 0, 0.07) + + > i + margin-right 4px + + > button + position absolute + z-index 2 + top 0 + right 0 + padding 0 + width 42px + font-size 0.9em + line-height 42px + color #ccc + + &:hover + color #aaa + + &:active + color #999 + + > .initializing + margin 0 + padding 16px + text-align center + color #aaa + + > i + margin-right 4px + + </style> + <script> + this.mixin('api'); + + this.design = this.opts.design || 0; + this.view = this.opts.view || 0; + + this.user = this.opts.user; + this.initializing = true; + + this.on('mount', () => { + this.api('aggregation/users/activity', { + user_id: this.user.id, + limit: 20 * 7 + }).then(activity => { + this.update({ + initializing: false, + activity + }); + }); + }); + + this.toggle = () => { + this.view++; + if (this.view == 2) this.view = 0; + this.update(); + this.trigger('view-changed', this.view); + }; + </script> +</mk-activity-widget> + +<mk-activity-widget-calender> + <svg viewBox="0 0 21 7" preserveAspectRatio="none"> + <rect each={ data } class="day" + width="1" height="1" + riot-x={ x } riot-y={ date.weekday } + rx="1" ry="1" + fill="transparent"> + <title>{ date.year }/{ date.month }/{ date.day }<br/>Post: { posts }, Reply: { replies }, Repost: { reposts }</title> + </rect> + <rect each={ data } + riot-width={ v } riot-height={ v } + riot-x={ x + ((1 - v) / 2) } riot-y={ date.weekday + ((1 - v) / 2) } + rx="1" ry="1" + fill={ color } + style="pointer-events: none;"/> + <rect class="today" + width="1" height="1" + riot-x={ data[data.length - 1].x } riot-y={ data[data.length - 1].date.weekday } + rx="1" ry="1" + fill="none" + stroke-width="0.1" + stroke="#f73520"/> + </svg> + <style> + :scope + display block + + > svg + display block + padding 10px + width 100% + + > rect + transform-origin center + + &.day + &:hover + fill rgba(0, 0, 0, 0.05) + + </style> + <script> + this.data = this.opts.data; + this.data.forEach(d => d.total = d.posts + d.replies + d.reposts); + const peak = Math.max.apply(null, this.data.map(d => d.total)); + + let x = 0; + this.data.reverse().forEach(d => { + d.x = x; + d.date.weekday = (new Date(d.date.year, d.date.month - 1, d.date.day)).getDay(); + + d.v = d.total / (peak / 2); + if (d.v > 1) d.v = 1; + const ch = d.date.weekday == 0 || d.date.weekday == 6 ? 275 : 170; + const cs = d.v * 100; + const cl = 15 + ((1 - d.v) * 80); + d.color = `hsl(${ch}, ${cs}%, ${cl}%)`; + + if (d.date.weekday == 6) x++; + }); + </script> +</mk-activity-widget-calender> + +<mk-activity-widget-chart> + <svg riot-viewBox="0 0 { viewBoxX } { viewBoxY }" preserveAspectRatio="none" onmousedown={ onMousedown }> + <title>Black ... Total<br/>Blue ... Posts<br/>Red ... Replies<br/>Green ... Reposts</title> + <polyline + riot-points={ pointsPost } + fill="none" + stroke-width="1" + stroke="#41ddde"/> + <polyline + riot-points={ pointsReply } + fill="none" + stroke-width="1" + stroke="#f7796c"/> + <polyline + riot-points={ pointsRepost } + fill="none" + stroke-width="1" + stroke="#a1de41"/> + <polyline + riot-points={ pointsTotal } + fill="none" + stroke-width="1" + stroke="#555" + stroke-dasharray="2 2"/> + </svg> + <style> + :scope + display block + + > svg + display block + padding 10px + width 100% + cursor all-scroll + </style> + <script> + this.viewBoxX = 140; + this.viewBoxY = 60; + this.zoom = 1; + this.pos = 0; + + this.data = this.opts.data.reverse(); + this.data.forEach(d => d.total = d.posts + d.replies + d.reposts); + const peak = Math.max.apply(null, this.data.map(d => d.total)); + + this.on('mount', () => { + this.render(); + }); + + this.render = () => { + this.update({ + pointsPost: this.data.map((d, i) => `${(i * this.zoom) + this.pos},${(1 - (d.posts / peak)) * this.viewBoxY}`).join(' '), + pointsReply: this.data.map((d, i) => `${(i * this.zoom) + this.pos},${(1 - (d.replies / peak)) * this.viewBoxY}`).join(' '), + pointsRepost: this.data.map((d, i) => `${(i * this.zoom) + this.pos},${(1 - (d.reposts / peak)) * this.viewBoxY}`).join(' '), + pointsTotal: this.data.map((d, i) => `${(i * this.zoom) + this.pos},${(1 - (d.total / peak)) * this.viewBoxY}`).join(' ') + }); + }; + + this.onMousedown = e => { + e.preventDefault(); + + const clickX = e.clientX; + const clickY = e.clientY; + const baseZoom = this.zoom; + const basePos = this.pos; + + // 動かした時 + dragListen(me => { + let moveLeft = me.clientX - clickX; + let moveTop = me.clientY - clickY; + + this.zoom = baseZoom + (-moveTop / 20); + this.pos = basePos + moveLeft; + if (this.zoom < 1) this.zoom = 1; + if (this.pos > 0) this.pos = 0; + if (this.pos < -(((this.data.length - 1) * this.zoom) - this.viewBoxX)) this.pos = -(((this.data.length - 1) * this.zoom) - this.viewBoxX); + + this.render(); + }); + }; + + function dragListen(fn) { + window.addEventListener('mousemove', fn); + window.addEventListener('mouseleave', dragClear.bind(null, fn)); + window.addEventListener('mouseup', dragClear.bind(null, fn)); + } + + function dragClear(fn) { + window.removeEventListener('mousemove', fn); + window.removeEventListener('mouseleave', dragClear); + window.removeEventListener('mouseup', dragClear); + } + </script> +</mk-activity-widget-chart> + diff --git a/src/web/app/desktop/tags/widgets/calendar.tag b/src/web/app/desktop/tags/widgets/calendar.tag new file mode 100644 index 0000000000..5f00d5cf29 --- /dev/null +++ b/src/web/app/desktop/tags/widgets/calendar.tag @@ -0,0 +1,241 @@ +<mk-calendar-widget data-melt={ opts.design == 4 || opts.design == 5 }> + <virtual if={ opts.design == 0 || opts.design == 1 }> + <button onclick={ prev } title="%i18n:desktop.tags.mk-calendar-widget.prev%"><i class="fa fa-chevron-circle-left"></i></button> + <p class="title">{ '%i18n:desktop.tags.mk-calendar-widget.title%'.replace('{1}', year).replace('{2}', month) }</p> + <button onclick={ next } title="%i18n:desktop.tags.mk-calendar-widget.next%"><i class="fa fa-chevron-circle-right"></i></button> + </virtual> + + <div class="calendar"> + <div class="weekday" if={ opts.design == 0 || opts.design == 2 || opts.design == 4} each={ day, i in Array(7).fill(0) } + data-today={ year == today.getFullYear() && month == today.getMonth() + 1 && today.getDay() == i } + data-is-donichi={ i == 0 || i == 6 }>{ weekdayText[i] }</div> + <div each={ day, i in Array(paddingDays).fill(0) }></div> + <div class="day" each={ day, i in Array(days).fill(0) } + data-today={ isToday(i + 1) } + data-selected={ isSelected(i + 1) } + data-is-out-of-range={ isOutOfRange(i + 1) } + data-is-donichi={ isDonichi(i + 1) } + onclick={ go.bind(null, i + 1) } + title={ isOutOfRange(i + 1) ? null : '%i18n:desktop.tags.mk-calendar-widget.go%' }><div>{ i + 1 }</div></div> + </div> + <style> + :scope + display block + color #777 + background #fff + border solid 1px rgba(0, 0, 0, 0.075) + border-radius 6px + + &[data-melt] + background transparent !important + border none !important + + > .title + z-index 1 + margin 0 + padding 0 16px + text-align center + line-height 42px + font-size 0.9em + font-weight bold + color #888 + box-shadow 0 1px rgba(0, 0, 0, 0.07) + + > i + margin-right 4px + + > button + position absolute + z-index 2 + top 0 + padding 0 + width 42px + font-size 0.9em + line-height 42px + color #ccc + + &:hover + color #aaa + + &:active + color #999 + + &:first-of-type + left 0 + + &:last-of-type + right 0 + + > .calendar + display flex + flex-wrap wrap + padding 16px + + * + user-select none + + > div + width calc(100% * (1/7)) + text-align center + line-height 32px + font-size 14px + + &.weekday + color #19a2a9 + + &[data-is-donichi] + color #ef95a0 + + &[data-today] + box-shadow 0 0 0 1px #19a2a9 inset + border-radius 6px + + &[data-is-donichi] + box-shadow 0 0 0 1px #ef95a0 inset + + &.day + cursor pointer + color #777 + + > div + border-radius 6px + + &:hover > div + background rgba(0, 0, 0, 0.025) + + &:active > div + background rgba(0, 0, 0, 0.05) + + &[data-is-donichi] + color #ef95a0 + + &[data-is-out-of-range] + cursor default + color rgba(#777, 0.5) + + &[data-is-donichi] + color rgba(#ef95a0, 0.5) + + &[data-selected] + font-weight bold + + > div + background rgba(0, 0, 0, 0.025) + + &:active > div + background rgba(0, 0, 0, 0.05) + + &[data-today] + > div + color $theme-color-foreground + background $theme-color + + &:hover > div + background lighten($theme-color, 10%) + + &:active > div + background darken($theme-color, 10%) + + </style> + <script> + if (this.opts.design == null) this.opts.design = 0; + + const eachMonthDays = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; + + function isLeapYear(year) { + return (year % 400 == 0) ? true : + (year % 100 == 0) ? false : + (year % 4 == 0) ? true : + false; + } + + this.today = new Date(); + this.year = this.today.getFullYear(); + this.month = this.today.getMonth() + 1; + this.selected = this.today; + this.weekdayText = [ + '%i18n:common.weekday-short.sunday%', + '%i18n:common.weekday-short.monday%', + '%i18n:common.weekday-short.tuesday%', + '%i18n:common.weekday-short.wednesday%', + '%i18n:common.weekday-short.thursday%', + '%i18n:common.weekday-short.friday%', + '%i18n:common.weekday-short.satruday%' + ]; + + this.on('mount', () => { + this.calc(); + }); + + this.isToday = day => { + return this.year == this.today.getFullYear() && this.month == this.today.getMonth() + 1 && day == this.today.getDate(); + }; + + this.isSelected = day => { + return this.year == this.selected.getFullYear() && this.month == this.selected.getMonth() + 1 && day == this.selected.getDate(); + }; + + this.isOutOfRange = day => { + const test = (new Date(this.year, this.month - 1, day)).getTime(); + return test > this.today.getTime() || + (this.opts.start ? test < this.opts.start.getTime() : false); + }; + + this.isDonichi = day => { + const weekday = (new Date(this.year, this.month - 1, day)).getDay(); + return weekday == 0 || weekday == 6; + }; + + this.calc = () => { + let days = eachMonthDays[this.month - 1]; + + // うるう年なら+1日 + if (this.month == 2 && isLeapYear(this.year)) days++; + + const date = new Date(this.year, this.month - 1, 1); + const weekday = date.getDay(); + + this.update({ + paddingDays: weekday, + days: days + }); + }; + + this.prev = () => { + if (this.month == 1) { + this.update({ + year: this.year - 1, + month: 12 + }); + } else { + this.update({ + month: this.month - 1 + }); + } + this.calc(); + }; + + this.next = () => { + if (this.month == 12) { + this.update({ + year: this.year + 1, + month: 1 + }); + } else { + this.update({ + month: this.month + 1 + }); + } + this.calc(); + }; + + this.go = day => { + if (this.isOutOfRange(day)) return; + const date = new Date(this.year, this.month - 1, day, 23, 59, 59, 999); + this.update({ + selected: date + }); + this.opts.warp(date); + }; +</script> +</mk-calendar-widget> diff --git a/src/web/app/desktop/tags/window.tag b/src/web/app/desktop/tags/window.tag index aefb6499b7..256cfb7900 100644 --- a/src/web/app/desktop/tags/window.tag +++ b/src/web/app/desktop/tags/window.tag @@ -4,7 +4,10 @@ <div class="body"> <header ref="header" onmousedown={ onHeaderMousedown }> <h1 data-yield="header"><yield from="header"/></h1> - <button class="close" if={ canClose } onmousedown={ repelMove } onclick={ close } title="閉じる"><i class="fa fa-times"></i></button> + <div> + <button class="popout" if={ popoutUrl } onmousedown={ repelMove } onclick={ popout } title="ポップアウト"><i class="fa fa-window-restore"></i></button> + <button class="close" if={ canClose } onmousedown={ repelMove } onclick={ close } title="閉じる"><i class="fa fa-times"></i></button> + </div> </header> <div class="content" data-yield="content"><yield from="content"/></div> </div> @@ -117,8 +120,12 @@ box-shadow 0 2px 6px 0 rgba(0, 0, 0, 0.2) > header + $header-height = 40px + z-index 128 + height $header-height overflow hidden + white-space nowrap cursor move background #fff border-radius 6px 6px 0 0 @@ -130,39 +137,45 @@ > h1 pointer-events none display block - margin 0 - height 40px + margin 0 auto + overflow hidden + height $header-height + text-overflow ellipsis text-align center font-size 1em - line-height 40px + line-height $header-height font-weight normal color #666 - > .close - cursor pointer - display block + > div:last-child position absolute top 0 right 0 + display block z-index 1 - margin 0 - padding 0 - font-size 1.2em - color rgba(#000, 0.4) - border none - outline none - background transparent - &:hover - color rgba(#000, 0.6) + > * + display inline-block + margin 0 + padding 0 + cursor pointer + font-size 1.2em + color rgba(#000, 0.4) + border none + outline none + background transparent + + &:hover + color rgba(#000, 0.6) - &:active - color darken(#000, 30%) + &:active + color darken(#000, 30%) - > i - padding 0 - width 40px - line-height 40px + > i + padding 0 + width $header-height + line-height $header-height + text-align center > .content height 100% @@ -181,6 +194,7 @@ this.isModal = this.opts.isModal != null ? this.opts.isModal : false; this.canClose = this.opts.canClose != null ? this.opts.canClose : true; + this.popoutUrl = this.opts.popout; this.isFlexible = this.opts.height == null; this.canResize = !this.isFlexible; @@ -247,6 +261,22 @@ }, 300); }; + this.popout = () => { + const position = this.refs.main.getBoundingClientRect(); + + const width = parseInt(getComputedStyle(this.refs.main, '').width, 10); + const height = parseInt(getComputedStyle(this.refs.main, '').height, 10); + const x = window.screenX + position.left; + const y = window.screenY + position.top; + + const url = typeof this.popoutUrl == 'function' ? this.popoutUrl() : this.popoutUrl; + + window.open(url, url, + `height=${height},width=${width},left=${x},top=${y}`); + + this.close(); + }; + this.close = () => { this.trigger('closing'); diff --git a/src/web/app/dev/router.js b/src/web/app/dev/router.ts index 7fde30fa5c..fcd2b1f76b 100644 --- a/src/web/app/dev/router.js +++ b/src/web/app/dev/router.ts @@ -1,8 +1,8 @@ import * as riot from 'riot'; -const route = require('page'); +import * as route from 'page'; let page = null; -export default me => { +export default () => { route('/', index); route('/apps', apps); route('/app/new', newApp); @@ -32,7 +32,7 @@ export default me => { } // EXEC - route(); + (route as any)(); }; function mount(content) { diff --git a/src/web/app/dev/script.js b/src/web/app/dev/script.ts index 39d7fc891e..b115c5be48 100644 --- a/src/web/app/dev/script.js +++ b/src/web/app/dev/script.ts @@ -12,7 +12,7 @@ import route from './router'; /** * init */ -init(me => { +init(() => { // Start routing - route(me); + route(); }); diff --git a/src/web/app/dev/tags/index.js b/src/web/app/dev/tags/index.ts index 1e0c73697e..1e0c73697e 100644 --- a/src/web/app/dev/tags/index.js +++ b/src/web/app/dev/tags/index.ts diff --git a/src/web/app/init.js b/src/web/app/init.js deleted file mode 100644 index 5a6899ed4f..0000000000 --- a/src/web/app/init.js +++ /dev/null @@ -1,206 +0,0 @@ -/** - * App initializer - */ - -'use strict'; - -import * as riot from 'riot'; -import api from './common/scripts/api'; -import signout from './common/scripts/signout'; -import checkForUpdate from './common/scripts/check-for-update'; -import Connection from './common/scripts/home-stream'; -import Progress from './common/scripts/loading'; -import mixin from './common/mixins'; -import generateDefaultUserdata from './common/scripts/generate-default-userdata'; -import CONFIG from './common/scripts/config'; -require('./common/tags'); - -/** - * APP ENTRY POINT! - */ - -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'); - meta.setAttribute('name', 'description'); - meta.setAttribute('content', '%i18n:common.misskey%'); - head.appendChild(meta); -} - -document.domain = CONFIG.host; - -// Set global configuration -riot.mixin({ CONFIG }); - -// ↓ NodeList、HTMLCollection、FileList、DataTransferItemListで forEach を使えるようにする -if (NodeList.prototype.forEach === undefined) { - NodeList.prototype.forEach = Array.prototype.forEach; -} -if (HTMLCollection.prototype.forEach === undefined) { - HTMLCollection.prototype.forEach = Array.prototype.forEach; -} -if (FileList.prototype.forEach === undefined) { - FileList.prototype.forEach = Array.prototype.forEach; -} -if (window.DataTransferItemList && DataTransferItemList.prototype.forEach === undefined) { - DataTransferItemList.prototype.forEach = Array.prototype.forEach; -} - -// iOSでプライベートモードだとlocalStorageが使えないので既存のメソッドを上書きする -try { - localStorage.setItem('kyoppie', 'yuppie'); -} catch (e) { - Storage.prototype.setItem = () => { }; // noop -} - -// クライアントを更新すべきならする -if (localStorage.getItem('should-refresh') == 'true') { - localStorage.removeItem('should-refresh'); - location.reload(true); -} - -// 更新チェック -setTimeout(checkForUpdate, 3000); - -// ユーザーをフェッチしてコールバックする -export default callback => { - // Get cached account data - let cachedMe = JSON.parse(localStorage.getItem('me')); - - if (cachedMe) { - fetched(cachedMe); - - // 後から新鮮なデータをフェッチ - fetchme(cachedMe.token, freshData => { - Object.assign(cachedMe, freshData); - cachedMe.trigger('updated'); - }); - } else { - // Get token from cookie - const i = (document.cookie.match(/i=(!\w+)/) || [null, null])[1]; - - fetchme(i, fetched); - } - - // フェッチが完了したとき - function fetched(me) { - if (me) { - riot.observable(me); - - // この me オブジェクトを更新するメソッド - me.update = data => { - if (data) Object.assign(me, data); - me.trigger('updated'); - }; - - // ローカルストレージにキャッシュ - localStorage.setItem('me', JSON.stringify(me)); - - me.on('updated', () => { - // キャッシュ更新 - localStorage.setItem('me', JSON.stringify(me)); - }); - } - - // Init home stream connection - const stream = me ? new Connection(me) : null; - - // ミックスイン初期化 - mixin(me, stream); - - // ローディング画面クリア - const ini = document.getElementById('ini'); - ini.parentNode.removeChild(ini); - - // アプリ基底要素マウント - const app = document.createElement('div'); - app.setAttribute('id', 'app'); - document.body.appendChild(app); - - try { - callback(me, stream); - } catch (e) { - panic(e); - } - } -}; - -// ユーザーをフェッチしてコールバックする -function fetchme(token, cb) { - let me = null; - - // Return when not signed in - if (token == null) { - return done(); - } - - // Fetch user - fetch(`${CONFIG.apiUrl}/i`, { - method: 'POST', - body: JSON.stringify({ - i: token - }) - }).then(res => { // When success - // When failed to authenticate user - if (res.status !== 200) { - return signout(); - } - - res.json().then(i => { - me = i; - me.token = token; - - // initialize it if user data is empty - me.data ? done() : init(); - }); - }, () => { // When failure - // Render the error screen - document.body.innerHTML = '<mk-error />'; - riot.mount('*'); - Progress.done(); - }); - - function done() { - if (cb) cb(me); - } - - // Initialize user data - function init() { - const data = generateDefaultUserdata(); - api(token, 'i/appdata/set', { - data - }).then(() => { - me.data = data; - done(); - }); - } -} - -// BSoD -function panic(e) { - console.error(e); - - // Display blue screen - document.documentElement.style.background = '#1269e2'; - document.body.innerHTML = - '<div id="error">' - + '<h1>:( 致命的な問題が発生しました。</h1>' - + '<p>お使いのブラウザ(またはOS)のバージョンを更新すると解決する可能性があります。</p>' - + '<hr>' - + `<p>エラーコード: ${e.toString()}</p>` - + `<p>ブラウザ バージョン: ${navigator.userAgent}</p>` - + `<p>クライアント バージョン: ${VERSION}</p>` - + '<hr>' - + '<p>問題が解決しない場合は、上記の情報をお書き添えの上 syuilotan@yahoo.co.jp までご連絡ください。</p>' - + '<p>Thank you for using Misskey.</p>' - + '</div>'; - - // TODO: Report the bug -} diff --git a/src/web/app/init.ts b/src/web/app/init.ts new file mode 100644 index 0000000000..79be1d3687 --- /dev/null +++ b/src/web/app/init.ts @@ -0,0 +1,105 @@ +/** + * App initializer + */ + +declare const _VERSION_: string; +declare const _LANG_: string; +declare const _HOST_: string; +declare const __CONSTS__: any; + +import * as riot from 'riot'; +import checkForUpdate from './common/scripts/check-for-update'; +import mixin from './common/mixins'; +import MiOS from './common/mios'; +require('./common/tags'); + +/** + * APP ENTRY POINT! + */ + +console.info(`Misskey v${_VERSION_} (葵 aoi)`); + +if (_HOST_ != 'localhost') { + document.domain = _HOST_; +} + +{ // 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'); + meta.setAttribute('name', 'description'); + meta.setAttribute('content', '%i18n:common.misskey%'); + head.appendChild(meta); +} + +// Set global configuration +(riot as any).mixin(__CONSTS__); + +// iOSでプライベートモードだとlocalStorageが使えないので既存のメソッドを上書きする +try { + localStorage.setItem('kyoppie', 'yuppie'); +} catch (e) { + Storage.prototype.setItem = () => { }; // noop +} + +// クライアントを更新すべきならする +if (localStorage.getItem('should-refresh') == 'true') { + localStorage.removeItem('should-refresh'); + location.reload(true); +} + +// MiOSを初期化してコールバックする +export default (callback, sw = false) => { + const mios = new MiOS(sw); + + mios.init(() => { + // ミックスイン初期化 + mixin(mios); + + // ローディング画面クリア + const ini = document.getElementById('ini'); + ini.parentNode.removeChild(ini); + + // アプリ基底要素マウント + const app = document.createElement('div'); + app.setAttribute('id', 'app'); + document.body.appendChild(app); + + try { + callback(mios); + } catch (e) { + panic(e); + } + + // 更新チェック + setTimeout(() => { + checkForUpdate(mios); + }, 3000); + }); +}; + +// BSoD +function panic(e) { + console.error(e); + + // Display blue screen + document.documentElement.style.background = '#1269e2'; + document.body.innerHTML = + '<div id="error">' + + '<h1>:( 致命的な問題が発生しました。</h1>' + + '<p>お使いのブラウザ(またはOS)のバージョンを更新すると解決する可能性があります。</p>' + + '<hr>' + + `<p>エラーコード: ${e.toString()}</p>` + + `<p>ブラウザ バージョン: ${navigator.userAgent}</p>` + + `<p>クライアント バージョン: ${_VERSION_}</p>` + + '<hr>' + + '<p>問題が解決しない場合は、上記の情報をお書き添えの上 syuilotan@yahoo.co.jp までご連絡ください。</p>' + + '<p>Thank you for using Misskey.</p>' + + '</div>'; + + // TODO: Report the bug +} diff --git a/src/web/app/mobile/router.js b/src/web/app/mobile/router.ts index 01eb3c8145..0358d10e9e 100644 --- a/src/web/app/mobile/router.js +++ b/src/web/app/mobile/router.ts @@ -3,10 +3,11 @@ */ import * as riot from 'riot'; -const route = require('page'); +import * as route from 'page'; +import MiOS from '../common/mios'; let page = null; -export default me => { +export default (mios: MiOS) => { route('/', index); route('/selectdrive', selectDrive); route('/i/notifications', notifications); @@ -32,7 +33,7 @@ export default me => { route('*', notFound); function index() { - me ? home() : entrance(); + mios.isSignedin ? home() : entrance(); } function home() { @@ -131,12 +132,12 @@ export default me => { mount(document.createElement('mk-not-found')); } - riot.mixin('page', { + (riot as any).mixin('page', { page: route }); // EXEC - route(); + (route as any)(); }; function mount(content) { diff --git a/src/web/app/mobile/script.js b/src/web/app/mobile/script.ts index 503e0fd673..4dfff8f72f 100644 --- a/src/web/app/mobile/script.js +++ b/src/web/app/mobile/script.ts @@ -8,14 +8,15 @@ import './style.styl'; require('./tags'); import init from '../init'; import route from './router'; +import MiOS from '../common/mios'; /** * init */ -init(me => { +init((mios: MiOS) => { // http://qiita.com/junya/items/3ff380878f26ca447f85 document.body.setAttribute('ontouchstart', ''); // Start routing - route(me); -}); + route(mios); +}, true); diff --git a/src/web/app/mobile/scripts/open-post-form.js b/src/web/app/mobile/scripts/open-post-form.ts index e0fae4d8ca..e0fae4d8ca 100644 --- a/src/web/app/mobile/scripts/open-post-form.js +++ b/src/web/app/mobile/scripts/open-post-form.ts diff --git a/src/web/app/mobile/scripts/ui-event.js b/src/web/app/mobile/scripts/ui-event.ts index 2e406549a4..2e406549a4 100644 --- a/src/web/app/mobile/scripts/ui-event.js +++ b/src/web/app/mobile/scripts/ui-event.ts diff --git a/src/web/app/mobile/tags/drive.tag b/src/web/app/mobile/tags/drive.tag index 6929c50ab1..2c36c43ac5 100644 --- a/src/web/app/mobile/tags/drive.tag +++ b/src/web/app/mobile/tags/drive.tag @@ -172,7 +172,10 @@ <script> this.mixin('i'); this.mixin('api'); - this.mixin('stream'); + + this.mixin('drive-stream'); + this.connection = this.driveStream.getConnection(); + this.connectionId = this.driveStream.use(); this.files = []; this.folders = []; @@ -189,10 +192,10 @@ this.multiple = this.opts.multiple; this.on('mount', () => { - this.stream.on('drive_file_created', this.onStreamDriveFileCreated); - this.stream.on('drive_file_updated', this.onStreamDriveFileUpdated); - this.stream.on('drive_folder_created', this.onStreamDriveFolderCreated); - this.stream.on('drive_folder_updated', this.onStreamDriveFolderUpdated); + this.connection.on('file_created', this.onStreamDriveFileCreated); + this.connection.on('file_updated', this.onStreamDriveFileUpdated); + this.connection.on('folder_created', this.onStreamDriveFolderCreated); + this.connection.on('folder_updated', this.onStreamDriveFolderUpdated); if (this.opts.folder) { this.cd(this.opts.folder, true); @@ -208,10 +211,11 @@ }); this.on('unmount', () => { - this.stream.off('drive_file_created', this.onStreamDriveFileCreated); - this.stream.off('drive_file_updated', this.onStreamDriveFileUpdated); - this.stream.off('drive_folder_created', this.onStreamDriveFolderCreated); - this.stream.off('drive_folder_updated', this.onStreamDriveFolderUpdated); + this.connection.off('file_created', this.onStreamDriveFileCreated); + this.connection.off('file_updated', this.onStreamDriveFileUpdated); + this.connection.off('folder_created', this.onStreamDriveFolderCreated); + this.connection.off('folder_updated', this.onStreamDriveFolderUpdated); + this.driveStream.dispose(this.connectionId); }); this.onStreamDriveFileCreated = file => { @@ -561,7 +565,7 @@ }; this.changeLocalFile = () => { - this.refs.file.files.forEach(f => this.refs.uploader.upload(f, this.folder)); + Array.from(this.refs.file.files).forEach(f => this.refs.uploader.upload(f, this.folder)); }; </script> </mk-drive> diff --git a/src/web/app/mobile/tags/drive/file-viewer.tag b/src/web/app/mobile/tags/drive/file-viewer.tag index e6129652b0..2cec4f329e 100644 --- a/src/web/app/mobile/tags/drive/file-viewer.tag +++ b/src/web/app/mobile/tags/drive/file-viewer.tag @@ -2,7 +2,7 @@ <div class="preview"> <img if={ kind == 'image' } src={ file.url } alt={ file.name } title={ file.name }> <i if={ kind != 'image' } class="fa fa-file"></i> - <footer if={ kind == 'image' }> + <footer if={ kind == 'image' && file.properties && file.properties.width && file.properties.height }> <span class="size"> <span class="width">{ file.properties.width }</span> <span class="time">×</span> @@ -44,7 +44,7 @@ <p> <i class="fa fa-hashtag"></i>%i18n:mobile.tags.mk-drive-file-viewer.hash% </p> - <code>{ file.hash }</code> + <code>{ file.md5 }</code> </div> </div> <style> diff --git a/src/web/app/mobile/tags/drive/file.tag b/src/web/app/mobile/tags/drive/file.tag index bf51f79a5d..1499e8d7b7 100644 --- a/src/web/app/mobile/tags/drive/file.tag +++ b/src/web/app/mobile/tags/drive/file.tag @@ -2,7 +2,7 @@ <div class="container"> <div class="thumbnail" style={ 'background-image: url(' + file.url + '?thumbnail&size=128)' }></div> <div class="body"> - <p class="name">{ file.name }</p> + <p class="name"><span>{ file.name.lastIndexOf('.') != -1 ? file.name.substr(0, file.name.lastIndexOf('.')) : file.name }</span><span class="ext" if={ file.name.lastIndexOf('.') != -1 }>{ file.name.substr(file.name.lastIndexOf('.')) }</span></p> <!-- if file.tags.length > 0 ul.tags @@ -64,6 +64,9 @@ text-overflow ellipsis overflow-wrap break-word + > .ext + opacity 0.5 + > .tags display block margin 4px 0 0 0 diff --git a/src/web/app/mobile/tags/follow-button.tag b/src/web/app/mobile/tags/follow-button.tag index 67d580eb99..f25b2ed50e 100644 --- a/src/web/app/mobile/tags/follow-button.tag +++ b/src/web/app/mobile/tags/follow-button.tag @@ -52,7 +52,10 @@ this.mixin('i'); this.mixin('api'); + this.mixin('stream'); + this.connection = this.stream.getConnection(); + this.connectionId = this.stream.use(); this.user = null; this.userPromise = isPromise(this.opts.user) @@ -67,14 +70,15 @@ init: false, user: user }); - this.stream.on('follow', this.onStreamFollow); - this.stream.on('unfollow', this.onStreamUnfollow); + this.connection.on('follow', this.onStreamFollow); + this.connection.on('unfollow', this.onStreamUnfollow); }); }); this.on('unmount', () => { - this.stream.off('follow', this.onStreamFollow); - this.stream.off('unfollow', this.onStreamUnfollow); + this.connection.off('follow', this.onStreamFollow); + this.connection.off('unfollow', this.onStreamUnfollow); + this.stream.dispose(this.connectionId); }); this.onStreamFollow = user => { diff --git a/src/web/app/mobile/tags/home-timeline.tag b/src/web/app/mobile/tags/home-timeline.tag index 051158597d..e96823fa10 100644 --- a/src/web/app/mobile/tags/home-timeline.tag +++ b/src/web/app/mobile/tags/home-timeline.tag @@ -12,7 +12,10 @@ <script> this.mixin('i'); this.mixin('api'); + this.mixin('stream'); + this.connection = this.stream.getConnection(); + this.connectionId = this.stream.use(); this.noFollowing = this.I.following_count == 0; @@ -30,15 +33,16 @@ }; this.on('mount', () => { - this.stream.on('post', this.onStreamPost); - this.stream.on('follow', this.onStreamFollow); - this.stream.on('unfollow', this.onStreamUnfollow); + this.connection.on('post', this.onStreamPost); + this.connection.on('follow', this.onStreamFollow); + this.connection.on('unfollow', this.onStreamUnfollow); }); this.on('unmount', () => { - this.stream.off('post', this.onStreamPost); - this.stream.off('follow', this.onStreamFollow); - this.stream.off('unfollow', this.onStreamUnfollow); + this.connection.off('post', this.onStreamPost); + this.connection.off('follow', this.onStreamFollow); + this.connection.off('unfollow', this.onStreamUnfollow); + this.stream.dispose(this.connectionId); }); this.more = () => { diff --git a/src/web/app/mobile/tags/index.js b/src/web/app/mobile/tags/index.ts index 19952c20cd..19952c20cd 100644 --- a/src/web/app/mobile/tags/index.js +++ b/src/web/app/mobile/tags/index.ts diff --git a/src/web/app/mobile/tags/notifications.tag b/src/web/app/mobile/tags/notifications.tag index 2e95990314..7406fd95e2 100644 --- a/src/web/app/mobile/tags/notifications.tag +++ b/src/web/app/mobile/tags/notifications.tag @@ -82,7 +82,10 @@ this.getPostSummary = getPostSummary; this.mixin('api'); + this.mixin('stream'); + this.connection = this.stream.getConnection(); + this.connectionId = this.stream.use(); this.notifications = []; this.loading = true; @@ -106,11 +109,12 @@ this.trigger('fetched'); }); - this.stream.on('notification', this.onNotification); + this.connection.on('notification', this.onNotification); }); this.on('unmount', () => { - this.stream.off('notification', this.onNotification); + this.connection.off('notification', this.onNotification); + this.stream.dispose(this.connectionId); }); this.on('update', () => { @@ -124,7 +128,7 @@ this.onNotification = notification => { // TODO: ユーザーが画面を見てないと思われるとき(ブラウザやタブがアクティブじゃないなど)は送信しない - this.stream.send({ + this.connection.send({ type: 'read_notification', id: notification.id }); diff --git a/src/web/app/mobile/tags/page/entrance/signin.tag b/src/web/app/mobile/tags/page/entrance/signin.tag index 827fbccb94..6f473feb9d 100644 --- a/src/web/app/mobile/tags/page/entrance/signin.tag +++ b/src/web/app/mobile/tags/page/entrance/signin.tag @@ -1,5 +1,6 @@ <mk-entrance-signin> <mk-signin/> + <a href={ _API_URL_ + '/signin/twitter' }>Twitterでサインイン</a> <div class="divider"><span>or</span></div> <button class="signup" onclick={ parent.signup }>%i18n:mobile.tags.mk-entrance-signin.signup%</button><a class="introduction" onclick={ parent.introduction }>%i18n:mobile.tags.mk-entrance-signin.about%</a> <style> diff --git a/src/web/app/mobile/tags/page/home.tag b/src/web/app/mobile/tags/page/home.tag index 3b0255b293..1b2a4b1e13 100644 --- a/src/web/app/mobile/tags/page/home.tag +++ b/src/web/app/mobile/tags/page/home.tag @@ -13,7 +13,10 @@ import openPostForm from '../../scripts/open-post-form'; this.mixin('i'); + this.mixin('stream'); + this.connection = this.stream.getConnection(); + this.connectionId = this.stream.use(); this.unreadCount = 0; @@ -28,7 +31,7 @@ Progress.start(); - this.stream.on('post', this.onStreamPost); + this.connection.on('post', this.onStreamPost); document.addEventListener('visibilitychange', this.onVisibilitychange, false); this.refs.ui.refs.home.on('loaded', () => { @@ -37,7 +40,8 @@ }); this.on('unmount', () => { - this.stream.off('post', this.onStreamPost); + this.connection.off('post', this.onStreamPost); + this.stream.dispose(this.connectionId); document.removeEventListener('visibilitychange', this.onVisibilitychange); }); diff --git a/src/web/app/mobile/tags/page/settings.tag b/src/web/app/mobile/tags/page/settings.tag index b6501142ee..95b3f757d7 100644 --- a/src/web/app/mobile/tags/page/settings.tag +++ b/src/web/app/mobile/tags/page/settings.tag @@ -29,7 +29,7 @@ <ul> <li><a onclick={ signout }><i class="fa fa-power-off"></i>%i18n:mobile.tags.mk-settings-page.signout%</a></li> </ul> - <p><small>ver { version } (葵 aoi)</small></p> + <p><small>ver { _VERSION_ } (葵 aoi)</small></p> <style> :scope display block @@ -97,7 +97,5 @@ this.signout = signout; this.mixin('i'); - - this.version = VERSION; </script> </mk-settings> diff --git a/src/web/app/mobile/tags/post-detail.tag b/src/web/app/mobile/tags/post-detail.tag index 8a32101036..28071a5cac 100644 --- a/src/web/app/mobile/tags/post-detail.tag +++ b/src/web/app/mobile/tags/post-detail.tag @@ -285,7 +285,7 @@ this.refs.text.innerHTML = compile(tokens); - this.refs.text.children.forEach(e => { + Array.from(this.refs.text.children).forEach(e => { if (e.tagName == 'MK-URL') riot.mount(e); }); diff --git a/src/web/app/mobile/tags/post-form.tag b/src/web/app/mobile/tags/post-form.tag index d7d382c9e2..2912bfdfa2 100644 --- a/src/web/app/mobile/tags/post-form.tag +++ b/src/web/app/mobile/tags/post-form.tag @@ -207,7 +207,7 @@ }; this.onpaste = e => { - e.clipboardData.items.forEach(item => { + Array.from(e.clipboardData.items).forEach(item => { if (item.kind == 'file') { this.upload(item.getAsFile()); } @@ -228,7 +228,7 @@ }; this.changeFile = () => { - this.refs.file.files.forEach(this.upload); + Array.from(this.refs.file.files).forEach(this.upload); }; this.upload = file => { diff --git a/src/web/app/mobile/tags/sub-post-content.tag b/src/web/app/mobile/tags/sub-post-content.tag index e32e245185..c14233d3b7 100644 --- a/src/web/app/mobile/tags/sub-post-content.tag +++ b/src/web/app/mobile/tags/sub-post-content.tag @@ -37,7 +37,7 @@ const tokens = this.post.ast; this.refs.text.innerHTML = compile(tokens, false); - this.refs.text.children.forEach(e => { + Array.from(this.refs.text.children).forEach(e => { if (e.tagName == 'MK-URL') riot.mount(e); }); } diff --git a/src/web/app/mobile/tags/timeline.tag b/src/web/app/mobile/tags/timeline.tag index f9ec2cca60..074422a20e 100644 --- a/src/web/app/mobile/tags/timeline.tag +++ b/src/web/app/mobile/tags/timeline.tag @@ -164,7 +164,7 @@ </header> <div class="body"> <div class="text" ref="text"> - <p class="channel" if={ p.channel != null }><a href={ CONFIG.chUrl + '/' + p.channel.id } target="_blank">{ p.channel.title }</a>:</p> + <p class="channel" if={ p.channel != null }><a href={ _CH_URL_ + '/' + p.channel.id } target="_blank">{ p.channel.title }</a>:</p> <a class="reply" if={ p.reply }> <i class="fa fa-reply"></i> </a> @@ -473,7 +473,10 @@ this.mixin('i'); this.mixin('api'); + this.mixin('stream'); + this.connection = this.stream.getConnection(); + this.connectionId = this.stream.use(); this.set = post => { this.post = post; @@ -508,21 +511,21 @@ this.capture = withHandler => { if (this.SIGNIN) { - this.stream.send({ + this.connection.send({ type: 'capture', id: this.post.id }); - if (withHandler) this.stream.on('post-updated', this.onStreamPostUpdated); + if (withHandler) this.connection.on('post-updated', this.onStreamPostUpdated); } }; this.decapture = withHandler => { if (this.SIGNIN) { - this.stream.send({ + this.connection.send({ type: 'decapture', id: this.post.id }); - if (withHandler) this.stream.off('post-updated', this.onStreamPostUpdated); + if (withHandler) this.connection.off('post-updated', this.onStreamPostUpdated); } }; @@ -530,7 +533,7 @@ this.capture(true); if (this.SIGNIN) { - this.stream.on('_connected_', this.onStreamConnected); + this.connection.on('_connected_', this.onStreamConnected); } if (this.p.text) { @@ -538,7 +541,7 @@ this.refs.text.innerHTML = this.refs.text.innerHTML.replace('<p class="dummy"></p>', compile(tokens)); - this.refs.text.children.forEach(e => { + Array.from(this.refs.text.children).forEach(e => { if (e.tagName == 'MK-URL') riot.mount(e); }); @@ -555,7 +558,8 @@ this.on('unmount', () => { this.decapture(true); - this.stream.off('_connected_', this.onStreamConnected); + this.connection.off('_connected_', this.onStreamConnected); + this.stream.dispose(this.connectionId); }); this.reply = () => { diff --git a/src/web/app/mobile/tags/ui.tag b/src/web/app/mobile/tags/ui.tag index b2d96f6b8b..bad6bf73fe 100644 --- a/src/web/app/mobile/tags/ui.tag +++ b/src/web/app/mobile/tags/ui.tag @@ -12,16 +12,20 @@ </style> <script> this.mixin('i'); + this.mixin('stream'); + this.connection = this.stream.getConnection(); + this.connectionId = this.stream.use(); this.isDrawerOpening = false; this.on('mount', () => { - this.stream.on('notification', this.onStreamNotification); + this.connection.on('notification', this.onStreamNotification); }); this.on('unmount', () => { - this.stream.off('notification', this.onStreamNotification); + this.connection.off('notification', this.onStreamNotification); + this.stream.dispose(this.connectionId); }); this.toggleDrawer = () => { @@ -31,7 +35,7 @@ this.onStreamNotification = notification => { // TODO: ユーザーが画面を見てないと思われるとき(ブラウザやタブがアクティブじゃないなど)は送信しない - this.stream.send({ + this.connection.send({ type: 'read_notification', id: notification.id }); @@ -145,15 +149,18 @@ import ui from '../scripts/ui-event'; this.mixin('api'); + this.mixin('stream'); + this.connection = this.stream.getConnection(); + this.connectionId = this.stream.use(); 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); + this.connection.on('read_all_notifications', this.onReadAllNotifications); + this.connection.on('read_all_messaging_messages', this.onReadAllMessagingMessages); + this.connection.on('unread_messaging_message', this.onUnreadMessagingMessage); // Fetch count of unread notifications this.api('notifications/get_unread_count').then(res => { @@ -175,9 +182,10 @@ }); 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.connection.off('read_all_notifications', this.onReadAllNotifications); + this.connection.off('read_all_messaging_messages', this.onReadAllMessagingMessages); + this.connection.off('unread_messaging_message', this.onUnreadMessagingMessage); + this.stream.dispose(this.connectionId); ui.off('title', this.setTitle); ui.off('func', this.setFunc); @@ -231,7 +239,7 @@ <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={ _CH_URL_ } 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> @@ -241,7 +249,7 @@ <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> + <a href={ _ABOUT_URL_ }><p class="about">%i18n:mobile.tags.mk-ui-nav.about%</p></a> </div> <style> :scope @@ -348,12 +356,15 @@ this.mixin('i'); this.mixin('page'); this.mixin('api'); + this.mixin('stream'); + this.connection = this.stream.getConnection(); + this.connectionId = this.stream.use(); 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); + this.connection.on('read_all_notifications', this.onReadAllNotifications); + this.connection.on('read_all_messaging_messages', this.onReadAllMessagingMessages); + this.connection.on('unread_messaging_message', this.onUnreadMessagingMessage); // Fetch count of unread notifications this.api('notifications/get_unread_count').then(res => { @@ -375,9 +386,10 @@ }); 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.connection.off('read_all_notifications', this.onReadAllNotifications); + this.connection.off('read_all_messaging_messages', this.onReadAllMessagingMessages); + this.connection.off('unread_messaging_message', this.onUnreadMessagingMessage); + this.stream.dispose(this.connectionId); }); this.onReadAllNotifications = () => { diff --git a/src/web/app/safe.js b/src/web/app/safe.js index 77293be81d..2fd5361725 100644 --- a/src/web/app/safe.js +++ b/src/web/app/safe.js @@ -1,6 +1,5 @@ /** - * 古いブラウザの検知を行う - * ブートローダーとは隔離されているため互いに影響を及ぼすことはない + * ブラウザの検証 */ // Detect an old browser @@ -9,6 +8,24 @@ if (!('fetch' in window)) { 'お使いのブラウザが古いためMisskeyを動作させることができません。' + 'バージョンを最新のものに更新するか、別のブラウザをお試しください。' + '\n\n' + - 'Your browser seems outdated.' + + 'Your browser seems outdated. ' + 'To run Misskey, please update your browser to latest version or try other browsers.'); } + +// Detect Edge +if (navigator.userAgent.toLowerCase().indexOf('edge') != -1) { + alert( + '現在、お使いのブラウザ(Microsoft Edge)ではMisskeyは正しく動作しません。' + + 'サポートしているブラウザ: Google Chrome, Mozilla Firefox, Apple Safari など' + + '\n\n' + + 'Currently, Misskey cannot run correctly on your browser (Microsoft Edge). ' + + 'Supported browsers: Google Chrome, Mozilla Firefox, Apple Safari, etc'); +} + +// Check whether cookie enabled +if (!navigator.cookieEnabled) { + alert( + 'Misskeyを利用するにはCookieを有効にしてください。' + + '\n\n' + + 'To use Misskey, please enable Cookie.'); +} diff --git a/src/web/app/stats/script.js b/src/web/app/stats/script.ts index 75063501bb..3bbd80c339 100644 --- a/src/web/app/stats/script.js +++ b/src/web/app/stats/script.ts @@ -14,7 +14,7 @@ document.title = 'Misskey Statistics'; /** * init */ -init(me => { +init(() => { mount(document.createElement('mk-index')); }); diff --git a/src/web/app/stats/tags/index.tag b/src/web/app/stats/tags/index.tag index 134fad3c0c..4b5415b2fd 100644 --- a/src/web/app/stats/tags/index.tag +++ b/src/web/app/stats/tags/index.tag @@ -4,7 +4,7 @@ <mk-users stats={ stats }/> <mk-posts stats={ stats }/> </main> - <footer><a href={ CONFIG.url }>{ CONFIG.host }</a></footer> + <footer><a href={ _URL_ }>{ _HOST_ }</a></footer> <style> :scope display block diff --git a/src/web/app/stats/tags/index.js b/src/web/app/stats/tags/index.ts index f41151949f..f41151949f 100644 --- a/src/web/app/stats/tags/index.js +++ b/src/web/app/stats/tags/index.ts diff --git a/src/web/app/status/script.js b/src/web/app/status/script.ts index 06d4d9a7a4..84483acab7 100644 --- a/src/web/app/status/script.js +++ b/src/web/app/status/script.ts @@ -14,7 +14,7 @@ document.title = 'Misskey System Status'; /** * init */ -init(me => { +init(() => { mount(document.createElement('mk-index')); }); diff --git a/src/web/app/status/tags/index.tag b/src/web/app/status/tags/index.tag index 6fb6041c3c..cb379f66bc 100644 --- a/src/web/app/status/tags/index.tag +++ b/src/web/app/status/tags/index.tag @@ -5,7 +5,7 @@ <mk-cpu-usage connection={ connection }/> <mk-mem-usage connection={ connection }/> </main> - <footer><a href={ CONFIG.url }>{ CONFIG.host }</a></footer> + <footer><a href={ _URL_ }>{ _HOST_ }</a></footer> <style> :scope display block @@ -51,7 +51,7 @@ color #546567 </style> <script> - import Connection from '../../common/scripts/server-stream'; + import Connection from '../../common/scripts/streaming/server-stream'; this.mixin('api'); @@ -177,7 +177,7 @@ width 100% </style> <script> - import uuid from '../../common/scripts/uuid'; + import uuid from 'uuid'; this.viewBoxX = 100; this.viewBoxY = 30; diff --git a/src/web/app/status/tags/index.js b/src/web/app/status/tags/index.ts index f41151949f..f41151949f 100644 --- a/src/web/app/status/tags/index.js +++ b/src/web/app/status/tags/index.ts diff --git a/src/web/app/sw.js b/src/web/app/sw.js new file mode 100644 index 0000000000..a7c84d022a --- /dev/null +++ b/src/web/app/sw.js @@ -0,0 +1,33 @@ +/** + * Service Worker + */ + +import composeNotification from './common/scripts/compose-notification'; + +// インストールされたとき +self.addEventListener('install', () => { + console.info('installed'); +}); + +// プッシュ通知を受け取ったとき +self.addEventListener('push', ev => { + console.log('pushed'); + + // クライアント取得 + ev.waitUntil(self.clients.matchAll({ + includeUncontrolled: true + }).then(clients => { + // クライアントがあったらストリームに接続しているということなので通知しない + if (clients.length != 0) return; + + const { type, body } = ev.data.json(); + + console.log(type, body); + + const n = composeNotification(type, body); + return self.registration.showNotification(n.title, { + body: n.body, + icon: n.icon, + }); + })); +}); diff --git a/src/web/assets/manifest.json b/src/web/assets/manifest.json index 0967ef424b..783d0539ac 100644 --- a/src/web/assets/manifest.json +++ b/src/web/assets/manifest.json @@ -1 +1,7 @@ -{} +{ + "short_name": "Misskey", + "name": "Misskey", + "start_url": "/", + "display": "standalone", + "background_color": "#313a42" +} diff --git a/src/web/server.ts b/src/web/server.ts index dde4eca5ec..d8a4713290 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -11,8 +11,6 @@ import * as bodyParser from 'body-parser'; import * as favicon from 'serve-favicon'; import * as compression from 'compression'; -import config from '../conf'; - /** * Init app */ @@ -37,27 +35,27 @@ app.use((req, res, next) => { * Static assets */ app.use(favicon(`${__dirname}/assets/favicon.ico`)); -app.get('/manifest.json', (req, res) => res.sendFile(`${__dirname}/assets/manifest.json`)); app.get('/apple-touch-icon.png', (req, res) => res.sendFile(`${__dirname}/assets/apple-touch-icon.png`)); app.use('/assets', express.static(`${__dirname}/assets`, { maxAge: ms('7 days') })); /** - * Common API + * ServiceWroker */ -app.get(/\/api:url/, require('./service/url-preview')); +app.get(/^\/sw\.(.+?)\.js$/, (req, res) => + res.sendFile(`${__dirname}/assets/sw.${req.params[0]}.js`)); /** - * Serve config + * Manifest */ -app.get('/config.json', (req, res) => { - res.send({ - recaptcha: { - siteKey: config.recaptcha.siteKey - } - }); -}); +app.get('/manifest.json', (req, res) => + res.sendFile(`${__dirname}/assets/manifest.json`)); + +/** + * Common API + */ +app.get(/\/api:url/, require('./service/url-preview')); /** * Routing |