diff options
Diffstat (limited to 'src/server')
563 files changed, 51350 insertions, 0 deletions
diff --git a/src/server/api/api-handler.ts b/src/server/api/api-handler.ts new file mode 100644 index 0000000000..fb603a0e2a --- /dev/null +++ b/src/server/api/api-handler.ts @@ -0,0 +1,56 @@ +import * as express from 'express'; + +import { Endpoint } from './endpoints'; +import authenticate from './authenticate'; +import { IAuthContext } from './authenticate'; +import _reply from './reply'; +import limitter from './limitter'; + +export default async (endpoint: Endpoint, req: express.Request, res: express.Response) => { + const reply = _reply.bind(null, res); + let ctx: IAuthContext; + + // Authentication + try { + ctx = await authenticate(req); + } catch (e) { + return reply(403, 'AUTHENTICATION_FAILED'); + } + + if (endpoint.secure && !ctx.isSecure) { + return reply(403, 'ACCESS_DENIED'); + } + + if (endpoint.withCredential && ctx.user == null) { + return reply(401, 'PLZ_SIGNIN'); + } + + if (ctx.app && endpoint.kind) { + if (!ctx.app.permission.some(p => p === endpoint.kind)) { + return reply(403, 'ACCESS_DENIED'); + } + } + + if (endpoint.withCredential && endpoint.limit) { + try { + await limitter(endpoint, ctx); // Rate limit + } catch (e) { + // drop request if limit exceeded + return reply(429); + } + } + + let exec = require(`${__dirname}/endpoints/${endpoint.name}`); + + if (endpoint.withFile) { + exec = exec.bind(null, req.file); + } + + // API invoking + try { + const res = await exec(req.body, ctx.user, ctx.app, ctx.isSecure); + reply(res); + } catch (e) { + reply(400, e); + } +}; diff --git a/src/server/api/authenticate.ts b/src/server/api/authenticate.ts new file mode 100644 index 0000000000..537c3d1e1f --- /dev/null +++ b/src/server/api/authenticate.ts @@ -0,0 +1,69 @@ +import * as express from 'express'; +import App from './models/app'; +import { default as User, IUser } from './models/user'; +import AccessToken from './models/access-token'; +import isNativeToken from './common/is-native-token'; + +export interface IAuthContext { + /** + * App which requested + */ + app: any; + + /** + * Authenticated user + */ + user: IUser; + + /** + * Whether requested with a User-Native Token + */ + isSecure: boolean; +} + +export default (req: express.Request) => new Promise<IAuthContext>(async (resolve, reject) => { + const token = req.body['i'] as string; + + if (token == null) { + return resolve({ + app: null, + user: null, + isSecure: false + }); + } + + if (isNativeToken(token)) { + const user: IUser = await User + .findOne({ 'account.token': token }); + + if (user === null) { + return reject('user not found'); + } + + return resolve({ + app: null, + user: user, + isSecure: true + }); + } else { + const accessToken = await AccessToken.findOne({ + hash: token.toLowerCase() + }); + + if (accessToken === null) { + return reject('invalid signature'); + } + + const app = await App + .findOne({ _id: accessToken.app_id }); + + const user = await User + .findOne({ _id: accessToken.user_id }); + + return resolve({ + app: app, + user: user, + isSecure: false + }); + } +}); diff --git a/src/server/api/bot/core.ts b/src/server/api/bot/core.ts new file mode 100644 index 0000000000..77a68aaee6 --- /dev/null +++ b/src/server/api/bot/core.ts @@ -0,0 +1,438 @@ +import * as EventEmitter from 'events'; +import * as bcrypt from 'bcryptjs'; + +import User, { ILocalAccount, IUser, init as initUser } from '../models/user'; + +import getPostSummary from '../../common/get-post-summary'; +import getUserSummary from '../../common/user/get-summary'; +import parseAcct from '../../common/user/parse-acct'; +import getNotificationSummary from '../../common/get-notification-summary'; + +const hmm = [ + '?', + 'ふぅ~む...?', + 'ちょっと何言ってるかわからないです', + '「ヘルプ」と言うと利用可能な操作が確認できますよ' +]; + +/** + * Botの頭脳 + */ +export default class BotCore extends EventEmitter { + public user: IUser = null; + + private context: Context = null; + + constructor(user?: IUser) { + super(); + + this.user = user; + } + + public clearContext() { + this.setContext(null); + } + + public setContext(context: Context) { + this.context = context; + this.emit('updated'); + + if (context) { + context.on('updated', () => { + this.emit('updated'); + }); + } + } + + public export() { + return { + user: this.user, + context: this.context ? this.context.export() : null + }; + } + + protected _import(data) { + this.user = data.user ? initUser(data.user) : null; + this.setContext(data.context ? Context.import(this, data.context) : null); + } + + public static import(data) { + const bot = new BotCore(); + bot._import(data); + return bot; + } + + public async q(query: string): Promise<string> { + if (this.context != null) { + return await this.context.q(query); + } + + if (/^@[a-zA-Z0-9-]+$/.test(query)) { + return await this.showUserCommand(query); + } + + switch (query) { + case 'ping': + return 'PONG'; + + case 'help': + case 'ヘルプ': + return '利用可能なコマンド一覧です:\n' + + 'help: これです\n' + + 'me: アカウント情報を見ます\n' + + 'login, signin: サインインします\n' + + 'logout, signout: サインアウトします\n' + + 'post: 投稿します\n' + + 'tl: タイムラインを見ます\n' + + 'no: 通知を見ます\n' + + '@<ユーザー名>: ユーザーを表示します\n' + + '\n' + + 'タイムラインや通知を見た後、「次」というとさらに遡ることができます。'; + + case 'me': + return this.user ? `${this.user.name}としてサインインしています。\n\n${getUserSummary(this.user)}` : 'サインインしていません'; + + case 'login': + case 'signin': + case 'ログイン': + case 'サインイン': + if (this.user != null) return '既にサインインしていますよ!'; + this.setContext(new SigninContext(this)); + return await this.context.greet(); + + case 'logout': + case 'signout': + case 'ログアウト': + case 'サインアウト': + if (this.user == null) return '今はサインインしてないですよ!'; + this.signout(); + return 'ご利用ありがとうございました <3'; + + case 'post': + case '投稿': + if (this.user == null) return 'まずサインインしてください。'; + this.setContext(new PostContext(this)); + return await this.context.greet(); + + case 'tl': + case 'タイムライン': + 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 '数当てゲーム': + this.setContext(new GuessingGameContext(this)); + return await this.context.greet(); + + default: + return hmm[Math.floor(Math.random() * hmm.length)]; + } + } + + public signin(user: IUser) { + this.user = user; + this.emit('signin', user); + this.emit('updated'); + } + + public signout() { + const user = this.user; + this.user = null; + this.emit('signout', user); + this.emit('updated'); + } + + public async refreshUser() { + this.user = await User.findOne({ + _id: this.user._id + }, { + fields: { + data: false + } + }); + + this.emit('updated'); + } + + public async showUserCommand(q: string): Promise<string> { + try { + const user = await require('../endpoints/users/show')(parseAcct(q.substr(1)), this.user); + + const text = getUserSummary(user); + + return text; + } catch (e) { + return `問題が発生したようです...: ${e}`; + } + } +} + +abstract class Context extends EventEmitter { + protected bot: BotCore; + + public abstract async greet(): Promise<string>; + public abstract async q(query: string): Promise<string>; + public abstract export(): any; + + constructor(bot: BotCore) { + super(); + this.bot = bot; + } + + public static import(bot: BotCore, data: any) { + if (data.type == 'guessing-game') return GuessingGameContext.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; + } +} + +class SigninContext extends Context { + private temporaryUser: IUser = null; + + public async greet(): Promise<string> { + return 'まずユーザー名を教えてください:'; + } + + public async q(query: string): Promise<string> { + if (this.temporaryUser == null) { + // Fetch user + const user: IUser = await User.findOne({ + username_lower: query.toLowerCase(), + host: null + }, { + fields: { + data: false + } + }); + + if (user === null) { + return `${query}というユーザーは存在しませんでした... もう一度教えてください:`; + } else { + this.temporaryUser = user; + this.emit('updated'); + return `パスワードを教えてください:`; + } + } else { + // Compare password + const same = await bcrypt.compare(query, (this.temporaryUser.account as ILocalAccount).password); + + if (same) { + this.bot.signin(this.temporaryUser); + this.bot.clearContext(); + return `${this.temporaryUser.name}さん、おかえりなさい!`; + } else { + return `パスワードが違います... もう一度教えてください:`; + } + } + } + + public export() { + return { + type: 'signin', + content: { + temporaryUser: this.temporaryUser + } + }; + } + + public static import(bot: BotCore, data: any) { + const context = new SigninContext(bot); + context.temporaryUser = data.temporaryUser; + return context; + } +} + +class PostContext extends Context { + public async greet(): Promise<string> { + return '内容:'; + } + + public async q(query: string): Promise<string> { + await require('../endpoints/posts/create')({ + text: query + }, this.bot.user); + this.bot.clearContext(); + return '投稿しましたよ!'; + } + + public export() { + return { + type: 'post' + }; + } + + public static import(bot: BotCore, data: any) { + const context = new PostContext(bot); + return 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, + until_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, + until_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[] = []; + + public async greet(): Promise<string> { + this.secret = Math.floor(Math.random() * 100); + this.emit('updated'); + return '0~100の秘密の数を当ててみてください:'; + } + + public async q(query: string): Promise<string> { + if (query == 'やめる') { + this.bot.clearContext(); + return 'やめました。'; + } + + const guess = parseInt(query, 10); + + if (isNaN(guess)) { + return '整数で推測してください。「やめる」と言うとゲームをやめます。'; + } + + const firsttime = this.history.indexOf(guess) === -1; + + this.history.push(guess); + this.emit('updated'); + + if (this.secret < guess) { + return firsttime ? `${guess}よりも小さいですね` : `もう一度言いますが${guess}より小さいですよ`; + } else if (this.secret > guess) { + return firsttime ? `${guess}よりも大きいですね` : `もう一度言いますが${guess}より大きいですよ`; + } else { + this.bot.clearContext(); + return `正解です🎉 (${this.history.length}回目で当てました)`; + } + } + + public export() { + return { + type: 'guessing-game', + content: { + secret: this.secret, + history: this.history + } + }; + } + + public static import(bot: BotCore, data: any) { + const context = new GuessingGameContext(bot); + context.secret = data.secret; + context.history = data.history; + return context; + } +} diff --git a/src/server/api/bot/interfaces/line.ts b/src/server/api/bot/interfaces/line.ts new file mode 100644 index 0000000000..5b3e9107f6 --- /dev/null +++ b/src/server/api/bot/interfaces/line.ts @@ -0,0 +1,238 @@ +import * as EventEmitter from 'events'; +import * as express from 'express'; +import * as request from 'request'; +import * as crypto from 'crypto'; +import User from '../../models/user'; +import config from '../../../../conf'; +import BotCore from '../core'; +import _redis from '../../../../db/redis'; +import prominence = require('prominence'); +import getAcct from '../../../common/user/get-acct'; +import parseAcct from '../../../common/user/parse-acct'; +import getPostSummary from '../../../common/get-post-summary'; + +const redis = prominence(_redis); + +// SEE: https://developers.line.me/media/messaging-api/messages/sticker_list.pdf +const stickers = [ + '297', + '298', + '299', + '300', + '301', + '302', + '303', + '304', + '305', + '306', + '307' +]; + +class LineBot extends BotCore { + private replyToken: string; + + private reply(messages: any[]) { + request.post({ + url: 'https://api.line.me/v2/bot/message/reply', + headers: { + 'Authorization': `Bearer ${config.line_bot.channel_access_token}` + }, + json: { + replyToken: this.replyToken, + messages: messages + } + }, (err, res, body) => { + if (err) { + console.error(err); + return; + } + }); + } + + public async react(ev: any): Promise<void> { + this.replyToken = ev.replyToken; + + switch (ev.type) { + // メッセージ + case 'message': + switch (ev.message.type) { + // テキスト + case 'text': + const res = await this.q(ev.message.text); + if (res == null) return; + // 返信 + this.reply([{ + type: 'text', + text: res + }]); + break; + + // スタンプ + case 'sticker': + // スタンプで返信 + this.reply([{ + type: 'sticker', + packageId: '4', + stickerId: stickers[Math.floor(Math.random() * stickers.length)] + }]); + break; + } + break; + + // postback + case 'postback': + const data = ev.postback.data; + const cmd = data.split('|')[0]; + const arg = data.split('|')[1]; + switch (cmd) { + case 'showtl': + this.showUserTimelinePostback(arg); + break; + } + break; + } + } + + public static import(data) { + const bot = new LineBot(); + bot._import(data); + return bot; + } + + public async showUserCommand(q: string) { + const user = await require('../../endpoints/users/show')(parseAcct(q.substr(1)), this.user); + + const acct = getAcct(user); + const actions = []; + + actions.push({ + type: 'postback', + label: 'タイムラインを見る', + data: `showtl|${user.id}` + }); + + if (user.account.twitter) { + actions.push({ + type: 'uri', + label: 'Twitterアカウントを見る', + uri: `https://twitter.com/${user.account.twitter.screen_name}` + }); + } + + actions.push({ + type: 'uri', + label: 'Webで見る', + uri: `${config.url}/@${acct}` + }); + + this.reply([{ + type: 'template', + altText: await super.showUserCommand(q), + template: { + type: 'buttons', + thumbnailImageUrl: `${user.avatar_url}?thumbnail&size=1024`, + title: `${user.name} (@${acct})`, + text: user.description || '(no description)', + actions: actions + } + }]); + + return null; + } + + public async showUserTimelinePostback(userId: string) { + const tl = await require('../../endpoints/users/posts')({ + user_id: userId, + limit: 5 + }, this.user); + + const text = `${tl[0].user.name}さんのタイムラインはこちらです:\n\n` + tl + .map(post => getPostSummary(post)) + .join('\n-----\n'); + + this.reply([{ + type: 'text', + text: text + }]); + } +} + +module.exports = async (app: express.Application) => { + if (config.line_bot == null) return; + + const handler = new EventEmitter(); + + handler.on('event', async (ev) => { + + const sourceId = ev.source.userId; + const sessionId = `line-bot-sessions:${sourceId}`; + + const session = await redis.get(sessionId); + let bot: LineBot; + + if (session == null) { + const user = await User.findOne({ + host: null, + 'account.line': { + user_id: sourceId + } + }); + + bot = new LineBot(user); + + bot.on('signin', user => { + User.update(user._id, { + $set: { + 'account.line': { + user_id: sourceId + } + } + }); + }); + + bot.on('signout', user => { + User.update(user._id, { + $set: { + 'account.line': { + user_id: null + } + } + }); + }); + + redis.set(sessionId, JSON.stringify(bot.export())); + } else { + bot = LineBot.import(JSON.parse(session)); + } + + bot.on('updated', () => { + redis.set(sessionId, JSON.stringify(bot.export())); + }); + + if (session != null) bot.refreshUser(); + + bot.react(ev); + }); + + app.post('/hooks/line', (req, res, next) => { + // req.headers['x-line-signature'] は常に string ですが、型定義の都合上 + // string | string[] になっているので string を明示しています + const sig1 = req.headers['x-line-signature'] as string; + + const hash = crypto.createHmac('SHA256', config.line_bot.channel_secret) + .update((req as any).rawBody); + + const sig2 = hash.digest('base64'); + + // シグネチャ比較 + if (sig1 === sig2) { + req.body.events.forEach(ev => { + handler.emit('event', ev); + }); + + res.sendStatus(200); + } else { + res.sendStatus(400); + } + }); +}; diff --git a/src/server/api/common/drive/add-file.ts b/src/server/api/common/drive/add-file.ts new file mode 100644 index 0000000000..5f3c69c15a --- /dev/null +++ b/src/server/api/common/drive/add-file.ts @@ -0,0 +1,307 @@ +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, { getGridFSBucket } from '../../models/drive-file'; +import DriveFolder from '../../models/drive-folder'; +import { pack } from '../../models/drive-file'; +import event, { publishDriveStream } from '../../event'; +import getAcct from '../../../common/user/get-acct'; +import config from '../../../../conf'; + +const gm = _gm.subClass({ + imageMagick: true +}); + +const log = debug('misskey:drive:add-file'); + +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, + path: string, + name: string = null, + comment: string = null, + folderId: mongodb.ObjectID = null, + force: boolean = false +) => { + log(`registering ${name} (user: ${getAcct(user)}, path: ${path})`); + + // 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); + }); + }))() + ]); + + log(`hash: ${hash}, mime: ${mime}, ext: ${ext}, size: ${size}`); + + // 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({ + md5: hash, + 'metadata.user_id': user._id + }); + + if (much !== null) { + log('file with same hash is found'); + return much; + } else { + log('file with same hash is not found'); + } + } + + const [wh, averageColor, folder] = await Promise.all([ + // Width and height (when image) + (async () => { + // 画像かどうか + if (!/^image\/.*$/.test(mime)) { + return null; + } + + const imageType = mime.split('/')[1]; + + // 画像でもPNGかJPEGかGIFでないならスキップ + if (imageType != 'png' && imageType != 'jpeg' && imageType != 'gif') { + return null; + } + + log('calculate image width and height...'); + + // Calculate width and height + const g = gm(fs.createReadStream(path), name); + const size = await prominence(g).size(); + + log(`image width and height is calculated: ${size.width}, ${size.height}`); + + return [size.width, size.height]; + })(), + // average color (when image) + (async () => { + // 画像かどうか + if (!/^image\/.*$/.test(mime)) { + return null; + } + + const imageType = mime.split('/')[1]; + + // 画像でもPNGかJPEGでないならスキップ + if (imageType != 'png' && imageType != 'jpeg') { + return null; + } + + log('calculate average color...'); + + const buffer = await prominence(gm(fs.createReadStream(path), name) + .setFormat('ppm') + .resize(1, 1)) // 1pxのサイズに縮小して平均色を取得するというハック + .toBuffer(); + + const r = buffer.readUInt8(buffer.length - 3); + const g = buffer.readUInt8(buffer.length - 2); + const b = buffer.readUInt8(buffer.length - 1); + + log(`average color is calculated: ${r}, ${g}, ${b}`); + + return [r, g, b]; + })(), + // 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; + }); + + log(`drive usage is ${usage}`); + + // If usage limit exceeded + if (usage + size > user.drive_capacity) { + throw 'no-free-space'; + } + })() + ]); + + const readable = fs.createReadStream(path); + + const properties = {}; + + if (wh) { + properties['width'] = wh[0]; + properties['height'] = wh[1]; + } + + if (averageColor) { + properties['average_color'] = averageColor; + } + + return addToGridFS(detectedName, readable, mime, { + user_id: user._id, + folder_id: folder !== null ? folder._id : null, + comment: comment, + properties: properties + }); +}; + +/** + * 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, shouldCleanup]): Promise<any> => new Promise((res, rej) => { + addFile(user, path, ...args) + .then(file => { + res(file); + if (shouldCleanup) { + fs.unlink(path, (e) => { + if (e) log(e.stack); + }); + } + }) + .catch(rej); + })) + .then(file => { + log(`drive file has been created ${file._id}`); + resolve(file); + + pack(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() + } + }); + } + }); + }) + .catch(reject); +}); diff --git a/src/server/api/common/drive/upload_from_url.ts b/src/server/api/common/drive/upload_from_url.ts new file mode 100644 index 0000000000..5dd9695936 --- /dev/null +++ b/src/server/api/common/drive/upload_from_url.ts @@ -0,0 +1,46 @@ +import * as URL from 'url'; +import { IDriveFile, validateFileName } from '../../models/drive-file'; +import create from './add-file'; +import * as debug from 'debug'; +import * as tmp from 'tmp'; +import * as fs from 'fs'; +import * as request from 'request'; + +const log = debug('misskey:common:drive:upload_from_url'); + +export default async (url, user, folderId = null): Promise<IDriveFile> => { + let name = URL.parse(url).pathname.split('/').pop(); + if (!validateFileName(name)) { + name = null; + } + + // Create temp file + const path = await new Promise((res: (string) => void, rej) => { + tmp.file((e, path) => { + if (e) return rej(e); + res(path); + }); + }); + + // 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); + }); + + const driveFile = await create(user, path, name, null, folderId); + + // clean-up + fs.unlink(path, (e) => { + if (e) log(e.stack); + }); + + return driveFile; +}; diff --git a/src/server/api/common/generate-native-user-token.ts b/src/server/api/common/generate-native-user-token.ts new file mode 100644 index 0000000000..2082b89a5a --- /dev/null +++ b/src/server/api/common/generate-native-user-token.ts @@ -0,0 +1,3 @@ +import rndstr from 'rndstr'; + +export default () => `!${rndstr('a-zA-Z0-9', 32)}`; diff --git a/src/server/api/common/get-friends.ts b/src/server/api/common/get-friends.ts new file mode 100644 index 0000000000..db6313816d --- /dev/null +++ b/src/server/api/common/get-friends.ts @@ -0,0 +1,26 @@ +import * as mongodb from 'mongodb'; +import Following from '../models/following'; + +export default async (me: mongodb.ObjectID, includeMe: boolean = true) => { + // Fetch relation to other users who the I follows + // SELECT followee + const myfollowing = await Following + .find({ + follower_id: me, + // 削除されたドキュメントは除く + deleted_at: { $exists: false } + }, { + fields: { + followee_id: true + } + }); + + // ID list of other users who the I follows + const myfollowingIds = myfollowing.map(follow => follow.followee_id); + + if (includeMe) { + myfollowingIds.push(me); + } + + return myfollowingIds; +}; diff --git a/src/server/api/common/get-host-lower.ts b/src/server/api/common/get-host-lower.ts new file mode 100644 index 0000000000..fc4b30439e --- /dev/null +++ b/src/server/api/common/get-host-lower.ts @@ -0,0 +1,5 @@ +import { toUnicode } from 'punycode'; + +export default host => { + return toUnicode(host).replace(/[A-Z]+/, match => match.toLowerCase()); +}; diff --git a/src/server/api/common/is-native-token.ts b/src/server/api/common/is-native-token.ts new file mode 100644 index 0000000000..0769a4812e --- /dev/null +++ b/src/server/api/common/is-native-token.ts @@ -0,0 +1 @@ +export default (token: string) => token[0] == '!'; diff --git a/src/server/api/common/notify.ts b/src/server/api/common/notify.ts new file mode 100644 index 0000000000..ae5669b84c --- /dev/null +++ b/src/server/api/common/notify.ts @@ -0,0 +1,50 @@ +import * as mongo from 'mongodb'; +import Notification from '../models/notification'; +import Mute from '../models/mute'; +import event from '../event'; +import { pack } from '../models/notification'; + +export default ( + notifiee: mongo.ObjectID, + notifier: mongo.ObjectID, + type: string, + content?: any +) => new Promise<any>(async (resolve, reject) => { + if (notifiee.equals(notifier)) { + return resolve(); + } + + // Create notification + const notification = await Notification.insert(Object.assign({ + created_at: new Date(), + notifiee_id: notifiee, + notifier_id: notifier, + type: type, + is_read: false + }, content)); + + resolve(notification); + + // Publish notification event + event(notifiee, 'notification', + await pack(notification)); + + // 3秒経っても(今回作成した)通知が既読にならなかったら「未読の通知がありますよ」イベントを発行する + setTimeout(async () => { + const fresh = await Notification.findOne({ _id: notification._id }, { is_read: true }); + if (!fresh.is_read) { + //#region ただしミュートしているユーザーからの通知なら無視 + const mute = await Mute.find({ + muter_id: notifiee, + deleted_at: { $exists: false } + }); + const mutedUserIds = mute.map(m => m.mutee_id.toString()); + if (mutedUserIds.indexOf(notifier.toString()) != -1) { + return; + } + //#endregion + + event(notifiee, 'unread_notification', await pack(notification)); + } + }, 3000); +}); diff --git a/src/server/api/common/push-sw.ts b/src/server/api/common/push-sw.ts new file mode 100644 index 0000000000..b33715eb18 --- /dev/null +++ b/src/server/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/server/api/common/read-messaging-message.ts b/src/server/api/common/read-messaging-message.ts new file mode 100644 index 0000000000..8e5e5b2b68 --- /dev/null +++ b/src/server/api/common/read-messaging-message.ts @@ -0,0 +1,66 @@ +import * as mongo from 'mongodb'; +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) + */ +export default ( + user: string | mongo.ObjectID, + otherparty: string | mongo.ObjectID, + message: string | string[] | IMessage | IMessage[] | mongo.ObjectID | mongo.ObjectID[] +) => new Promise<any>(async (resolve, reject) => { + + const userId = mongo.ObjectID.prototype.isPrototypeOf(user) + ? user + : new mongo.ObjectID(user); + + const otherpartyId = mongo.ObjectID.prototype.isPrototypeOf(otherparty) + ? otherparty + : new mongo.ObjectID(otherparty); + + const ids: mongo.ObjectID[] = Array.isArray(message) + ? mongo.ObjectID.prototype.isPrototypeOf(message[0]) + ? (message as mongo.ObjectID[]) + : typeof message[0] === 'string' + ? (message as string[]).map(m => new mongo.ObjectID(m)) + : (message as IMessage[]).map(m => m._id) + : mongo.ObjectID.prototype.isPrototypeOf(message) + ? [(message as mongo.ObjectID)] + : typeof message === 'string' + ? [new mongo.ObjectID(message)] + : [(message as IMessage)._id]; + + // Update documents + await Message.update({ + _id: { $in: ids }, + user_id: otherpartyId, + recipient_id: userId, + is_read: false + }, { + $set: { + is_read: true + } + }, { + multi: true + }); + + // 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 + .count({ + recipient_id: userId, + is_read: false + }); + + if (count == 0) { + // 全ての(いままで未読だった)自分宛てのメッセージを(これで)読みましたよというイベントを発行 + publishUserStream(userId, 'read_all_messaging_messages'); + } +}); diff --git a/src/server/api/common/read-notification.ts b/src/server/api/common/read-notification.ts new file mode 100644 index 0000000000..3009cc5d08 --- /dev/null +++ b/src/server/api/common/read-notification.ts @@ -0,0 +1,52 @@ +import * as mongo from 'mongodb'; +import { default as Notification, INotification } from '../models/notification'; +import publishUserStream from '../event'; + +/** + * Mark as read notification(s) + */ +export default ( + user: string | mongo.ObjectID, + message: string | string[] | INotification | INotification[] | mongo.ObjectID | mongo.ObjectID[] +) => new Promise<any>(async (resolve, reject) => { + + const userId = mongo.ObjectID.prototype.isPrototypeOf(user) + ? user + : new mongo.ObjectID(user); + + const ids: mongo.ObjectID[] = Array.isArray(message) + ? mongo.ObjectID.prototype.isPrototypeOf(message[0]) + ? (message as mongo.ObjectID[]) + : typeof message[0] === 'string' + ? (message as string[]).map(m => new mongo.ObjectID(m)) + : (message as INotification[]).map(m => m._id) + : mongo.ObjectID.prototype.isPrototypeOf(message) + ? [(message as mongo.ObjectID)] + : typeof message === 'string' + ? [new mongo.ObjectID(message)] + : [(message as INotification)._id]; + + // Update documents + await Notification.update({ + _id: { $in: ids }, + is_read: false + }, { + $set: { + is_read: true + } + }, { + multi: true + }); + + // Calc count of my unread notifications + const count = await Notification + .count({ + notifiee_id: userId, + is_read: false + }); + + if (count == 0) { + // 全ての(いままで未読だった)通知を(これで)読みましたよというイベントを発行 + publishUserStream(userId, 'read_all_notifications'); + } +}); diff --git a/src/server/api/common/signin.ts b/src/server/api/common/signin.ts new file mode 100644 index 0000000000..a11ea56c0c --- /dev/null +++ b/src/server/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.account.token, { + path: '/', + domain: `.${config.hostname}`, + 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/server/api/common/text/core/syntax-highlighter.ts b/src/server/api/common/text/core/syntax-highlighter.ts new file mode 100644 index 0000000000..c0396b1fc6 --- /dev/null +++ b/src/server/api/common/text/core/syntax-highlighter.ts @@ -0,0 +1,334 @@ +function escape(text) { + return text + .replace(/>/g, '>') + .replace(/</g, '<'); +} + +// 文字数が多い順にソートします +// そうしないと、「function」という文字列が与えられたときに「func」が先にマッチしてしまう可能性があるためです +const _keywords = [ + 'true', + 'false', + 'null', + 'nil', + 'undefined', + 'void', + 'var', + 'const', + 'let', + 'mut', + 'dim', + 'if', + 'then', + 'else', + 'switch', + 'match', + 'case', + 'default', + 'for', + 'each', + 'in', + 'while', + 'loop', + 'continue', + 'break', + 'do', + 'goto', + 'next', + 'end', + 'sub', + 'throw', + 'try', + 'catch', + 'finally', + 'enum', + 'delegate', + 'function', + 'func', + 'fun', + 'fn', + 'return', + 'yield', + 'async', + 'await', + 'require', + 'include', + 'import', + 'imports', + 'export', + 'exports', + 'from', + 'as', + 'using', + 'use', + 'internal', + 'module', + 'namespace', + 'where', + 'select', + 'struct', + 'union', + 'new', + 'delete', + 'this', + 'super', + 'base', + 'class', + 'interface', + 'abstract', + 'static', + 'public', + 'private', + 'protected', + 'virtual', + 'partial', + 'override', + 'extends', + 'implements', + 'constructor' +]; + +const keywords = _keywords + .concat(_keywords.map(k => k[0].toUpperCase() + k.substr(1))) + .concat(_keywords.map(k => k.toUpperCase())) + .sort((a, b) => b.length - a.length); + +const symbols = [ + '=', + '+', + '-', + '*', + '/', + '%', + '~', + '^', + '&', + '|', + '>', + '<', + '!', + '?' +]; + +const elements = [ + // comment + code => { + if (code.substr(0, 2) != '//') return null; + const match = code.match(/^\/\/(.+?)(\n|$)/); + if (!match) return null; + const comment = match[0]; + return { + html: `<span class="comment">${escape(comment)}</span>`, + next: comment.length + }; + }, + + // block comment + code => { + const match = code.match(/^\/\*([\s\S]+?)\*\//); + if (!match) return null; + return { + html: `<span class="comment">${escape(match[0])}</span>`, + next: match[0].length + }; + }, + + // string + code => { + if (!/^['"`]/.test(code)) return null; + const begin = code[0]; + let str = begin; + let thisIsNotAString = false; + for (let i = 1; i < code.length; i++) { + const char = code[i]; + if (char == '\\') { + str += char; + str += code[i + 1] || ''; + i++; + continue; + } else if (char == begin) { + str += char; + break; + } else if (char == '\n' || i == (code.length - 1)) { + thisIsNotAString = true; + break; + } else { + str += char; + } + } + if (thisIsNotAString) { + return null; + } else { + return { + html: `<span class="string">${escape(str)}</span>`, + next: str.length + }; + } + }, + + // regexp + code => { + if (code[0] != '/') return null; + let regexp = ''; + let thisIsNotARegexp = false; + for (let i = 1; i < code.length; i++) { + const char = code[i]; + if (char == '\\') { + regexp += char; + regexp += code[i + 1] || ''; + i++; + continue; + } else if (char == '/') { + break; + } else if (char == '\n' || i == (code.length - 1)) { + thisIsNotARegexp = true; + break; + } else { + regexp += char; + } + } + + if (thisIsNotARegexp) return null; + if (regexp == '') return null; + if (regexp[0] == ' ' && regexp[regexp.length - 1] == ' ') return null; + + return { + html: `<span class="regexp">/${escape(regexp)}/</span>`, + next: regexp.length + 2 + }; + }, + + // label + code => { + if (code[0] != '@') return null; + const match = code.match(/^@([a-zA-Z_-]+?)\n/); + if (!match) return null; + const label = match[0]; + return { + html: `<span class="label">${label}</span>`, + next: label.length + }; + }, + + // number + (code, i, source) => { + const prev = source[i - 1]; + if (prev && /[a-zA-Z]/.test(prev)) return null; + if (!/^[\-\+]?[0-9\.]+/.test(code)) return null; + const match = code.match(/^[\-\+]?[0-9\.]+/)[0]; + if (match) { + return { + html: `<span class="number">${match}</span>`, + next: match.length + }; + } else { + return null; + } + }, + + // nan + (code, i, source) => { + const prev = source[i - 1]; + if (prev && /[a-zA-Z]/.test(prev)) return null; + if (code.substr(0, 3) == 'NaN') { + return { + html: `<span class="nan">NaN</span>`, + next: 3 + }; + } else { + return null; + } + }, + + // method + code => { + const match = code.match(/^([a-zA-Z_-]+?)\(/); + if (!match) return null; + + if (match[1] == '-') return null; + + return { + html: `<span class="method">${match[1]}</span>`, + next: match[1].length + }; + }, + + // property + (code, i, source) => { + const prev = source[i - 1]; + if (prev != '.') return null; + + const match = code.match(/^[a-zA-Z0-9_-]+/); + if (!match) return null; + + return { + html: `<span class="property">${match[0]}</span>`, + next: match[0].length + }; + }, + + // keyword + (code, i, source) => { + const prev = source[i - 1]; + if (prev && /[a-zA-Z]/.test(prev)) return null; + + const match = keywords.filter(k => code.substr(0, k.length) == k)[0]; + if (match) { + if (/^[a-zA-Z]/.test(code.substr(match.length))) return null; + return { + html: `<span class="keyword ${match}">${match}</span>`, + next: match.length + }; + } else { + return null; + } + }, + + // symbol + code => { + const match = symbols.filter(s => code[0] == s)[0]; + if (match) { + return { + html: `<span class="symbol">${match}</span>`, + next: 1 + }; + } else { + return null; + } + } +]; + +// specify lang is todo +export default (source: string, lang?: string) => { + let code = source; + let html = ''; + + let i = 0; + + function push(token) { + html += token.html; + code = code.substr(token.next); + i += token.next; + } + + while (code != '') { + const parsed = elements.some(el => { + const e = el(code, i, source); + if (e) { + push(e); + return true; + } else { + return false; + } + }); + + if (!parsed) { + push({ + html: escape(code[0]), + next: 1 + }); + } + } + + return html; +}; diff --git a/src/server/api/common/text/elements/bold.ts b/src/server/api/common/text/elements/bold.ts new file mode 100644 index 0000000000..ce25764457 --- /dev/null +++ b/src/server/api/common/text/elements/bold.ts @@ -0,0 +1,14 @@ +/** + * Bold + */ + +module.exports = text => { + const match = text.match(/^\*\*(.+?)\*\*/); + if (!match) return null; + const bold = match[0]; + return { + type: 'bold', + content: bold, + bold: bold.substr(2, bold.length - 4) + }; +}; diff --git a/src/server/api/common/text/elements/code.ts b/src/server/api/common/text/elements/code.ts new file mode 100644 index 0000000000..4821e95fe2 --- /dev/null +++ b/src/server/api/common/text/elements/code.ts @@ -0,0 +1,17 @@ +/** + * Code (block) + */ + +import genHtml from '../core/syntax-highlighter'; + +module.exports = text => { + const match = text.match(/^```([\s\S]+?)```/); + if (!match) return null; + const code = match[0]; + return { + type: 'code', + content: code, + code: code.substr(3, code.length - 6).trim(), + html: genHtml(code.substr(3, code.length - 6).trim()) + }; +}; diff --git a/src/server/api/common/text/elements/emoji.ts b/src/server/api/common/text/elements/emoji.ts new file mode 100644 index 0000000000..e24231a223 --- /dev/null +++ b/src/server/api/common/text/elements/emoji.ts @@ -0,0 +1,14 @@ +/** + * Emoji + */ + +module.exports = text => { + const match = text.match(/^:[a-zA-Z0-9+-_]+:/); + if (!match) return null; + const emoji = match[0]; + return { + type: 'emoji', + content: emoji, + emoji: emoji.substr(1, emoji.length - 2) + }; +}; diff --git a/src/server/api/common/text/elements/hashtag.ts b/src/server/api/common/text/elements/hashtag.ts new file mode 100644 index 0000000000..ee57b140b8 --- /dev/null +++ b/src/server/api/common/text/elements/hashtag.ts @@ -0,0 +1,19 @@ +/** + * Hashtag + */ + +module.exports = (text, i) => { + if (!(/^\s#[^\s]+/.test(text) || (i == 0 && /^#[^\s]+/.test(text)))) return null; + const isHead = text[0] == '#'; + const hashtag = text.match(/^\s?#[^\s]+/)[0]; + const res: any[] = !isHead ? [{ + type: 'text', + content: text[0] + }] : []; + res.push({ + type: 'hashtag', + content: isHead ? hashtag : hashtag.substr(1), + hashtag: isHead ? hashtag.substr(1) : hashtag.substr(2) + }); + return res; +}; diff --git a/src/server/api/common/text/elements/inline-code.ts b/src/server/api/common/text/elements/inline-code.ts new file mode 100644 index 0000000000..9f9ef51a2b --- /dev/null +++ b/src/server/api/common/text/elements/inline-code.ts @@ -0,0 +1,17 @@ +/** + * Code (inline) + */ + +import genHtml from '../core/syntax-highlighter'; + +module.exports = text => { + const match = text.match(/^`(.+?)`/); + if (!match) return null; + const code = match[0]; + return { + type: 'inline-code', + content: code, + code: code.substr(1, code.length - 2).trim(), + html: genHtml(code.substr(1, code.length - 2).trim()) + }; +}; diff --git a/src/server/api/common/text/elements/link.ts b/src/server/api/common/text/elements/link.ts new file mode 100644 index 0000000000..35563ddc3d --- /dev/null +++ b/src/server/api/common/text/elements/link.ts @@ -0,0 +1,19 @@ +/** + * Link + */ + +module.exports = text => { + const match = text.match(/^\??\[([^\[\]]+?)\]\((https?:\/\/[\w\/:%#@\$&\?!\(\)\[\]~\.=\+\-]+?)\)/); + if (!match) return null; + const silent = text[0] == '?'; + const link = match[0]; + const title = match[1]; + const url = match[2]; + return { + type: 'link', + content: link, + title: title, + url: url, + silent: silent + }; +}; diff --git a/src/server/api/common/text/elements/mention.ts b/src/server/api/common/text/elements/mention.ts new file mode 100644 index 0000000000..2025dfdaad --- /dev/null +++ b/src/server/api/common/text/elements/mention.ts @@ -0,0 +1,17 @@ +/** + * Mention + */ +import parseAcct from '../../../../common/user/parse-acct'; + +module.exports = text => { + const match = text.match(/^(?:@[a-zA-Z0-9\-]+){1,2}/); + if (!match) return null; + const mention = match[0]; + const { username, host } = parseAcct(mention.substr(1)); + return { + type: 'mention', + content: mention, + username, + host + }; +}; diff --git a/src/server/api/common/text/elements/quote.ts b/src/server/api/common/text/elements/quote.ts new file mode 100644 index 0000000000..cc8cfffdc4 --- /dev/null +++ b/src/server/api/common/text/elements/quote.ts @@ -0,0 +1,14 @@ +/** + * Quoted text + */ + +module.exports = text => { + const match = text.match(/^"([\s\S]+?)\n"/); + if (!match) return null; + const quote = match[0]; + return { + type: 'quote', + content: quote, + quote: quote.substr(1, quote.length - 2).trim(), + }; +}; diff --git a/src/server/api/common/text/elements/url.ts b/src/server/api/common/text/elements/url.ts new file mode 100644 index 0000000000..1003aff9c3 --- /dev/null +++ b/src/server/api/common/text/elements/url.ts @@ -0,0 +1,14 @@ +/** + * URL + */ + +module.exports = text => { + const match = text.match(/^https?:\/\/[\w\/:%#@\$&\?!\(\)\[\]~\.=\+\-]+/); + if (!match) return null; + const url = match[0]; + return { + type: 'url', + content: url, + url: url + }; +}; diff --git a/src/server/api/common/text/index.ts b/src/server/api/common/text/index.ts new file mode 100644 index 0000000000..1e2398dc38 --- /dev/null +++ b/src/server/api/common/text/index.ts @@ -0,0 +1,72 @@ +/** + * Misskey Text Analyzer + */ + +const elements = [ + require('./elements/bold'), + require('./elements/url'), + require('./elements/link'), + require('./elements/mention'), + require('./elements/hashtag'), + require('./elements/code'), + require('./elements/inline-code'), + require('./elements/quote'), + require('./elements/emoji') +]; + +export default (source: string) => { + + if (source == '') { + return null; + } + + const tokens = []; + + function push(token) { + if (token != null) { + tokens.push(token); + source = source.substr(token.content.length); + } + } + + let i = 0; + + // パース + while (source != '') { + const parsed = elements.some(el => { + let _tokens = el(source, i); + if (_tokens) { + if (!Array.isArray(_tokens)) { + _tokens = [_tokens]; + } + _tokens.forEach(push); + return true; + } else { + return false; + } + }); + + if (!parsed) { + push({ + type: 'text', + content: source[0] + }); + } + + i++; + } + + // テキストを纏める + tokens[0] = [tokens[0]]; + return tokens.reduce((a, b) => { + if (a[a.length - 1].type == 'text' && b.type == 'text') { + const tail = a.pop(); + return a.concat({ + type: 'text', + content: tail.content + b.content + }); + } else { + return a.concat(b); + } + }); +}; diff --git a/src/server/api/common/watch-post.ts b/src/server/api/common/watch-post.ts new file mode 100644 index 0000000000..1a50f0edaa --- /dev/null +++ b/src/server/api/common/watch-post.ts @@ -0,0 +1,26 @@ +import * as mongodb from 'mongodb'; +import Watching from '../models/post-watching'; + +export default async (me: mongodb.ObjectID, post: object) => { + // 自分の投稿はwatchできない + if (me.equals((post as any).user_id)) { + return; + } + + // if watching now + const exist = await Watching.findOne({ + post_id: (post as any)._id, + user_id: me, + deleted_at: { $exists: false } + }); + + if (exist !== null) { + return; + } + + await Watching.insert({ + created_at: new Date(), + post_id: (post as any)._id, + user_id: me + }); +}; diff --git a/src/server/api/endpoints.ts b/src/server/api/endpoints.ts new file mode 100644 index 0000000000..c7100bd036 --- /dev/null +++ b/src/server/api/endpoints.ts @@ -0,0 +1,584 @@ +const ms = require('ms'); + +/** + * エンドポイントを表します。 + */ +export type Endpoint = { + + /** + * エンドポイント名 + */ + name: string; + + /** + * このエンドポイントにリクエストするのにユーザー情報が必須か否か + * 省略した場合は false として解釈されます。 + */ + withCredential?: boolean; + + /** + * エンドポイントのリミテーションに関するやつ + * 省略した場合はリミテーションは無いものとして解釈されます。 + * また、withCredential が false の場合はリミテーションを行うことはできません。 + */ + limit?: { + + /** + * 複数のエンドポイントでリミットを共有したい場合に指定するキー + */ + key?: string; + + /** + * リミットを適用する期間(ms) + * このプロパティを設定する場合、max プロパティも設定する必要があります。 + */ + duration?: number; + + /** + * durationで指定した期間内にいくつまでリクエストできるのか + * このプロパティを設定する場合、duration プロパティも設定する必要があります。 + */ + max?: number; + + /** + * 最低でもどれくらいの間隔を開けてリクエストしなければならないか(ms) + */ + minInterval?: number; + }; + + /** + * ファイルの添付を必要とするか否か + * 省略した場合は false として解釈されます。 + */ + withFile?: boolean; + + /** + * サードパーティアプリからはリクエストすることができないか否か + * 省略した場合は false として解釈されます。 + */ + secure?: boolean; + + /** + * エンドポイントの種類 + * パーミッションの実現に利用されます。 + */ + kind?: string; +}; + +const endpoints: Endpoint[] = [ + { + name: 'meta' + }, + { + name: 'stats' + }, + { + name: 'username/available' + }, + { + name: 'my/apps', + withCredential: true + }, + { + name: 'app/create', + withCredential: true, + limit: { + duration: ms('1day'), + max: 3 + } + }, + { + name: 'app/show' + }, + { + name: 'app/name_id/available' + }, + { + name: 'auth/session/generate' + }, + { + name: 'auth/session/show' + }, + { + name: 'auth/session/userkey' + }, + { + name: 'auth/accept', + withCredential: true, + secure: true + }, + { + name: 'auth/deny', + withCredential: true, + secure: true + }, + { + name: 'aggregation/posts', + }, + { + name: 'aggregation/users', + }, + { + name: 'aggregation/users/activity', + }, + { + name: 'aggregation/users/post', + }, + { + name: 'aggregation/users/followers' + }, + { + name: 'aggregation/users/following' + }, + { + name: 'aggregation/users/reaction' + }, + { + name: 'aggregation/posts/repost' + }, + { + name: 'aggregation/posts/reply' + }, + { + name: 'aggregation/posts/reaction' + }, + { + name: 'aggregation/posts/reactions' + }, + + { + name: 'sw/register', + withCredential: true + }, + + { + name: 'i', + withCredential: true + }, + { + name: 'i/2fa/register', + withCredential: true, + secure: true + }, + { + name: 'i/2fa/unregister', + withCredential: true, + secure: true + }, + { + name: 'i/2fa/done', + withCredential: true, + secure: true + }, + { + name: 'i/update', + withCredential: true, + limit: { + duration: ms('1day'), + max: 50 + }, + kind: 'account-write' + }, + { + name: 'i/update_home', + withCredential: true, + secure: true + }, + { + name: 'i/update_mobile_home', + withCredential: true, + secure: true + }, + { + name: 'i/change_password', + withCredential: true, + secure: true + }, + { + name: 'i/regenerate_token', + withCredential: true, + secure: true + }, + { + name: 'i/update_client_setting', + withCredential: true, + secure: true + }, + { + name: 'i/pin', + kind: 'account-write' + }, + { + name: 'i/appdata/get', + withCredential: true + }, + { + name: 'i/appdata/set', + withCredential: true + }, + { + name: 'i/signin_history', + withCredential: true, + kind: 'account-read' + }, + { + name: 'i/authorized_apps', + withCredential: true, + secure: true + }, + + { + name: 'i/notifications', + withCredential: true, + kind: 'notification-read' + }, + + { + name: 'othello/match', + withCredential: true + }, + + { + name: 'othello/match/cancel', + withCredential: true + }, + + { + name: 'othello/invitations', + withCredential: true + }, + + { + name: 'othello/games', + withCredential: true + }, + + { + name: 'othello/games/show' + }, + + { + name: 'mute/create', + withCredential: true, + kind: 'account/write' + }, + { + name: 'mute/delete', + withCredential: true, + kind: 'account/write' + }, + { + name: 'mute/list', + withCredential: true, + kind: 'account/read' + }, + + { + name: 'notifications/get_unread_count', + withCredential: true, + kind: 'notification-read' + }, + { + name: 'notifications/delete', + withCredential: true, + kind: 'notification-write' + }, + { + name: 'notifications/delete_all', + withCredential: true, + kind: 'notification-write' + }, + { + name: 'notifications/mark_as_read_all', + withCredential: true, + kind: 'notification-write' + }, + + { + name: 'drive', + withCredential: true, + kind: 'drive-read' + }, + { + name: 'drive/stream', + withCredential: true, + kind: 'drive-read' + }, + { + name: 'drive/files', + withCredential: true, + kind: 'drive-read' + }, + { + name: 'drive/files/create', + withCredential: true, + limit: { + duration: ms('1hour'), + max: 100 + }, + withFile: true, + kind: 'drive-write' + }, + { + name: 'drive/files/upload_from_url', + withCredential: true, + limit: { + duration: ms('1hour'), + max: 10 + }, + kind: 'drive-write' + }, + { + name: 'drive/files/show', + withCredential: true, + kind: 'drive-read' + }, + { + name: 'drive/files/find', + withCredential: true, + kind: 'drive-read' + }, + { + name: 'drive/files/delete', + withCredential: true, + kind: 'drive-write' + }, + { + name: 'drive/files/update', + withCredential: true, + kind: 'drive-write' + }, + { + name: 'drive/folders', + withCredential: true, + kind: 'drive-read' + }, + { + name: 'drive/folders/create', + withCredential: true, + limit: { + duration: ms('1hour'), + max: 50 + }, + kind: 'drive-write' + }, + { + name: 'drive/folders/show', + withCredential: true, + kind: 'drive-read' + }, + { + name: 'drive/folders/find', + withCredential: true, + kind: 'drive-read' + }, + { + name: 'drive/folders/update', + withCredential: true, + kind: 'drive-write' + }, + + { + name: 'users' + }, + { + name: 'users/show' + }, + { + name: 'users/search' + }, + { + name: 'users/search_by_username' + }, + { + name: 'users/posts' + }, + { + name: 'users/following' + }, + { + name: 'users/followers' + }, + { + name: 'users/recommendation', + withCredential: true, + kind: 'account-read' + }, + { + name: 'users/get_frequently_replied_users' + }, + + { + name: 'following/create', + withCredential: true, + limit: { + duration: ms('1hour'), + max: 100 + }, + kind: 'following-write' + }, + { + name: 'following/delete', + withCredential: true, + limit: { + duration: ms('1hour'), + max: 100 + }, + kind: 'following-write' + }, + + { + name: 'posts' + }, + { + name: 'posts/show' + }, + { + name: 'posts/replies' + }, + { + name: 'posts/context' + }, + { + name: 'posts/create', + withCredential: true, + limit: { + duration: ms('1hour'), + max: 120, + minInterval: ms('1second') + }, + kind: 'post-write' + }, + { + name: 'posts/reposts' + }, + { + name: 'posts/search' + }, + { + name: 'posts/timeline', + withCredential: true, + limit: { + duration: ms('10minutes'), + max: 100 + } + }, + { + name: 'posts/mentions', + withCredential: true, + limit: { + duration: ms('10minutes'), + max: 100 + } + }, + { + name: 'posts/trend', + withCredential: true + }, + { + name: 'posts/categorize', + withCredential: true + }, + { + name: 'posts/reactions', + withCredential: true + }, + { + name: 'posts/reactions/create', + withCredential: true, + limit: { + duration: ms('1hour'), + max: 100 + }, + kind: 'reaction-write' + }, + { + name: 'posts/reactions/delete', + withCredential: true, + limit: { + duration: ms('1hour'), + max: 100 + }, + kind: 'reaction-write' + }, + { + name: 'posts/favorites/create', + withCredential: true, + limit: { + duration: ms('1hour'), + max: 100 + }, + kind: 'favorite-write' + }, + { + name: 'posts/favorites/delete', + withCredential: true, + limit: { + duration: ms('1hour'), + max: 100 + }, + kind: 'favorite-write' + }, + { + name: 'posts/polls/vote', + withCredential: true, + limit: { + duration: ms('1hour'), + max: 100 + }, + kind: 'vote-write' + }, + { + name: 'posts/polls/recommendation', + withCredential: true + }, + + { + name: 'messaging/history', + withCredential: true, + kind: 'messaging-read' + }, + { + name: 'messaging/unread', + withCredential: true, + kind: 'messaging-read' + }, + { + name: 'messaging/messages', + withCredential: true, + kind: 'messaging-read' + }, + { + name: 'messaging/messages/create', + withCredential: true, + kind: 'messaging-write' + }, + { + name: 'channels/create', + withCredential: true, + limit: { + duration: ms('1hour'), + max: 3, + minInterval: ms('10seconds') + } + }, + { + name: 'channels/show' + }, + { + name: 'channels/posts' + }, + { + name: 'channels/watch', + withCredential: true + }, + { + name: 'channels/unwatch', + withCredential: true + }, + { + name: 'channels' + }, +]; + +export default endpoints; diff --git a/src/server/api/endpoints/aggregation/posts.ts b/src/server/api/endpoints/aggregation/posts.ts new file mode 100644 index 0000000000..9d8bccbdb2 --- /dev/null +++ b/src/server/api/endpoints/aggregation/posts.ts @@ -0,0 +1,90 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import Post from '../../models/post'; + +/** + * Aggregate posts + * + * @param {any} params + * @return {Promise<any>} + */ +module.exports = params => new Promise(async (res, rej) => { + // Get 'limit' parameter + const [limit = 365, limitErr] = $(params.limit).optional.number().range(1, 365).$; + if (limitErr) return rej('invalid limit param'); + + const datas = await Post + .aggregate([ + { $project: { + repost_id: '$repost_id', + reply_id: '$reply_id', + created_at: { $add: ['$created_at', 9 * 60 * 60 * 1000] } // Convert into JST + }}, + { $project: { + date: { + year: { $year: '$created_at' }, + month: { $month: '$created_at' }, + day: { $dayOfMonth: '$created_at' } + }, + type: { + $cond: { + if: { $ne: ['$repost_id', null] }, + then: 'repost', + else: { + $cond: { + if: { $ne: ['$reply_id', null] }, + then: 'reply', + else: 'post' + } + } + } + }} + }, + { $group: { _id: { + date: '$date', + type: '$type' + }, count: { $sum: 1 } } }, + { $group: { + _id: '$_id.date', + data: { $addToSet: { + type: '$_id.type', + count: '$count' + }} + } } + ]); + + datas.forEach(data => { + data.date = data._id; + delete data._id; + + data.posts = (data.data.filter(x => x.type == 'post')[0] || { count: 0 }).count; + data.reposts = (data.data.filter(x => x.type == 'repost')[0] || { count: 0 }).count; + data.replies = (data.data.filter(x => x.type == 'reply')[0] || { count: 0 }).count; + + delete data.data; + }); + + const graph = []; + + for (let i = 0; i < limit; i++) { + const day = new Date(new Date().setDate(new Date().getDate() - i)); + + const data = datas.filter(d => + d.date.year == day.getFullYear() && d.date.month == day.getMonth() + 1 && d.date.day == day.getDate() + )[0]; + + if (data) { + graph.push(data); + } else { + graph.push({ + posts: 0, + reposts: 0, + replies: 0 + }); + } + } + + res(graph); +}); diff --git a/src/server/api/endpoints/aggregation/posts/reaction.ts b/src/server/api/endpoints/aggregation/posts/reaction.ts new file mode 100644 index 0000000000..eb99b9d088 --- /dev/null +++ b/src/server/api/endpoints/aggregation/posts/reaction.ts @@ -0,0 +1,76 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import Post from '../../../models/post'; +import Reaction from '../../../models/post-reaction'; + +/** + * Aggregate reaction of a post + * + * @param {any} params + * @return {Promise<any>} + */ +module.exports = (params) => new Promise(async (res, rej) => { + // Get 'post_id' parameter + const [postId, postIdErr] = $(params.post_id).id().$; + if (postIdErr) return rej('invalid post_id param'); + + // Lookup post + const post = await Post.findOne({ + _id: postId + }); + + if (post === null) { + return rej('post not found'); + } + + const datas = await Reaction + .aggregate([ + { $match: { post_id: post._id } }, + { $project: { + created_at: { $add: ['$created_at', 9 * 60 * 60 * 1000] } // Convert into JST + }}, + { $project: { + date: { + year: { $year: '$created_at' }, + month: { $month: '$created_at' }, + day: { $dayOfMonth: '$created_at' } + } + }}, + { $group: { + _id: '$date', + count: { $sum: 1 } + }} + ]); + + datas.forEach(data => { + data.date = data._id; + delete data._id; + }); + + const graph = []; + + for (let i = 0; i < 30; i++) { + const day = new Date(new Date().setDate(new Date().getDate() - i)); + + const data = datas.filter(d => + d.date.year == day.getFullYear() && d.date.month == day.getMonth() + 1 && d.date.day == day.getDate() + )[0]; + + if (data) { + graph.push(data); + } else { + graph.push({ + date: { + year: day.getFullYear(), + month: day.getMonth() + 1, // In JavaScript, month is zero-based. + day: day.getDate() + }, + count: 0 + }); + } + } + + res(graph); +}); diff --git a/src/server/api/endpoints/aggregation/posts/reactions.ts b/src/server/api/endpoints/aggregation/posts/reactions.ts new file mode 100644 index 0000000000..790b523be9 --- /dev/null +++ b/src/server/api/endpoints/aggregation/posts/reactions.ts @@ -0,0 +1,72 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import Post from '../../../models/post'; +import Reaction from '../../../models/post-reaction'; + +/** + * Aggregate reactions of a post + * + * @param {any} params + * @return {Promise<any>} + */ +module.exports = (params) => new Promise(async (res, rej) => { + // Get 'post_id' parameter + const [postId, postIdErr] = $(params.post_id).id().$; + if (postIdErr) return rej('invalid post_id param'); + + // Lookup post + const post = await Post.findOne({ + _id: postId + }); + + if (post === null) { + return rej('post not found'); + } + + const startTime = new Date(new Date().setMonth(new Date().getMonth() - 1)); + + const reactions = await Reaction + .find({ + post_id: post._id, + $or: [ + { deleted_at: { $exists: false } }, + { deleted_at: { $gt: startTime } } + ] + }, { + sort: { + _id: -1 + }, + fields: { + _id: false, + post_id: false + } + }); + + const graph = []; + + for (let i = 0; i < 30; i++) { + let day = new Date(new Date().setDate(new Date().getDate() - i)); + day = new Date(day.setMilliseconds(999)); + day = new Date(day.setSeconds(59)); + day = new Date(day.setMinutes(59)); + day = new Date(day.setHours(23)); + // day = day.getTime(); + + const count = reactions.filter(r => + r.created_at < day && (r.deleted_at == null || r.deleted_at > day) + ).length; + + graph.push({ + date: { + year: day.getFullYear(), + month: day.getMonth() + 1, // In JavaScript, month is zero-based. + day: day.getDate() + }, + count: count + }); + } + + res(graph); +}); diff --git a/src/server/api/endpoints/aggregation/posts/reply.ts b/src/server/api/endpoints/aggregation/posts/reply.ts new file mode 100644 index 0000000000..b114c34e1e --- /dev/null +++ b/src/server/api/endpoints/aggregation/posts/reply.ts @@ -0,0 +1,75 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import Post from '../../../models/post'; + +/** + * Aggregate reply of a post + * + * @param {any} params + * @return {Promise<any>} + */ +module.exports = (params) => new Promise(async (res, rej) => { + // Get 'post_id' parameter + const [postId, postIdErr] = $(params.post_id).id().$; + if (postIdErr) return rej('invalid post_id param'); + + // Lookup post + const post = await Post.findOne({ + _id: postId + }); + + if (post === null) { + return rej('post not found'); + } + + const datas = await Post + .aggregate([ + { $match: { reply: post._id } }, + { $project: { + created_at: { $add: ['$created_at', 9 * 60 * 60 * 1000] } // Convert into JST + }}, + { $project: { + date: { + year: { $year: '$created_at' }, + month: { $month: '$created_at' }, + day: { $dayOfMonth: '$created_at' } + } + }}, + { $group: { + _id: '$date', + count: { $sum: 1 } + }} + ]); + + datas.forEach(data => { + data.date = data._id; + delete data._id; + }); + + const graph = []; + + for (let i = 0; i < 30; i++) { + const day = new Date(new Date().setDate(new Date().getDate() - i)); + + const data = datas.filter(d => + d.date.year == day.getFullYear() && d.date.month == day.getMonth() + 1 && d.date.day == day.getDate() + )[0]; + + if (data) { + graph.push(data); + } else { + graph.push({ + date: { + year: day.getFullYear(), + month: day.getMonth() + 1, // In JavaScript, month is zero-based. + day: day.getDate() + }, + count: 0 + }); + } + } + + res(graph); +}); diff --git a/src/server/api/endpoints/aggregation/posts/repost.ts b/src/server/api/endpoints/aggregation/posts/repost.ts new file mode 100644 index 0000000000..217159caa7 --- /dev/null +++ b/src/server/api/endpoints/aggregation/posts/repost.ts @@ -0,0 +1,75 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import Post from '../../../models/post'; + +/** + * Aggregate repost of a post + * + * @param {any} params + * @return {Promise<any>} + */ +module.exports = (params) => new Promise(async (res, rej) => { + // Get 'post_id' parameter + const [postId, postIdErr] = $(params.post_id).id().$; + if (postIdErr) return rej('invalid post_id param'); + + // Lookup post + const post = await Post.findOne({ + _id: postId + }); + + if (post === null) { + return rej('post not found'); + } + + const datas = await Post + .aggregate([ + { $match: { repost_id: post._id } }, + { $project: { + created_at: { $add: ['$created_at', 9 * 60 * 60 * 1000] } // Convert into JST + }}, + { $project: { + date: { + year: { $year: '$created_at' }, + month: { $month: '$created_at' }, + day: { $dayOfMonth: '$created_at' } + } + }}, + { $group: { + _id: '$date', + count: { $sum: 1 } + }} + ]); + + datas.forEach(data => { + data.date = data._id; + delete data._id; + }); + + const graph = []; + + for (let i = 0; i < 30; i++) { + const day = new Date(new Date().setDate(new Date().getDate() - i)); + + const data = datas.filter(d => + d.date.year == day.getFullYear() && d.date.month == day.getMonth() + 1 && d.date.day == day.getDate() + )[0]; + + if (data) { + graph.push(data); + } else { + graph.push({ + date: { + year: day.getFullYear(), + month: day.getMonth() + 1, // In JavaScript, month is zero-based. + day: day.getDate() + }, + count: 0 + }); + } + } + + res(graph); +}); diff --git a/src/server/api/endpoints/aggregation/users.ts b/src/server/api/endpoints/aggregation/users.ts new file mode 100644 index 0000000000..e38ce92ff9 --- /dev/null +++ b/src/server/api/endpoints/aggregation/users.ts @@ -0,0 +1,61 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import User from '../../models/user'; + +/** + * Aggregate users + * + * @param {any} params + * @return {Promise<any>} + */ +module.exports = params => new Promise(async (res, rej) => { + // Get 'limit' parameter + const [limit = 365, limitErr] = $(params.limit).optional.number().range(1, 365).$; + if (limitErr) return rej('invalid limit param'); + + const users = await User + .find({}, { + sort: { + _id: -1 + }, + fields: { + _id: false, + created_at: true, + deleted_at: true + } + }); + + const graph = []; + + for (let i = 0; i < limit; i++) { + let dayStart = new Date(new Date().setDate(new Date().getDate() - i)); + dayStart = new Date(dayStart.setMilliseconds(0)); + dayStart = new Date(dayStart.setSeconds(0)); + dayStart = new Date(dayStart.setMinutes(0)); + dayStart = new Date(dayStart.setHours(0)); + + let dayEnd = new Date(new Date().setDate(new Date().getDate() - i)); + dayEnd = new Date(dayEnd.setMilliseconds(999)); + dayEnd = new Date(dayEnd.setSeconds(59)); + dayEnd = new Date(dayEnd.setMinutes(59)); + dayEnd = new Date(dayEnd.setHours(23)); + // day = day.getTime(); + + const total = users.filter(u => + u.created_at < dayEnd && (u.deleted_at == null || u.deleted_at > dayEnd) + ).length; + + const created = users.filter(u => + u.created_at < dayEnd && u.created_at > dayStart + ).length; + + graph.push({ + total: total, + created: created + }); + } + + res(graph); +}); diff --git a/src/server/api/endpoints/aggregation/users/activity.ts b/src/server/api/endpoints/aggregation/users/activity.ts new file mode 100644 index 0000000000..102a71d7cb --- /dev/null +++ b/src/server/api/endpoints/aggregation/users/activity.ts @@ -0,0 +1,116 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import User from '../../../models/user'; +import Post from '../../../models/post'; + +// TODO: likeやfollowも集計 + +/** + * Aggregate activity of a user + * + * @param {any} params + * @return {Promise<any>} + */ +module.exports = (params) => new Promise(async (res, rej) => { + // Get 'limit' parameter + const [limit = 365, limitErr] = $(params.limit).optional.number().range(1, 365).$; + if (limitErr) return rej('invalid limit param'); + + // Get 'user_id' parameter + const [userId, userIdErr] = $(params.user_id).id().$; + if (userIdErr) return rej('invalid user_id param'); + + // Lookup user + const user = await User.findOne({ + _id: userId + }, { + fields: { + _id: true + } + }); + + if (user === null) { + return rej('user not found'); + } + + const datas = await Post + .aggregate([ + { $match: { user_id: user._id } }, + { $project: { + repost_id: '$repost_id', + reply_id: '$reply_id', + created_at: { $add: ['$created_at', 9 * 60 * 60 * 1000] } // Convert into JST + }}, + { $project: { + date: { + year: { $year: '$created_at' }, + month: { $month: '$created_at' }, + day: { $dayOfMonth: '$created_at' } + }, + type: { + $cond: { + if: { $ne: ['$repost_id', null] }, + then: 'repost', + else: { + $cond: { + if: { $ne: ['$reply_id', null] }, + then: 'reply', + else: 'post' + } + } + } + }} + }, + { $group: { _id: { + date: '$date', + type: '$type' + }, count: { $sum: 1 } } }, + { $group: { + _id: '$_id.date', + data: { $addToSet: { + type: '$_id.type', + count: '$count' + }} + } } + ]); + + datas.forEach(data => { + data.date = data._id; + delete data._id; + + data.posts = (data.data.filter(x => x.type == 'post')[0] || { count: 0 }).count; + data.reposts = (data.data.filter(x => x.type == 'repost')[0] || { count: 0 }).count; + data.replies = (data.data.filter(x => x.type == 'reply')[0] || { count: 0 }).count; + + delete data.data; + }); + + const graph = []; + + for (let i = 0; i < limit; i++) { + const day = new Date(new Date().setDate(new Date().getDate() - i)); + + const data = datas.filter(d => + d.date.year == day.getFullYear() && d.date.month == day.getMonth() + 1 && d.date.day == day.getDate() + )[0]; + + if (data) { + graph.push(data); + } else { + graph.push({ + date: { + year: day.getFullYear(), + month: day.getMonth() + 1, // In JavaScript, month is zero-based. + day: day.getDate() + }, + posts: 0, + reposts: 0, + replies: 0 + }); + } + } + + res(graph); +}); diff --git a/src/server/api/endpoints/aggregation/users/followers.ts b/src/server/api/endpoints/aggregation/users/followers.ts new file mode 100644 index 0000000000..3022b2b002 --- /dev/null +++ b/src/server/api/endpoints/aggregation/users/followers.ts @@ -0,0 +1,74 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import User from '../../../models/user'; +import Following from '../../../models/following'; + +/** + * Aggregate followers of a user + * + * @param {any} params + * @return {Promise<any>} + */ +module.exports = (params) => new Promise(async (res, rej) => { + // Get 'user_id' parameter + const [userId, userIdErr] = $(params.user_id).id().$; + if (userIdErr) return rej('invalid user_id param'); + + // Lookup user + const user = await User.findOne({ + _id: userId + }, { + fields: { + _id: true + } + }); + + if (user === null) { + return rej('user not found'); + } + + const startTime = new Date(new Date().setMonth(new Date().getMonth() - 1)); + + const following = await Following + .find({ + followee_id: user._id, + $or: [ + { deleted_at: { $exists: false } }, + { deleted_at: { $gt: startTime } } + ] + }, { + _id: false, + follower_id: false, + followee_id: false + }, { + sort: { created_at: -1 } + }); + + const graph = []; + + for (let i = 0; i < 30; i++) { + let day = new Date(new Date().setDate(new Date().getDate() - i)); + day = new Date(day.setMilliseconds(999)); + day = new Date(day.setSeconds(59)); + day = new Date(day.setMinutes(59)); + day = new Date(day.setHours(23)); + // day = day.getTime(); + + const count = following.filter(f => + f.created_at < day && (f.deleted_at == null || f.deleted_at > day) + ).length; + + graph.push({ + date: { + year: day.getFullYear(), + month: day.getMonth() + 1, // In JavaScript, month is zero-based. + day: day.getDate() + }, + count: count + }); + } + + res(graph); +}); diff --git a/src/server/api/endpoints/aggregation/users/following.ts b/src/server/api/endpoints/aggregation/users/following.ts new file mode 100644 index 0000000000..92da7e6921 --- /dev/null +++ b/src/server/api/endpoints/aggregation/users/following.ts @@ -0,0 +1,73 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import User from '../../../models/user'; +import Following from '../../../models/following'; + +/** + * Aggregate following of a user + * + * @param {any} params + * @return {Promise<any>} + */ +module.exports = (params) => new Promise(async (res, rej) => { + // Get 'user_id' parameter + const [userId, userIdErr] = $(params.user_id).id().$; + if (userIdErr) return rej('invalid user_id param'); + + // Lookup user + const user = await User.findOne({ + _id: userId + }, { + fields: { + _id: true + } + }); + + if (user === null) { + return rej('user not found'); + } + + const startTime = new Date(new Date().setMonth(new Date().getMonth() - 1)); + + const following = await Following + .find({ + follower_id: user._id, + $or: [ + { deleted_at: { $exists: false } }, + { deleted_at: { $gt: startTime } } + ] + }, { + _id: false, + follower_id: false, + followee_id: false + }, { + sort: { created_at: -1 } + }); + + const graph = []; + + for (let i = 0; i < 30; i++) { + let day = new Date(new Date().setDate(new Date().getDate() - i)); + day = new Date(day.setMilliseconds(999)); + day = new Date(day.setSeconds(59)); + day = new Date(day.setMinutes(59)); + day = new Date(day.setHours(23)); + + const count = following.filter(f => + f.created_at < day && (f.deleted_at == null || f.deleted_at > day) + ).length; + + graph.push({ + date: { + year: day.getFullYear(), + month: day.getMonth() + 1, // In JavaScript, month is zero-based. + day: day.getDate() + }, + count: count + }); + } + + res(graph); +}); diff --git a/src/server/api/endpoints/aggregation/users/post.ts b/src/server/api/endpoints/aggregation/users/post.ts new file mode 100644 index 0000000000..c6a75eee39 --- /dev/null +++ b/src/server/api/endpoints/aggregation/users/post.ts @@ -0,0 +1,110 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import User from '../../../models/user'; +import Post from '../../../models/post'; + +/** + * Aggregate post of a user + * + * @param {any} params + * @return {Promise<any>} + */ +module.exports = (params) => new Promise(async (res, rej) => { + // Get 'user_id' parameter + const [userId, userIdErr] = $(params.user_id).id().$; + if (userIdErr) return rej('invalid user_id param'); + + // Lookup user + const user = await User.findOne({ + _id: userId + }, { + fields: { + _id: true + } + }); + + if (user === null) { + return rej('user not found'); + } + + const datas = await Post + .aggregate([ + { $match: { user_id: user._id } }, + { $project: { + repost_id: '$repost_id', + reply_id: '$reply_id', + created_at: { $add: ['$created_at', 9 * 60 * 60 * 1000] } // Convert into JST + }}, + { $project: { + date: { + year: { $year: '$created_at' }, + month: { $month: '$created_at' }, + day: { $dayOfMonth: '$created_at' } + }, + type: { + $cond: { + if: { $ne: ['$repost_id', null] }, + then: 'repost', + else: { + $cond: { + if: { $ne: ['$reply_id', null] }, + then: 'reply', + else: 'post' + } + } + } + }} + }, + { $group: { _id: { + date: '$date', + type: '$type' + }, count: { $sum: 1 } } }, + { $group: { + _id: '$_id.date', + data: { $addToSet: { + type: '$_id.type', + count: '$count' + }} + } } + ]); + + datas.forEach(data => { + data.date = data._id; + delete data._id; + + data.posts = (data.data.filter(x => x.type == 'post')[0] || { count: 0 }).count; + data.reposts = (data.data.filter(x => x.type == 'repost')[0] || { count: 0 }).count; + data.replies = (data.data.filter(x => x.type == 'reply')[0] || { count: 0 }).count; + + delete data.data; + }); + + const graph = []; + + for (let i = 0; i < 30; i++) { + const day = new Date(new Date().setDate(new Date().getDate() - i)); + + const data = datas.filter(d => + d.date.year == day.getFullYear() && d.date.month == day.getMonth() + 1 && d.date.day == day.getDate() + )[0]; + + if (data) { + graph.push(data); + } else { + graph.push({ + date: { + year: day.getFullYear(), + month: day.getMonth() + 1, // In JavaScript, month is zero-based. + day: day.getDate() + }, + posts: 0, + reposts: 0, + replies: 0 + }); + } + } + + res(graph); +}); diff --git a/src/server/api/endpoints/aggregation/users/reaction.ts b/src/server/api/endpoints/aggregation/users/reaction.ts new file mode 100644 index 0000000000..0a082ed1b7 --- /dev/null +++ b/src/server/api/endpoints/aggregation/users/reaction.ts @@ -0,0 +1,80 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import User from '../../../models/user'; +import Reaction from '../../../models/post-reaction'; + +/** + * Aggregate reaction of a user + * + * @param {any} params + * @return {Promise<any>} + */ +module.exports = (params) => new Promise(async (res, rej) => { + // Get 'user_id' parameter + const [userId, userIdErr] = $(params.user_id).id().$; + if (userIdErr) return rej('invalid user_id param'); + + // Lookup user + const user = await User.findOne({ + _id: userId + }, { + fields: { + _id: true + } + }); + + if (user === null) { + return rej('user not found'); + } + + const datas = await Reaction + .aggregate([ + { $match: { user_id: user._id } }, + { $project: { + created_at: { $add: ['$created_at', 9 * 60 * 60 * 1000] } // Convert into JST + }}, + { $project: { + date: { + year: { $year: '$created_at' }, + month: { $month: '$created_at' }, + day: { $dayOfMonth: '$created_at' } + } + }}, + { $group: { + _id: '$date', + count: { $sum: 1 } + }} + ]); + + datas.forEach(data => { + data.date = data._id; + delete data._id; + }); + + const graph = []; + + for (let i = 0; i < 30; i++) { + const day = new Date(new Date().setDate(new Date().getDate() - i)); + + const data = datas.filter(d => + d.date.year == day.getFullYear() && d.date.month == day.getMonth() + 1 && d.date.day == day.getDate() + )[0]; + + if (data) { + graph.push(data); + } else { + graph.push({ + date: { + year: day.getFullYear(), + month: day.getMonth() + 1, // In JavaScript, month is zero-based. + day: day.getDate() + }, + count: 0 + }); + } + } + + res(graph); +}); diff --git a/src/server/api/endpoints/app/create.ts b/src/server/api/endpoints/app/create.ts new file mode 100644 index 0000000000..0f688792a7 --- /dev/null +++ b/src/server/api/endpoints/app/create.ts @@ -0,0 +1,108 @@ +/** + * Module dependencies + */ +import rndstr from 'rndstr'; +import $ from 'cafy'; +import App, { isValidNameId, pack } from '../../models/app'; + +/** + * @swagger + * /app/create: + * post: + * summary: Create an application + * parameters: + * - $ref: "#/parameters/AccessToken" + * - + * name: name_id + * description: Application unique name + * in: formData + * required: true + * type: string + * - + * name: name + * description: Application name + * in: formData + * required: true + * type: string + * - + * name: description + * description: Application description + * in: formData + * required: true + * type: string + * - + * name: permission + * description: Permissions that application has + * in: formData + * required: true + * type: array + * items: + * type: string + * collectionFormat: csv + * - + * name: callback_url + * description: URL called back after authentication + * in: formData + * required: false + * type: string + * + * responses: + * 200: + * description: Created application's information + * schema: + * $ref: "#/definitions/Application" + * + * default: + * description: Failed + * schema: + * $ref: "#/definitions/Error" + */ + +/** + * Create an app + * + * @param {any} params + * @param {any} user + * @return {Promise<any>} + */ +module.exports = async (params, user) => new Promise(async (res, rej) => { + // Get 'name_id' parameter + const [nameId, nameIdErr] = $(params.name_id).string().pipe(isValidNameId).$; + if (nameIdErr) return rej('invalid name_id param'); + + // Get 'name' parameter + const [name, nameErr] = $(params.name).string().$; + if (nameErr) return rej('invalid name param'); + + // Get 'description' parameter + const [description, descriptionErr] = $(params.description).string().$; + if (descriptionErr) return rej('invalid description param'); + + // Get 'permission' parameter + const [permission, permissionErr] = $(params.permission).array('string').unique().$; + if (permissionErr) return rej('invalid permission param'); + + // Get 'callback_url' parameter + // TODO: Check it is valid url + const [callbackUrl = null, callbackUrlErr] = $(params.callback_url).optional.nullable.string().$; + if (callbackUrlErr) return rej('invalid callback_url param'); + + // Generate secret + const secret = rndstr('a-zA-Z0-9', 32); + + // Create account + const app = await App.insert({ + created_at: new Date(), + user_id: user._id, + name: name, + name_id: nameId, + name_id_lower: nameId.toLowerCase(), + description: description, + permission: permission, + callback_url: callbackUrl, + secret: secret + }); + + // Response + res(await pack(app)); +}); diff --git a/src/server/api/endpoints/app/name_id/available.ts b/src/server/api/endpoints/app/name_id/available.ts new file mode 100644 index 0000000000..3d2c710322 --- /dev/null +++ b/src/server/api/endpoints/app/name_id/available.ts @@ -0,0 +1,60 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import App from '../../../models/app'; +import { isValidNameId } from '../../../models/app'; + +/** + * @swagger + * /app/name_id/available: + * post: + * summary: Check available name_id on creation an application + * parameters: + * - + * name: name_id + * description: Application unique name + * in: formData + * required: true + * type: string + * + * responses: + * 200: + * description: Success + * schema: + * type: object + * properties: + * available: + * description: Whether name_id is available + * type: boolean + * + * default: + * description: Failed + * schema: + * $ref: "#/definitions/Error" + */ + +/** + * Check available name_id of app + * + * @param {any} params + * @return {Promise<any>} + */ +module.exports = async (params) => new Promise(async (res, rej) => { + // Get 'name_id' parameter + const [nameId, nameIdErr] = $(params.name_id).string().pipe(isValidNameId).$; + if (nameIdErr) return rej('invalid name_id param'); + + // Get exist + const exist = await App + .count({ + name_id_lower: nameId.toLowerCase() + }, { + limit: 1 + }); + + // Reply + res({ + available: exist === 0 + }); +}); diff --git a/src/server/api/endpoints/app/show.ts b/src/server/api/endpoints/app/show.ts new file mode 100644 index 0000000000..8bc3dda42c --- /dev/null +++ b/src/server/api/endpoints/app/show.ts @@ -0,0 +1,72 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import App, { pack } from '../../models/app'; + +/** + * @swagger + * /app/show: + * post: + * summary: Show an application's information + * description: Require app_id or name_id + * parameters: + * - + * name: app_id + * description: Application ID + * in: formData + * type: string + * - + * name: name_id + * description: Application unique name + * in: formData + * type: string + * + * responses: + * 200: + * description: Success + * schema: + * $ref: "#/definitions/Application" + * + * default: + * description: Failed + * schema: + * $ref: "#/definitions/Error" + */ + +/** + * Show an app + * + * @param {any} params + * @param {any} user + * @param {any} _ + * @param {any} isSecure + * @return {Promise<any>} + */ +module.exports = (params, user, _, isSecure) => new Promise(async (res, rej) => { + // Get 'app_id' parameter + const [appId, appIdErr] = $(params.app_id).optional.id().$; + if (appIdErr) return rej('invalid app_id param'); + + // Get 'name_id' parameter + const [nameId, nameIdErr] = $(params.name_id).optional.string().$; + if (nameIdErr) return rej('invalid name_id param'); + + if (appId === undefined && nameId === undefined) { + return rej('app_id or name_id is required'); + } + + // Lookup app + const app = appId !== undefined + ? await App.findOne({ _id: appId }) + : await App.findOne({ name_id_lower: nameId.toLowerCase() }); + + if (app === null) { + return rej('app not found'); + } + + // Send response + res(await pack(app, user, { + includeSecret: isSecure && app.user_id.equals(user._id) + })); +}); diff --git a/src/server/api/endpoints/auth/accept.ts b/src/server/api/endpoints/auth/accept.ts new file mode 100644 index 0000000000..4ee20a6d25 --- /dev/null +++ b/src/server/api/endpoints/auth/accept.ts @@ -0,0 +1,93 @@ +/** + * Module dependencies + */ +import rndstr from 'rndstr'; +const crypto = require('crypto'); +import $ from 'cafy'; +import App from '../../models/app'; +import AuthSess from '../../models/auth-session'; +import AccessToken from '../../models/access-token'; + +/** + * @swagger + * /auth/accept: + * post: + * summary: Accept a session + * parameters: + * - $ref: "#/parameters/NativeToken" + * - + * name: token + * description: Session Token + * in: formData + * required: true + * type: string + * responses: + * 204: + * description: OK + * + * default: + * description: Failed + * schema: + * $ref: "#/definitions/Error" + */ + +/** + * Accept + * + * @param {any} params + * @param {any} user + * @return {Promise<any>} + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + // Get 'token' parameter + const [token, tokenErr] = $(params.token).string().$; + if (tokenErr) return rej('invalid token param'); + + // Fetch token + const session = await AuthSess + .findOne({ token: token }); + + if (session === null) { + return rej('session not found'); + } + + // Generate access token + const accessToken = rndstr('a-zA-Z0-9', 32); + + // Fetch exist access token + const exist = await AccessToken.findOne({ + app_id: session.app_id, + user_id: user._id, + }); + + if (exist === null) { + // Lookup app + const app = await App.findOne({ + _id: session.app_id + }); + + // Generate Hash + const sha256 = crypto.createHash('sha256'); + sha256.update(accessToken + app.secret); + const hash = sha256.digest('hex'); + + // Insert access token doc + await AccessToken.insert({ + created_at: new Date(), + app_id: session.app_id, + user_id: user._id, + token: accessToken, + hash: hash + }); + } + + // Update session + await AuthSess.update(session._id, { + $set: { + user_id: user._id + } + }); + + // Response + res(); +}); diff --git a/src/server/api/endpoints/auth/session/generate.ts b/src/server/api/endpoints/auth/session/generate.ts new file mode 100644 index 0000000000..dc6a045b6e --- /dev/null +++ b/src/server/api/endpoints/auth/session/generate.ts @@ -0,0 +1,76 @@ +/** + * Module dependencies + */ +import * as uuid from 'uuid'; +import $ from 'cafy'; +import App from '../../../models/app'; +import AuthSess from '../../../models/auth-session'; +import config from '../../../../../conf'; + +/** + * @swagger + * /auth/session/generate: + * post: + * summary: Generate a session + * parameters: + * - + * name: app_secret + * description: App Secret + * in: formData + * required: true + * type: string + * + * responses: + * 200: + * description: OK + * schema: + * type: object + * properties: + * token: + * type: string + * description: Session Token + * url: + * type: string + * description: Authentication form's URL + * default: + * description: Failed + * schema: + * $ref: "#/definitions/Error" + */ + +/** + * Generate a session + * + * @param {any} params + * @return {Promise<any>} + */ +module.exports = (params) => new Promise(async (res, rej) => { + // Get 'app_secret' parameter + const [appSecret, appSecretErr] = $(params.app_secret).string().$; + if (appSecretErr) return rej('invalid app_secret param'); + + // Lookup app + const app = await App.findOne({ + secret: appSecret + }); + + if (app == null) { + return rej('app not found'); + } + + // Generate token + const token = uuid.v4(); + + // Create session token document + const doc = await AuthSess.insert({ + created_at: new Date(), + app_id: app._id, + token: token + }); + + // Response + res({ + token: doc.token, + url: `${config.auth_url}/${doc.token}` + }); +}); diff --git a/src/server/api/endpoints/auth/session/show.ts b/src/server/api/endpoints/auth/session/show.ts new file mode 100644 index 0000000000..73ac3185f6 --- /dev/null +++ b/src/server/api/endpoints/auth/session/show.ts @@ -0,0 +1,70 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import AuthSess, { pack } from '../../../models/auth-session'; + +/** + * @swagger + * /auth/session/show: + * post: + * summary: Show a session information + * parameters: + * - + * name: token + * description: Session Token + * in: formData + * required: true + * type: string + * + * responses: + * 200: + * description: OK + * schema: + * type: object + * properties: + * created_at: + * type: string + * format: date-time + * description: Date and time of the session creation + * app_id: + * type: string + * description: Application ID + * token: + * type: string + * description: Session Token + * user_id: + * type: string + * description: ID of user who create the session + * app: + * $ref: "#/definitions/Application" + * default: + * description: Failed + * schema: + * $ref: "#/definitions/Error" + */ + +/** + * Show a session + * + * @param {any} params + * @param {any} user + * @return {Promise<any>} + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + // Get 'token' parameter + const [token, tokenErr] = $(params.token).string().$; + if (tokenErr) return rej('invalid token param'); + + // Lookup session + const session = await AuthSess.findOne({ + token: token + }); + + if (session == null) { + return rej('session not found'); + } + + // Response + res(await pack(session, user)); +}); diff --git a/src/server/api/endpoints/auth/session/userkey.ts b/src/server/api/endpoints/auth/session/userkey.ts new file mode 100644 index 0000000000..fc989bf8c2 --- /dev/null +++ b/src/server/api/endpoints/auth/session/userkey.ts @@ -0,0 +1,109 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import App from '../../../models/app'; +import AuthSess from '../../../models/auth-session'; +import AccessToken from '../../../models/access-token'; +import { pack } from '../../../models/user'; + +/** + * @swagger + * /auth/session/userkey: + * post: + * summary: Get an access token(userkey) + * parameters: + * - + * name: app_secret + * description: App Secret + * in: formData + * required: true + * type: string + * - + * name: token + * description: Session Token + * in: formData + * required: true + * type: string + * + * responses: + * 200: + * description: OK + * schema: + * type: object + * properties: + * userkey: + * type: string + * description: Access Token + * user: + * $ref: "#/definitions/User" + * default: + * description: Failed + * schema: + * $ref: "#/definitions/Error" + */ + +/** + * Generate a session + * + * @param {any} params + * @return {Promise<any>} + */ +module.exports = (params) => new Promise(async (res, rej) => { + // Get 'app_secret' parameter + const [appSecret, appSecretErr] = $(params.app_secret).string().$; + if (appSecretErr) return rej('invalid app_secret param'); + + // Lookup app + const app = await App.findOne({ + secret: appSecret + }); + + if (app == null) { + return rej('app not found'); + } + + // Get 'token' parameter + const [token, tokenErr] = $(params.token).string().$; + if (tokenErr) return rej('invalid token param'); + + // Fetch token + const session = await AuthSess + .findOne({ + token: token, + app_id: app._id + }); + + if (session === null) { + return rej('session not found'); + } + + if (session.user_id == null) { + return rej('this session is not allowed yet'); + } + + // Lookup access token + const accessToken = await AccessToken.findOne({ + app_id: app._id, + user_id: session.user_id + }); + + // Delete session + + /* https://github.com/Automattic/monk/issues/178 + AuthSess.deleteOne({ + _id: session._id + }); + */ + AuthSess.remove({ + _id: session._id + }); + + // Response + res({ + access_token: accessToken.token, + user: await pack(session.user_id, null, { + detail: true + }) + }); +}); diff --git a/src/server/api/endpoints/channels.ts b/src/server/api/endpoints/channels.ts new file mode 100644 index 0000000000..b9a7d1b788 --- /dev/null +++ b/src/server/api/endpoints/channels.ts @@ -0,0 +1,58 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import Channel, { pack } from '../models/channel'; + +/** + * Get all channels + * + * @param {any} params + * @param {any} me + * @return {Promise<any>} + */ +module.exports = (params, me) => new Promise(async (res, rej) => { + // Get 'limit' parameter + const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$; + if (limitErr) return rej('invalid limit param'); + + // Get 'since_id' parameter + const [sinceId, sinceIdErr] = $(params.since_id).optional.id().$; + if (sinceIdErr) return rej('invalid since_id param'); + + // Get 'until_id' parameter + const [untilId, untilIdErr] = $(params.until_id).optional.id().$; + if (untilIdErr) return rej('invalid until_id param'); + + // Check if both of since_id and until_id is specified + if (sinceId && untilId) { + return rej('cannot set since_id and until_id'); + } + + // Construct query + const sort = { + _id: -1 + }; + const query = {} as any; + if (sinceId) { + sort._id = 1; + query._id = { + $gt: sinceId + }; + } else if (untilId) { + query._id = { + $lt: untilId + }; + } + + // Issue query + const channels = await Channel + .find(query, { + limit: limit, + sort: sort + }); + + // Serialize + res(await Promise.all(channels.map(async channel => + await pack(channel, me)))); +}); diff --git a/src/server/api/endpoints/channels/create.ts b/src/server/api/endpoints/channels/create.ts new file mode 100644 index 0000000000..695b4515b3 --- /dev/null +++ b/src/server/api/endpoints/channels/create.ts @@ -0,0 +1,39 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import Channel from '../../models/channel'; +import Watching from '../../models/channel-watching'; +import { pack } from '../../models/channel'; + +/** + * Create a channel + * + * @param {any} params + * @param {any} user + * @return {Promise<any>} + */ +module.exports = async (params, user) => new Promise(async (res, rej) => { + // Get 'title' parameter + const [title, titleErr] = $(params.title).string().range(1, 100).$; + if (titleErr) return rej('invalid title param'); + + // Create a channel + const channel = await Channel.insert({ + created_at: new Date(), + user_id: user._id, + title: title, + index: 0, + watching_count: 1 + }); + + // Response + res(await pack(channel)); + + // Create Watching + await Watching.insert({ + created_at: new Date(), + user_id: user._id, + channel_id: channel._id + }); +}); diff --git a/src/server/api/endpoints/channels/posts.ts b/src/server/api/endpoints/channels/posts.ts new file mode 100644 index 0000000000..d722589c20 --- /dev/null +++ b/src/server/api/endpoints/channels/posts.ts @@ -0,0 +1,78 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import { default as Channel, IChannel } from '../../models/channel'; +import Post, { pack } from '../../models/post'; + +/** + * Show a posts of a channel + * + * @param {any} params + * @param {any} user + * @return {Promise<any>} + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + // Get 'limit' parameter + const [limit = 1000, limitErr] = $(params.limit).optional.number().range(1, 1000).$; + if (limitErr) return rej('invalid limit param'); + + // Get 'since_id' parameter + const [sinceId, sinceIdErr] = $(params.since_id).optional.id().$; + if (sinceIdErr) return rej('invalid since_id param'); + + // Get 'until_id' parameter + const [untilId, untilIdErr] = $(params.until_id).optional.id().$; + if (untilIdErr) return rej('invalid until_id param'); + + // Check if both of since_id and until_id is specified + if (sinceId && untilId) { + return rej('cannot set since_id and until_id'); + } + + // Get 'channel_id' parameter + const [channelId, channelIdErr] = $(params.channel_id).id().$; + if (channelIdErr) return rej('invalid channel_id param'); + + // Fetch channel + const channel: IChannel = await Channel.findOne({ + _id: channelId + }); + + if (channel === null) { + return rej('channel not found'); + } + + //#region Construct query + const sort = { + _id: -1 + }; + + const query = { + channel_id: channel._id + } as any; + + if (sinceId) { + sort._id = 1; + query._id = { + $gt: sinceId + }; + } else if (untilId) { + query._id = { + $lt: untilId + }; + } + //#endregion Construct query + + // Issue query + const posts = await Post + .find(query, { + limit: limit, + sort: sort + }); + + // Serialize + res(await Promise.all(posts.map(async (post) => + await pack(post, user) + ))); +}); diff --git a/src/server/api/endpoints/channels/show.ts b/src/server/api/endpoints/channels/show.ts new file mode 100644 index 0000000000..332da64675 --- /dev/null +++ b/src/server/api/endpoints/channels/show.ts @@ -0,0 +1,30 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import Channel, { IChannel, pack } from '../../models/channel'; + +/** + * Show a channel + * + * @param {any} params + * @param {any} user + * @return {Promise<any>} + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + // Get 'channel_id' parameter + const [channelId, channelIdErr] = $(params.channel_id).id().$; + if (channelIdErr) return rej('invalid channel_id param'); + + // Fetch channel + const channel: IChannel = await Channel.findOne({ + _id: channelId + }); + + if (channel === null) { + return rej('channel not found'); + } + + // Serialize + res(await pack(channel, user)); +}); diff --git a/src/server/api/endpoints/channels/unwatch.ts b/src/server/api/endpoints/channels/unwatch.ts new file mode 100644 index 0000000000..19d3be118a --- /dev/null +++ b/src/server/api/endpoints/channels/unwatch.ts @@ -0,0 +1,60 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import Channel from '../../models/channel'; +import Watching from '../../models/channel-watching'; + +/** + * Unwatch a channel + * + * @param {any} params + * @param {any} user + * @return {Promise<any>} + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + // Get 'channel_id' parameter + const [channelId, channelIdErr] = $(params.channel_id).id().$; + if (channelIdErr) return rej('invalid channel_id param'); + + //#region Fetch channel + const channel = await Channel.findOne({ + _id: channelId + }); + + if (channel === null) { + return rej('channel not found'); + } + //#endregion + + //#region Check whether not watching + const exist = await Watching.findOne({ + user_id: user._id, + channel_id: channel._id, + deleted_at: { $exists: false } + }); + + if (exist === null) { + return rej('already not watching'); + } + //#endregion + + // Delete watching + await Watching.update({ + _id: exist._id + }, { + $set: { + deleted_at: new Date() + } + }); + + // Send response + res(); + + // Decrement watching count + Channel.update(channel._id, { + $inc: { + watching_count: -1 + } + }); +}); diff --git a/src/server/api/endpoints/channels/watch.ts b/src/server/api/endpoints/channels/watch.ts new file mode 100644 index 0000000000..030e0dd411 --- /dev/null +++ b/src/server/api/endpoints/channels/watch.ts @@ -0,0 +1,58 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import Channel from '../../models/channel'; +import Watching from '../../models/channel-watching'; + +/** + * Watch a channel + * + * @param {any} params + * @param {any} user + * @return {Promise<any>} + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + // Get 'channel_id' parameter + const [channelId, channelIdErr] = $(params.channel_id).id().$; + if (channelIdErr) return rej('invalid channel_id param'); + + //#region Fetch channel + const channel = await Channel.findOne({ + _id: channelId + }); + + if (channel === null) { + return rej('channel not found'); + } + //#endregion + + //#region Check whether already watching + const exist = await Watching.findOne({ + user_id: user._id, + channel_id: channel._id, + deleted_at: { $exists: false } + }); + + if (exist !== null) { + return rej('already watching'); + } + //#endregion + + // Create Watching + await Watching.insert({ + created_at: new Date(), + user_id: user._id, + channel_id: channel._id + }); + + // Send response + res(); + + // Increment watching count + Channel.update(channel._id, { + $inc: { + watching_count: 1 + } + }); +}); diff --git a/src/server/api/endpoints/drive.ts b/src/server/api/endpoints/drive.ts new file mode 100644 index 0000000000..d92473633a --- /dev/null +++ b/src/server/api/endpoints/drive.ts @@ -0,0 +1,37 @@ +/** + * Module dependencies + */ +import DriveFile from '../models/drive-file'; + +/** + * Get drive information + * + * @param {any} params + * @param {any} user + * @return {Promise<any>} + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + // Calculate drive usage + const usage = ((await DriveFile + .aggregate([ + { $match: { 'metadata.user_id': user._id } }, + { + $project: { + length: true + } + }, + { + $group: { + _id: null, + usage: { $sum: '$length' } + } + } + ]))[0] || { + usage: 0 + }).usage; + + res({ + capacity: user.drive_capacity, + usage: usage + }); +}); diff --git a/src/server/api/endpoints/drive/files.ts b/src/server/api/endpoints/drive/files.ts new file mode 100644 index 0000000000..89915331ea --- /dev/null +++ b/src/server/api/endpoints/drive/files.ts @@ -0,0 +1,73 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import DriveFile, { pack } from '../../models/drive-file'; + +/** + * Get drive files + * + * @param {any} params + * @param {any} user + * @param {any} app + * @return {Promise<any>} + */ +module.exports = async (params, user, app) => { + // Get 'limit' parameter + const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$; + if (limitErr) throw 'invalid limit param'; + + // Get 'since_id' parameter + const [sinceId, sinceIdErr] = $(params.since_id).optional.id().$; + if (sinceIdErr) throw 'invalid since_id param'; + + // Get 'until_id' parameter + const [untilId, untilIdErr] = $(params.until_id).optional.id().$; + if (untilIdErr) throw 'invalid until_id param'; + + // Check if both of since_id and until_id is specified + if (sinceId && untilId) { + throw 'cannot set since_id and until_id'; + } + + // Get 'folder_id' parameter + const [folderId = null, folderIdErr] = $(params.folder_id).optional.nullable.id().$; + 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 = { + 'metadata.user_id': user._id, + 'metadata.folder_id': folderId + } as any; + if (sinceId) { + sort._id = 1; + query._id = { + $gt: sinceId + }; + } else if (untilId) { + query._id = { + $lt: untilId + }; + } + if (type) { + query.contentType = new RegExp(`^${type.replace(/\*/g, '.+?')}$`); + } + + // Issue query + const files = await DriveFile + .find(query, { + limit: limit, + sort: sort + }); + + // Serialize + const _files = await Promise.all(files.map(file => pack(file))); + return _files; +}; diff --git a/src/server/api/endpoints/drive/files/create.ts b/src/server/api/endpoints/drive/files/create.ts new file mode 100644 index 0000000000..db801b61fe --- /dev/null +++ b/src/server/api/endpoints/drive/files/create.ts @@ -0,0 +1,51 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import { validateFileName, pack } from '../../../models/drive-file'; +import create from '../../../common/drive/add-file'; + +/** + * Create a file + * + * @param {any} file + * @param {any} params + * @param {any} user + * @return {Promise<any>} + */ +module.exports = async (file, params, user): Promise<any> => { + if (file == null) { + throw 'file is required'; + } + + // Get 'name' parameter + let name = file.originalname; + if (name !== undefined && name !== null) { + name = name.trim(); + if (name.length === 0) { + name = null; + } else if (name === 'blob') { + name = null; + } else if (!validateFileName(name)) { + throw 'invalid name'; + } + } else { + name = null; + } + + // Get 'folder_id' parameter + const [folderId = null, folderIdErr] = $(params.folder_id).optional.nullable.id().$; + if (folderIdErr) throw 'invalid folder_id param'; + + try { + // Create file + const driveFile = await create(user, file.path, name, null, folderId); + + // Serialize + return pack(driveFile); + } catch (e) { + console.error(e); + + throw e; + } +}; diff --git a/src/server/api/endpoints/drive/files/find.ts b/src/server/api/endpoints/drive/files/find.ts new file mode 100644 index 0000000000..e026afe936 --- /dev/null +++ b/src/server/api/endpoints/drive/files/find.ts @@ -0,0 +1,34 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import DriveFile, { pack } from '../../../models/drive-file'; + +/** + * Find a file(s) + * + * @param {any} params + * @param {any} user + * @return {Promise<any>} + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + // Get 'name' parameter + const [name, nameErr] = $(params.name).string().$; + if (nameErr) return rej('invalid name param'); + + // Get 'folder_id' parameter + const [folderId = null, folderIdErr] = $(params.folder_id).optional.nullable.id().$; + if (folderIdErr) return rej('invalid folder_id param'); + + // Issue query + const files = await DriveFile + .find({ + filename: name, + 'metadata.user_id': user._id, + 'metadata.folder_id': folderId + }); + + // Serialize + res(await Promise.all(files.map(async file => + await pack(file)))); +}); diff --git a/src/server/api/endpoints/drive/files/show.ts b/src/server/api/endpoints/drive/files/show.ts new file mode 100644 index 0000000000..21664f7ba4 --- /dev/null +++ b/src/server/api/endpoints/drive/files/show.ts @@ -0,0 +1,36 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import DriveFile, { pack } from '../../../models/drive-file'; + +/** + * Show a file + * + * @param {any} params + * @param {any} user + * @return {Promise<any>} + */ +module.exports = async (params, user) => { + // Get 'file_id' parameter + const [fileId, fileIdErr] = $(params.file_id).id().$; + if (fileIdErr) throw 'invalid file_id param'; + + // Fetch file + const file = await DriveFile + .findOne({ + _id: fileId, + 'metadata.user_id': user._id + }); + + if (file === null) { + throw 'file-not-found'; + } + + // Serialize + const _file = await pack(file, { + detail: true + }); + + return _file; +}; diff --git a/src/server/api/endpoints/drive/files/update.ts b/src/server/api/endpoints/drive/files/update.ts new file mode 100644 index 0000000000..83da462113 --- /dev/null +++ b/src/server/api/endpoints/drive/files/update.ts @@ -0,0 +1,75 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import DriveFolder from '../../../models/drive-folder'; +import DriveFile, { validateFileName, pack } from '../../../models/drive-file'; +import { publishDriveStream } from '../../../event'; + +/** + * Update a file + * + * @param {any} params + * @param {any} user + * @return {Promise<any>} + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + // Get 'file_id' parameter + const [fileId, fileIdErr] = $(params.file_id).id().$; + if (fileIdErr) return rej('invalid file_id param'); + + // Fetch file + const file = await DriveFile + .findOne({ + _id: fileId, + 'metadata.user_id': user._id + }); + + if (file === null) { + return rej('file-not-found'); + } + + // Get 'name' parameter + const [name, nameErr] = $(params.name).optional.string().pipe(validateFileName).$; + if (nameErr) return rej('invalid name param'); + if (name) file.filename = name; + + // Get 'folder_id' parameter + const [folderId, folderIdErr] = $(params.folder_id).optional.nullable.id().$; + if (folderIdErr) return rej('invalid folder_id param'); + + if (folderId !== undefined) { + if (folderId === null) { + file.metadata.folder_id = null; + } else { + // Fetch folder + const folder = await DriveFolder + .findOne({ + _id: folderId, + user_id: user._id + }); + + if (folder === null) { + return rej('folder-not-found'); + } + + file.metadata.folder_id = folder._id; + } + } + + await DriveFile.update(file._id, { + $set: { + filename: file.filename, + 'metadata.folder_id': file.metadata.folder_id + } + }); + + // Serialize + const fileObj = await pack(file); + + // Response + res(fileObj); + + // Publish file_updated event + publishDriveStream(user._id, 'file_updated', fileObj); +}); diff --git a/src/server/api/endpoints/drive/files/upload_from_url.ts b/src/server/api/endpoints/drive/files/upload_from_url.ts new file mode 100644 index 0000000000..346633c616 --- /dev/null +++ b/src/server/api/endpoints/drive/files/upload_from_url.ts @@ -0,0 +1,26 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import { pack } from '../../../models/drive-file'; +import uploadFromUrl from '../../../common/drive/upload_from_url'; + +/** + * Create a file from a URL + * + * @param {any} params + * @param {any} user + * @return {Promise<any>} + */ +module.exports = async (params, user): Promise<any> => { + // Get 'url' parameter + // TODO: Validate this url + const [url, urlErr] = $(params.url).string().$; + if (urlErr) throw 'invalid url param'; + + // Get 'folder_id' parameter + const [folderId = null, folderIdErr] = $(params.folder_id).optional.nullable.id().$; + if (folderIdErr) throw 'invalid folder_id param'; + + return pack(await uploadFromUrl(url, user, folderId)); +}; diff --git a/src/server/api/endpoints/drive/folders.ts b/src/server/api/endpoints/drive/folders.ts new file mode 100644 index 0000000000..428bde3507 --- /dev/null +++ b/src/server/api/endpoints/drive/folders.ts @@ -0,0 +1,66 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import DriveFolder, { pack } from '../../models/drive-folder'; + +/** + * Get drive folders + * + * @param {any} params + * @param {any} user + * @param {any} app + * @return {Promise<any>} + */ +module.exports = (params, user, app) => new Promise(async (res, rej) => { + // Get 'limit' parameter + const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$; + if (limitErr) return rej('invalid limit param'); + + // Get 'since_id' parameter + const [sinceId, sinceIdErr] = $(params.since_id).optional.id().$; + if (sinceIdErr) return rej('invalid since_id param'); + + // Get 'until_id' parameter + const [untilId, untilIdErr] = $(params.until_id).optional.id().$; + if (untilIdErr) return rej('invalid until_id param'); + + // Check if both of since_id and until_id is specified + if (sinceId && untilId) { + return rej('cannot set since_id and until_id'); + } + + // Get 'folder_id' parameter + const [folderId = null, folderIdErr] = $(params.folder_id).optional.nullable.id().$; + if (folderIdErr) return rej('invalid folder_id param'); + + // Construct query + const sort = { + _id: -1 + }; + const query = { + user_id: user._id, + parent_id: folderId + } as any; + if (sinceId) { + sort._id = 1; + query._id = { + $gt: sinceId + }; + } else if (untilId) { + query._id = { + $lt: untilId + }; + } + + // Issue query + const folders = await DriveFolder + .find(query, { + limit: limit, + sort: sort + }); + + // Serialize + res(await Promise.all(folders.map(async folder => + await pack(folder)))); +}); diff --git a/src/server/api/endpoints/drive/folders/create.ts b/src/server/api/endpoints/drive/folders/create.ts new file mode 100644 index 0000000000..03f396ddc9 --- /dev/null +++ b/src/server/api/endpoints/drive/folders/create.ts @@ -0,0 +1,55 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import DriveFolder, { isValidFolderName, pack } from '../../../models/drive-folder'; +import { publishDriveStream } from '../../../event'; + +/** + * Create drive folder + * + * @param {any} params + * @param {any} user + * @return {Promise<any>} + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + // Get 'name' parameter + const [name = '無題のフォルダー', nameErr] = $(params.name).optional.string().pipe(isValidFolderName).$; + if (nameErr) return rej('invalid name param'); + + // Get 'parent_id' parameter + const [parentId = null, parentIdErr] = $(params.parent_id).optional.nullable.id().$; + if (parentIdErr) return rej('invalid parent_id param'); + + // If the parent folder is specified + let parent = null; + if (parentId) { + // Fetch parent folder + parent = await DriveFolder + .findOne({ + _id: parentId, + user_id: user._id + }); + + if (parent === null) { + return rej('parent-not-found'); + } + } + + // Create folder + const folder = await DriveFolder.insert({ + created_at: new Date(), + name: name, + parent_id: parent !== null ? parent._id : null, + user_id: user._id + }); + + // Serialize + const folderObj = await pack(folder); + + // Response + res(folderObj); + + // Publish folder_created event + publishDriveStream(user._id, 'folder_created', folderObj); +}); diff --git a/src/server/api/endpoints/drive/folders/find.ts b/src/server/api/endpoints/drive/folders/find.ts new file mode 100644 index 0000000000..fc84766bc8 --- /dev/null +++ b/src/server/api/endpoints/drive/folders/find.ts @@ -0,0 +1,33 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import DriveFolder, { pack } from '../../../models/drive-folder'; + +/** + * Find a folder(s) + * + * @param {any} params + * @param {any} user + * @return {Promise<any>} + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + // Get 'name' parameter + const [name, nameErr] = $(params.name).string().$; + if (nameErr) return rej('invalid name param'); + + // Get 'parent_id' parameter + const [parentId = null, parentIdErr] = $(params.parent_id).optional.nullable.id().$; + if (parentIdErr) return rej('invalid parent_id param'); + + // Issue query + const folders = await DriveFolder + .find({ + name: name, + user_id: user._id, + parent_id: parentId + }); + + // Serialize + res(await Promise.all(folders.map(folder => pack(folder)))); +}); diff --git a/src/server/api/endpoints/drive/folders/show.ts b/src/server/api/endpoints/drive/folders/show.ts new file mode 100644 index 0000000000..e07d14d20d --- /dev/null +++ b/src/server/api/endpoints/drive/folders/show.ts @@ -0,0 +1,34 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import DriveFolder, { pack } from '../../../models/drive-folder'; + +/** + * Show a folder + * + * @param {any} params + * @param {any} user + * @return {Promise<any>} + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + // Get 'folder_id' parameter + const [folderId, folderIdErr] = $(params.folder_id).id().$; + if (folderIdErr) return rej('invalid folder_id param'); + + // Get folder + const folder = await DriveFolder + .findOne({ + _id: folderId, + user_id: user._id + }); + + if (folder === null) { + return rej('folder-not-found'); + } + + // Serialize + res(await pack(folder, { + detail: true + })); +}); diff --git a/src/server/api/endpoints/drive/folders/update.ts b/src/server/api/endpoints/drive/folders/update.ts new file mode 100644 index 0000000000..d3df8bdae5 --- /dev/null +++ b/src/server/api/endpoints/drive/folders/update.ts @@ -0,0 +1,99 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import DriveFolder, { isValidFolderName, pack } from '../../../models/drive-folder'; +import { publishDriveStream } from '../../../event'; + +/** + * Update a folder + * + * @param {any} params + * @param {any} user + * @return {Promise<any>} + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + // Get 'folder_id' parameter + const [folderId, folderIdErr] = $(params.folder_id).id().$; + if (folderIdErr) return rej('invalid folder_id param'); + + // Fetch folder + const folder = await DriveFolder + .findOne({ + _id: folderId, + user_id: user._id + }); + + if (folder === null) { + return rej('folder-not-found'); + } + + // Get 'name' parameter + const [name, nameErr] = $(params.name).optional.string().pipe(isValidFolderName).$; + if (nameErr) return rej('invalid name param'); + if (name) folder.name = name; + + // Get 'parent_id' parameter + const [parentId, parentIdErr] = $(params.parent_id).optional.nullable.id().$; + if (parentIdErr) return rej('invalid parent_id param'); + if (parentId !== undefined) { + if (parentId === null) { + folder.parent_id = null; + } else { + // Get parent folder + const parent = await DriveFolder + .findOne({ + _id: parentId, + user_id: user._id + }); + + if (parent === null) { + return rej('parent-folder-not-found'); + } + + // Check if the circular reference will occur + async function checkCircle(folderId) { + // Fetch folder + const folder2 = await DriveFolder.findOne({ + _id: folderId + }, { + _id: true, + parent_id: true + }); + + if (folder2._id.equals(folder._id)) { + return true; + } else if (folder2.parent_id) { + return await checkCircle(folder2.parent_id); + } else { + return false; + } + } + + if (parent.parent_id !== null) { + if (await checkCircle(parent.parent_id)) { + return rej('detected-circular-definition'); + } + } + + folder.parent_id = parent._id; + } + } + + // Update + DriveFolder.update(folder._id, { + $set: { + name: folder.name, + parent_id: folder.parent_id + } + }); + + // Serialize + const folderObj = await pack(folder); + + // Response + res(folderObj); + + // Publish folder_updated event + publishDriveStream(user._id, 'folder_updated', folderObj); +}); diff --git a/src/server/api/endpoints/drive/stream.ts b/src/server/api/endpoints/drive/stream.ts new file mode 100644 index 0000000000..8352c7dd4c --- /dev/null +++ b/src/server/api/endpoints/drive/stream.ts @@ -0,0 +1,67 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import DriveFile, { pack } from '../../models/drive-file'; + +/** + * Get drive stream + * + * @param {any} params + * @param {any} user + * @return {Promise<any>} + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + // Get 'limit' parameter + const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$; + if (limitErr) return rej('invalid limit param'); + + // Get 'since_id' parameter + const [sinceId, sinceIdErr] = $(params.since_id).optional.id().$; + if (sinceIdErr) return rej('invalid since_id param'); + + // Get 'until_id' parameter + const [untilId, untilIdErr] = $(params.until_id).optional.id().$; + if (untilIdErr) return rej('invalid until_id param'); + + // Check if both of since_id and until_id is specified + if (sinceId && untilId) { + return rej('cannot set since_id and until_id'); + } + + // Get 'type' parameter + const [type, typeErr] = $(params.type).optional.string().match(/^[a-zA-Z\/\-\*]+$/).$; + if (typeErr) return rej('invalid type param'); + + // Construct query + const sort = { + _id: -1 + }; + const query = { + 'metadata.user_id': user._id + } as any; + if (sinceId) { + sort._id = 1; + query._id = { + $gt: sinceId + }; + } else if (untilId) { + query._id = { + $lt: untilId + }; + } + if (type) { + query.contentType = new RegExp(`^${type.replace(/\*/g, '.+?')}$`); + } + + // Issue query + const files = await DriveFile + .find(query, { + limit: limit, + sort: sort + }); + + // Serialize + res(await Promise.all(files.map(async file => + await pack(file)))); +}); diff --git a/src/server/api/endpoints/following/create.ts b/src/server/api/endpoints/following/create.ts new file mode 100644 index 0000000000..767b837b35 --- /dev/null +++ b/src/server/api/endpoints/following/create.ts @@ -0,0 +1,84 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import User, { pack as packUser } from '../../models/user'; +import Following from '../../models/following'; +import notify from '../../common/notify'; +import event from '../../event'; + +/** + * Follow a user + * + * @param {any} params + * @param {any} user + * @return {Promise<any>} + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + const follower = user; + + // Get 'user_id' parameter + const [userId, userIdErr] = $(params.user_id).id().$; + if (userIdErr) return rej('invalid user_id param'); + + // 自分自身 + if (user._id.equals(userId)) { + return rej('followee is yourself'); + } + + // Get followee + const followee = await User.findOne({ + _id: userId + }, { + fields: { + data: false, + 'account.profile': false + } + }); + + if (followee === null) { + return rej('user not found'); + } + + // Check if already following + const exist = await Following.findOne({ + follower_id: follower._id, + followee_id: followee._id, + deleted_at: { $exists: false } + }); + + if (exist !== null) { + return rej('already following'); + } + + // Create following + await Following.insert({ + created_at: new Date(), + follower_id: follower._id, + followee_id: followee._id + }); + + // Send response + res(); + + // Increment following count + User.update(follower._id, { + $inc: { + following_count: 1 + } + }); + + // Increment followers count + User.update({ _id: followee._id }, { + $inc: { + followers_count: 1 + } + }); + + // Publish follow event + event(follower._id, 'follow', await packUser(followee, follower)); + event(followee._id, 'followed', await packUser(follower, followee)); + + // Notify + notify(followee._id, follower._id, 'follow'); +}); diff --git a/src/server/api/endpoints/following/delete.ts b/src/server/api/endpoints/following/delete.ts new file mode 100644 index 0000000000..64b9a8cecb --- /dev/null +++ b/src/server/api/endpoints/following/delete.ts @@ -0,0 +1,81 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import User, { pack as packUser } from '../../models/user'; +import Following from '../../models/following'; +import event from '../../event'; + +/** + * Unfollow a user + * + * @param {any} params + * @param {any} user + * @return {Promise<any>} + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + const follower = user; + + // Get 'user_id' parameter + const [userId, userIdErr] = $(params.user_id).id().$; + if (userIdErr) return rej('invalid user_id param'); + + // Check if the followee is yourself + if (user._id.equals(userId)) { + return rej('followee is yourself'); + } + + // Get followee + const followee = await User.findOne({ + _id: userId + }, { + fields: { + data: false, + 'account.profile': false + } + }); + + if (followee === null) { + return rej('user not found'); + } + + // Check not following + const exist = await Following.findOne({ + follower_id: follower._id, + followee_id: followee._id, + deleted_at: { $exists: false } + }); + + if (exist === null) { + return rej('already not following'); + } + + // Delete following + await Following.update({ + _id: exist._id + }, { + $set: { + deleted_at: new Date() + } + }); + + // Send response + res(); + + // Decrement following count + User.update({ _id: follower._id }, { + $inc: { + following_count: -1 + } + }); + + // Decrement followers count + User.update({ _id: followee._id }, { + $inc: { + followers_count: -1 + } + }); + + // Publish follow event + event(follower._id, 'unfollow', await packUser(followee, follower)); +}); diff --git a/src/server/api/endpoints/i.ts b/src/server/api/endpoints/i.ts new file mode 100644 index 0000000000..32b0382faf --- /dev/null +++ b/src/server/api/endpoints/i.ts @@ -0,0 +1,28 @@ +/** + * Module dependencies + */ +import User, { pack } from '../models/user'; + +/** + * Show myself + * + * @param {any} params + * @param {any} user + * @param {any} app + * @param {Boolean} isSecure + * @return {Promise<any>} + */ +module.exports = (params, user, _, isSecure) => new Promise(async (res, rej) => { + // Serialize + res(await pack(user, user, { + detail: true, + includeSecrets: isSecure + })); + + // Update lastUsedAt + User.update({ _id: user._id }, { + $set: { + 'account.last_used_at': new Date() + } + }); +}); diff --git a/src/server/api/endpoints/i/2fa/done.ts b/src/server/api/endpoints/i/2fa/done.ts new file mode 100644 index 0000000000..0f1db73829 --- /dev/null +++ b/src/server/api/endpoints/i/2fa/done.ts @@ -0,0 +1,37 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import * as speakeasy from 'speakeasy'; +import User from '../../../models/user'; + +module.exports = async (params, user) => new Promise(async (res, rej) => { + // Get 'token' parameter + const [token, tokenErr] = $(params.token).string().$; + if (tokenErr) return rej('invalid token param'); + + const _token = token.replace(/\s/g, ''); + + if (user.two_factor_temp_secret == null) { + return rej('二段階認証の設定が開始されていません'); + } + + const verified = (speakeasy as any).totp.verify({ + secret: user.two_factor_temp_secret, + encoding: 'base32', + token: _token + }); + + if (!verified) { + return rej('not verified'); + } + + await User.update(user._id, { + $set: { + 'account.two_factor_secret': user.two_factor_temp_secret, + 'account.two_factor_enabled': true + } + }); + + res(); +}); diff --git a/src/server/api/endpoints/i/2fa/register.ts b/src/server/api/endpoints/i/2fa/register.ts new file mode 100644 index 0000000000..e2cc1487b8 --- /dev/null +++ b/src/server/api/endpoints/i/2fa/register.ts @@ -0,0 +1,48 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import * as bcrypt from 'bcryptjs'; +import * as speakeasy from 'speakeasy'; +import * as QRCode from 'qrcode'; +import User from '../../../models/user'; +import config from '../../../../../conf'; + +module.exports = async (params, user) => new Promise(async (res, rej) => { + // Get 'password' parameter + const [password, passwordErr] = $(params.password).string().$; + if (passwordErr) return rej('invalid password param'); + + // Compare password + const same = await bcrypt.compare(password, user.account.password); + + if (!same) { + return rej('incorrect password'); + } + + // Generate user's secret key + const secret = speakeasy.generateSecret({ + length: 32 + }); + + await User.update(user._id, { + $set: { + two_factor_temp_secret: secret.base32 + } + }); + + // Get the data URL of the authenticator URL + QRCode.toDataURL(speakeasy.otpauthURL({ + secret: secret.base32, + encoding: 'base32', + label: user.username, + issuer: config.host + }), (err, data_url) => { + res({ + qr: data_url, + secret: secret.base32, + label: user.username, + issuer: config.host + }); + }); +}); diff --git a/src/server/api/endpoints/i/2fa/unregister.ts b/src/server/api/endpoints/i/2fa/unregister.ts new file mode 100644 index 0000000000..c43f9ccc44 --- /dev/null +++ b/src/server/api/endpoints/i/2fa/unregister.ts @@ -0,0 +1,28 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import * as bcrypt from 'bcryptjs'; +import User from '../../../models/user'; + +module.exports = async (params, user) => new Promise(async (res, rej) => { + // Get 'password' parameter + const [password, passwordErr] = $(params.password).string().$; + if (passwordErr) return rej('invalid password param'); + + // Compare password + const same = await bcrypt.compare(password, user.account.password); + + if (!same) { + return rej('incorrect password'); + } + + await User.update(user._id, { + $set: { + 'account.two_factor_secret': null, + 'account.two_factor_enabled': false + } + }); + + res(); +}); diff --git a/src/server/api/endpoints/i/appdata/get.ts b/src/server/api/endpoints/i/appdata/get.ts new file mode 100644 index 0000000000..571208d46c --- /dev/null +++ b/src/server/api/endpoints/i/appdata/get.ts @@ -0,0 +1,39 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import Appdata from '../../../models/appdata'; + +/** + * Get app data + * + * @param {any} params + * @param {any} user + * @param {any} app + * @param {Boolean} isSecure + * @return {Promise<any>} + */ +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'); + + 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(); + } +}); diff --git a/src/server/api/endpoints/i/appdata/set.ts b/src/server/api/endpoints/i/appdata/set.ts new file mode 100644 index 0000000000..2804a14cb3 --- /dev/null +++ b/src/server/api/endpoints/i/appdata/set.ts @@ -0,0 +1,58 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import Appdata from '../../../models/appdata'; + +/** + * Set app data + * + * @param {any} params + * @param {any} user + * @param {any} app + * @param {Boolean} isSecure + * @return {Promise<any>} + */ +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 => { + const hasInvalidData = Object.entries(obj).some(([k, v]) => + $(k).string().match(/^[a-z_]+$/).nok() && $(v).string().nok()); + return !hasInvalidData; + }).$; + if (dataError) return rej('invalid data param'); + + // Get 'key' parameter + const [key, keyError] = $(params.key).optional.string().match(/[a-z_]+/).$; + if (keyError) return rej('invalid key param'); + + // Get 'value' parameter + const [value, valueError] = $(params.value).optional.string().$; + if (valueError) return rej('invalid value param'); + + const set = {}; + if (data) { + Object.entries(data).forEach(([k, v]) => { + set[`data.${k}`] = v; + }); + } else { + set[`data.${key}`] = value; + } + + 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); +}); diff --git a/src/server/api/endpoints/i/authorized_apps.ts b/src/server/api/endpoints/i/authorized_apps.ts new file mode 100644 index 0000000000..40ce7a68c8 --- /dev/null +++ b/src/server/api/endpoints/i/authorized_apps.ts @@ -0,0 +1,43 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import AccessToken from '../../models/access-token'; +import { pack } from '../../models/app'; + +/** + * Get authorized apps of my account + * + * @param {any} params + * @param {any} user + * @return {Promise<any>} + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + // Get 'limit' parameter + const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$; + if (limitErr) return rej('invalid limit param'); + + // Get 'offset' parameter + const [offset = 0, offsetErr] = $(params.offset).optional.number().min(0).$; + if (offsetErr) return rej('invalid offset param'); + + // Get 'sort' parameter + const [sort = 'desc', sortError] = $(params.sort).optional.string().or('desc asc').$; + if (sortError) return rej('invalid sort param'); + + // Get tokens + const tokens = await AccessToken + .find({ + user_id: user._id + }, { + limit: limit, + skip: offset, + sort: { + _id: sort == 'asc' ? 1 : -1 + } + }); + + // Serialize + res(await Promise.all(tokens.map(async token => + await pack(token.app_id)))); +}); diff --git a/src/server/api/endpoints/i/change_password.ts b/src/server/api/endpoints/i/change_password.ts new file mode 100644 index 0000000000..88fb36b1fb --- /dev/null +++ b/src/server/api/endpoints/i/change_password.ts @@ -0,0 +1,42 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import * as bcrypt from 'bcryptjs'; +import User from '../../models/user'; + +/** + * Change password + * + * @param {any} params + * @param {any} user + * @return {Promise<any>} + */ +module.exports = async (params, user) => new Promise(async (res, rej) => { + // Get 'current_password' parameter + const [currentPassword, currentPasswordErr] = $(params.current_password).string().$; + if (currentPasswordErr) return rej('invalid current_password param'); + + // Get 'new_password' parameter + const [newPassword, newPasswordErr] = $(params.new_password).string().$; + if (newPasswordErr) return rej('invalid new_password param'); + + // Compare password + const same = await bcrypt.compare(currentPassword, user.account.password); + + if (!same) { + return rej('incorrect password'); + } + + // Generate hash of password + const salt = await bcrypt.genSalt(8); + const hash = await bcrypt.hash(newPassword, salt); + + await User.update(user._id, { + $set: { + 'account.password': hash + } + }); + + res(); +}); diff --git a/src/server/api/endpoints/i/favorites.ts b/src/server/api/endpoints/i/favorites.ts new file mode 100644 index 0000000000..eb464cf0f0 --- /dev/null +++ b/src/server/api/endpoints/i/favorites.ts @@ -0,0 +1,44 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import Favorite from '../../models/favorite'; +import { pack } from '../../models/post'; + +/** + * Get followers of a user + * + * @param {any} params + * @param {any} user + * @return {Promise<any>} + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + // Get 'limit' parameter + const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$; + if (limitErr) return rej('invalid limit param'); + + // Get 'offset' parameter + const [offset = 0, offsetErr] = $(params.offset).optional.number().min(0).$; + if (offsetErr) return rej('invalid offset param'); + + // Get 'sort' parameter + const [sort = 'desc', sortError] = $(params.sort).optional.string().or('desc asc').$; + if (sortError) return rej('invalid sort param'); + + // Get favorites + const favorites = await Favorite + .find({ + user_id: user._id + }, { + limit: limit, + skip: offset, + sort: { + _id: sort == 'asc' ? 1 : -1 + } + }); + + // Serialize + res(await Promise.all(favorites.map(async favorite => + await pack(favorite.post) + ))); +}); diff --git a/src/server/api/endpoints/i/notifications.ts b/src/server/api/endpoints/i/notifications.ts new file mode 100644 index 0000000000..688039a0dd --- /dev/null +++ b/src/server/api/endpoints/i/notifications.ts @@ -0,0 +1,110 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import Notification from '../../models/notification'; +import Mute from '../../models/mute'; +import { pack } from '../../models/notification'; +import getFriends from '../../common/get-friends'; +import read from '../../common/read-notification'; + +/** + * Get notifications + * + * @param {any} params + * @param {any} user + * @return {Promise<any>} + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + // Get 'following' parameter + const [following = false, followingError] = + $(params.following).optional.boolean().$; + if (followingError) return rej('invalid following param'); + + // Get 'mark_as_read' parameter + const [markAsRead = true, markAsReadErr] = $(params.mark_as_read).optional.boolean().$; + if (markAsReadErr) return rej('invalid mark_as_read param'); + + // Get 'type' parameter + const [type, typeErr] = $(params.type).optional.array('string').unique().$; + if (typeErr) return rej('invalid type param'); + + // Get 'limit' parameter + const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$; + if (limitErr) return rej('invalid limit param'); + + // Get 'since_id' parameter + const [sinceId, sinceIdErr] = $(params.since_id).optional.id().$; + if (sinceIdErr) return rej('invalid since_id param'); + + // Get 'until_id' parameter + const [untilId, untilIdErr] = $(params.until_id).optional.id().$; + if (untilIdErr) return rej('invalid until_id param'); + + // Check if both of since_id and until_id is specified + if (sinceId && untilId) { + return rej('cannot set since_id and until_id'); + } + + const mute = await Mute.find({ + muter_id: user._id, + deleted_at: { $exists: false } + }); + + const query = { + notifiee_id: user._id, + $and: [{ + notifier_id: { + $nin: mute.map(m => m.mutee_id) + } + }] + } as any; + + const sort = { + _id: -1 + }; + + if (following) { + // ID list of the user itself and other users who the user follows + const followingIds = await getFriends(user._id); + + query.$and.push({ + notifier_id: { + $in: followingIds + } + }); + } + + if (type) { + query.type = { + $in: type + }; + } + + if (sinceId) { + sort._id = 1; + query._id = { + $gt: sinceId + }; + } else if (untilId) { + query._id = { + $lt: untilId + }; + } + + // Issue query + const notifications = await Notification + .find(query, { + limit: limit, + sort: sort + }); + + // Serialize + res(await Promise.all(notifications.map(async notification => + await pack(notification)))); + + // Mark as read all + if (notifications.length > 0 && markAsRead) { + read(user._id, notifications); + } +}); diff --git a/src/server/api/endpoints/i/pin.ts b/src/server/api/endpoints/i/pin.ts new file mode 100644 index 0000000000..ff546fc2bd --- /dev/null +++ b/src/server/api/endpoints/i/pin.ts @@ -0,0 +1,44 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import User from '../../models/user'; +import Post from '../../models/post'; +import { pack } from '../../models/user'; + +/** + * Pin post + * + * @param {any} params + * @param {any} user + * @return {Promise<any>} + */ +module.exports = async (params, user) => new Promise(async (res, rej) => { + // Get 'post_id' parameter + const [postId, postIdErr] = $(params.post_id).id().$; + if (postIdErr) return rej('invalid post_id param'); + + // Fetch pinee + const post = await Post.findOne({ + _id: postId, + user_id: user._id + }); + + if (post === null) { + return rej('post not found'); + } + + await User.update(user._id, { + $set: { + pinned_post_id: post._id + } + }); + + // Serialize + const iObj = await pack(user, user, { + detail: true + }); + + // Send response + res(iObj); +}); diff --git a/src/server/api/endpoints/i/regenerate_token.ts b/src/server/api/endpoints/i/regenerate_token.ts new file mode 100644 index 0000000000..9ac7b55071 --- /dev/null +++ b/src/server/api/endpoints/i/regenerate_token.ts @@ -0,0 +1,42 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import * as bcrypt from 'bcryptjs'; +import User from '../../models/user'; +import event from '../../event'; +import generateUserToken from '../../common/generate-native-user-token'; + +/** + * Regenerate native token + * + * @param {any} params + * @param {any} user + * @return {Promise<any>} + */ +module.exports = async (params, user) => new Promise(async (res, rej) => { + // Get 'password' parameter + const [password, passwordErr] = $(params.password).string().$; + if (passwordErr) return rej('invalid password param'); + + // Compare password + const same = await bcrypt.compare(password, user.account.password); + + if (!same) { + return rej('incorrect password'); + } + + // Generate secret + const secret = generateUserToken(); + + await User.update(user._id, { + $set: { + 'account.token': secret + } + }); + + res(); + + // Publish event + event(user._id, 'my_token_regenerated'); +}); diff --git a/src/server/api/endpoints/i/signin_history.ts b/src/server/api/endpoints/i/signin_history.ts new file mode 100644 index 0000000000..859e81653d --- /dev/null +++ b/src/server/api/endpoints/i/signin_history.ts @@ -0,0 +1,61 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import Signin, { pack } from '../../models/signin'; + +/** + * Get signin history of my account + * + * @param {any} params + * @param {any} user + * @return {Promise<any>} + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + // Get 'limit' parameter + const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$; + if (limitErr) return rej('invalid limit param'); + + // Get 'since_id' parameter + const [sinceId, sinceIdErr] = $(params.since_id).optional.id().$; + if (sinceIdErr) return rej('invalid since_id param'); + + // Get 'until_id' parameter + const [untilId, untilIdErr] = $(params.until_id).optional.id().$; + if (untilIdErr) return rej('invalid until_id param'); + + // Check if both of since_id and until_id is specified + if (sinceId && untilId) { + return rej('cannot set since_id and until_id'); + } + + const query = { + user_id: user._id + } as any; + + const sort = { + _id: -1 + }; + + if (sinceId) { + sort._id = 1; + query._id = { + $gt: sinceId + }; + } else if (untilId) { + query._id = { + $lt: untilId + }; + } + + // Issue query + const history = await Signin + .find(query, { + limit: limit, + sort: sort + }); + + // Serialize + res(await Promise.all(history.map(async record => + await pack(record)))); +}); diff --git a/src/server/api/endpoints/i/update.ts b/src/server/api/endpoints/i/update.ts new file mode 100644 index 0000000000..3d52de2cc5 --- /dev/null +++ b/src/server/api/endpoints/i/update.ts @@ -0,0 +1,97 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import User, { isValidName, isValidDescription, isValidLocation, isValidBirthday, pack } from '../../models/user'; +import event from '../../event'; +import config from '../../../../conf'; + +/** + * 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 'name' parameter + const [name, nameErr] = $(params.name).optional.string().pipe(isValidName).$; + if (nameErr) return rej('invalid name param'); + if (name) user.name = name; + + // Get 'description' parameter + const [description, descriptionErr] = $(params.description).optional.nullable.string().pipe(isValidDescription).$; + if (descriptionErr) return rej('invalid description param'); + if (description !== undefined) user.description = description; + + // Get 'location' parameter + const [location, locationErr] = $(params.location).optional.nullable.string().pipe(isValidLocation).$; + if (locationErr) return rej('invalid location param'); + if (location !== undefined) user.account.profile.location = location; + + // Get 'birthday' parameter + const [birthday, birthdayErr] = $(params.birthday).optional.nullable.string().pipe(isValidBirthday).$; + if (birthdayErr) return rej('invalid birthday param'); + if (birthday !== undefined) user.account.profile.birthday = birthday; + + // Get 'avatar_id' parameter + const [avatarId, avatarIdErr] = $(params.avatar_id).optional.id().$; + if (avatarIdErr) return rej('invalid avatar_id param'); + if (avatarId) user.avatar_id = avatarId; + + // Get 'banner_id' parameter + const [bannerId, bannerIdErr] = $(params.banner_id).optional.id().$; + if (bannerIdErr) return rej('invalid banner_id param'); + if (bannerId) user.banner_id = bannerId; + + // Get 'is_bot' parameter + const [isBot, isBotErr] = $(params.is_bot).optional.boolean().$; + if (isBotErr) return rej('invalid is_bot param'); + if (isBot != null) user.account.is_bot = isBot; + + // Get 'auto_watch' parameter + const [autoWatch, autoWatchErr] = $(params.auto_watch).optional.boolean().$; + if (autoWatchErr) return rej('invalid auto_watch param'); + if (autoWatch != null) user.account.settings.auto_watch = autoWatch; + + await User.update(user._id, { + $set: { + name: user.name, + description: user.description, + avatar_id: user.avatar_id, + banner_id: user.banner_id, + 'account.profile': user.account.profile, + 'account.is_bot': user.account.is_bot, + 'account.settings': user.account.settings + } + }); + + // Serialize + const iObj = await pack(user, user, { + detail: true, + includeSecrets: isSecure + }); + + // Send response + res(iObj); + + // Publish i updated event + event(user._id, 'i_updated', iObj); + + // Update search index + if (config.elasticsearch.enable) { + const es = require('../../../db/elasticsearch'); + + es.index({ + index: 'misskey', + type: 'user', + id: user._id.toString(), + body: { + name: user.name, + bio: user.bio + } + }); + } +}); diff --git a/src/server/api/endpoints/i/update_client_setting.ts b/src/server/api/endpoints/i/update_client_setting.ts new file mode 100644 index 0000000000..c772ed5dc3 --- /dev/null +++ b/src/server/api/endpoints/i/update_client_setting.ts @@ -0,0 +1,43 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import User, { pack } from '../../models/user'; +import event from '../../event'; + +/** + * Update myself + * + * @param {any} params + * @param {any} user + * @return {Promise<any>} + */ +module.exports = async (params, user) => new Promise(async (res, rej) => { + // Get 'name' parameter + const [name, nameErr] = $(params.name).string().$; + if (nameErr) return rej('invalid name param'); + + // Get 'value' parameter + const [value, valueErr] = $(params.value).nullable.any().$; + if (valueErr) return rej('invalid value param'); + + const x = {}; + x[`account.client_settings.${name}`] = value; + + await User.update(user._id, { + $set: x + }); + + // Serialize + user.account.client_settings[name] = value; + const iObj = await pack(user, user, { + detail: true, + includeSecrets: true + }); + + // Send response + res(iObj); + + // Publish i updated event + event(user._id, 'i_updated', iObj); +}); diff --git a/src/server/api/endpoints/i/update_home.ts b/src/server/api/endpoints/i/update_home.ts new file mode 100644 index 0000000000..9ce44e25ee --- /dev/null +++ b/src/server/api/endpoints/i/update_home.ts @@ -0,0 +1,60 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import User from '../../models/user'; +import event from '../../event'; + +module.exports = async (params, user) => new Promise(async (res, rej) => { + // Get 'home' parameter + const [home, homeErr] = $(params.home).optional.array().each( + $().strict.object() + .have('name', $().string()) + .have('id', $().string()) + .have('place', $().string()) + .have('data', $().object())).$; + 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: { + 'account.client_settings.home': home + } + }); + + res(); + + event(user._id, 'home_updated', { + home + }); + } else { + if (id == null && data == null) return rej('you need to set id and data params if home param unset'); + + const _home = user.account.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: { + 'account.client_settings.home': _home + } + }); + + res(); + + event(user._id, 'home_updated', { + id, data + }); + } +}); diff --git a/src/server/api/endpoints/i/update_mobile_home.ts b/src/server/api/endpoints/i/update_mobile_home.ts new file mode 100644 index 0000000000..1daddf42b9 --- /dev/null +++ b/src/server/api/endpoints/i/update_mobile_home.ts @@ -0,0 +1,59 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import User from '../../models/user'; +import event from '../../event'; + +module.exports = async (params, user) => new Promise(async (res, rej) => { + // Get 'home' parameter + const [home, homeErr] = $(params.home).optional.array().each( + $().strict.object() + .have('name', $().string()) + .have('id', $().string()) + .have('data', $().object())).$; + 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: { + 'account.client_settings.mobile_home': home + } + }); + + res(); + + event(user._id, 'mobile_home_updated', { + home + }); + } else { + if (id == null && data == null) return rej('you need to set id and data params if home param unset'); + + const _home = user.account.client_settings.mobile_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: { + 'account.client_settings.mobile_home': _home + } + }); + + res(); + + event(user._id, 'mobile_home_updated', { + id, data + }); + } +}); diff --git a/src/server/api/endpoints/messaging/history.ts b/src/server/api/endpoints/messaging/history.ts new file mode 100644 index 0000000000..1683ca7a89 --- /dev/null +++ b/src/server/api/endpoints/messaging/history.ts @@ -0,0 +1,43 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import History from '../../models/messaging-history'; +import Mute from '../../models/mute'; +import { pack } from '../../models/messaging-message'; + +/** + * Show messaging history + * + * @param {any} params + * @param {any} user + * @return {Promise<any>} + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + // Get 'limit' parameter + const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$; + if (limitErr) return rej('invalid limit param'); + + const mute = await Mute.find({ + muter_id: user._id, + deleted_at: { $exists: false } + }); + + // Get history + const history = await History + .find({ + user_id: user._id, + partner: { + $nin: mute.map(m => m.mutee_id) + } + }, { + limit: limit, + sort: { + updated_at: -1 + } + }); + + // Serialize + res(await Promise.all(history.map(async h => + await pack(h.message, user)))); +}); diff --git a/src/server/api/endpoints/messaging/messages.ts b/src/server/api/endpoints/messaging/messages.ts new file mode 100644 index 0000000000..67ba5e9d6d --- /dev/null +++ b/src/server/api/endpoints/messaging/messages.ts @@ -0,0 +1,102 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import Message from '../../models/messaging-message'; +import User from '../../models/user'; +import { pack } from '../../models/messaging-message'; +import read from '../../common/read-messaging-message'; + +/** + * Get messages + * + * @param {any} params + * @param {any} user + * @return {Promise<any>} + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + // Get 'user_id' parameter + const [recipientId, recipientIdErr] = $(params.user_id).id().$; + if (recipientIdErr) return rej('invalid user_id param'); + + // Fetch recipient + const recipient = await User.findOne({ + _id: recipientId + }, { + fields: { + _id: true + } + }); + + if (recipient === null) { + return rej('user not found'); + } + + // Get 'mark_as_read' parameter + const [markAsRead = true, markAsReadErr] = $(params.mark_as_read).optional.boolean().$; + if (markAsReadErr) return rej('invalid mark_as_read param'); + + // Get 'limit' parameter + const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$; + if (limitErr) return rej('invalid limit param'); + + // Get 'since_id' parameter + const [sinceId, sinceIdErr] = $(params.since_id).optional.id().$; + if (sinceIdErr) return rej('invalid since_id param'); + + // Get 'until_id' parameter + const [untilId, untilIdErr] = $(params.until_id).optional.id().$; + if (untilIdErr) return rej('invalid until_id param'); + + // Check if both of since_id and until_id is specified + if (sinceId && untilId) { + return rej('cannot set since_id and until_id'); + } + + const query = { + $or: [{ + user_id: user._id, + recipient_id: recipient._id + }, { + user_id: recipient._id, + recipient_id: user._id + }] + } as any; + + const sort = { + _id: -1 + }; + + if (sinceId) { + sort._id = 1; + query._id = { + $gt: sinceId + }; + } else if (untilId) { + query._id = { + $lt: untilId + }; + } + + // Issue query + const messages = await Message + .find(query, { + limit: limit, + sort: sort + }); + + // Serialize + res(await Promise.all(messages.map(async message => + await pack(message, user, { + populateRecipient: false + })))); + + if (messages.length === 0) { + return; + } + + // Mark as read all + if (markAsRead) { + read(user._id, recipient._id, messages); + } +}); diff --git a/src/server/api/endpoints/messaging/messages/create.ts b/src/server/api/endpoints/messaging/messages/create.ts new file mode 100644 index 0000000000..5184b2bd34 --- /dev/null +++ b/src/server/api/endpoints/messaging/messages/create.ts @@ -0,0 +1,156 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import Message from '../../../models/messaging-message'; +import { isValidText } from '../../../models/messaging-message'; +import History from '../../../models/messaging-history'; +import User from '../../../models/user'; +import Mute from '../../../models/mute'; +import DriveFile from '../../../models/drive-file'; +import { pack } from '../../../models/messaging-message'; +import publishUserStream from '../../../event'; +import { publishMessagingStream, publishMessagingIndexStream, pushSw } from '../../../event'; +import config from '../../../../../conf'; + +/** + * Create a message + * + * @param {any} params + * @param {any} user + * @return {Promise<any>} + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + // Get 'user_id' parameter + const [recipientId, recipientIdErr] = $(params.user_id).id().$; + if (recipientIdErr) return rej('invalid user_id param'); + + // Myself + if (recipientId.equals(user._id)) { + return rej('cannot send message to myself'); + } + + // Fetch recipient + const recipient = await User.findOne({ + _id: recipientId + }, { + fields: { + _id: true + } + }); + + if (recipient === null) { + return rej('user not found'); + } + + // Get 'text' parameter + const [text, textErr] = $(params.text).optional.string().pipe(isValidText).$; + if (textErr) return rej('invalid text'); + + // Get 'file_id' parameter + const [fileId, fileIdErr] = $(params.file_id).optional.id().$; + if (fileIdErr) return rej('invalid file_id param'); + + let file = null; + if (fileId !== undefined) { + file = await DriveFile.findOne({ + _id: fileId, + 'metadata.user_id': user._id + }); + + if (file === null) { + return rej('file not found'); + } + } + + // テキストが無いかつ添付ファイルも無かったらエラー + if (text === undefined && file === null) { + return rej('text or file is required'); + } + + // メッセージを作成 + const message = await Message.insert({ + created_at: new Date(), + file_id: file ? file._id : undefined, + recipient_id: recipient._id, + text: text ? text : undefined, + user_id: user._id, + is_read: false + }); + + // Serialize + const messageObj = await pack(message); + + // Reponse + res(messageObj); + + // 自分のストリーム + 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秒経っても(今回作成した)メッセージが既読にならなかったら「未読のメッセージがありますよ」イベントを発行する + setTimeout(async () => { + const freshMessage = await Message.findOne({ _id: message._id }, { is_read: true }); + if (!freshMessage.is_read) { + //#region ただしミュートされているなら発行しない + const mute = await Mute.find({ + muter_id: recipient._id, + deleted_at: { $exists: false } + }); + const mutedUserIds = mute.map(m => m.mutee_id.toString()); + if (mutedUserIds.indexOf(user._id.toString()) != -1) { + return; + } + //#endregion + + publishUserStream(message.recipient_id, 'unread_messaging_message', messageObj); + pushSw(message.recipient_id, 'unread_messaging_message', messageObj); + } + }, 3000); + + // Register to search database + if (message.text && config.elasticsearch.enable) { + const es = require('../../../db/elasticsearch'); + + es.index({ + index: 'misskey', + type: 'messaging_message', + id: message._id.toString(), + body: { + text: message.text + } + }); + } + + // 履歴作成(自分) + History.update({ + user_id: user._id, + partner: recipient._id + }, { + updated_at: new Date(), + user_id: user._id, + partner: recipient._id, + message: message._id + }, { + upsert: true + }); + + // 履歴作成(相手) + History.update({ + user_id: recipient._id, + partner: user._id + }, { + updated_at: new Date(), + user_id: recipient._id, + partner: user._id, + message: message._id + }, { + upsert: true + }); +}); diff --git a/src/server/api/endpoints/messaging/unread.ts b/src/server/api/endpoints/messaging/unread.ts new file mode 100644 index 0000000000..c4326e1d22 --- /dev/null +++ b/src/server/api/endpoints/messaging/unread.ts @@ -0,0 +1,33 @@ +/** + * Module dependencies + */ +import Message from '../../models/messaging-message'; +import Mute from '../../models/mute'; + +/** + * Get count of unread messages + * + * @param {any} params + * @param {any} user + * @return {Promise<any>} + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + const mute = await Mute.find({ + muter_id: user._id, + deleted_at: { $exists: false } + }); + const mutedUserIds = mute.map(m => m.mutee_id); + + const count = await Message + .count({ + user_id: { + $nin: mutedUserIds + }, + recipient_id: user._id, + is_read: false + }); + + res({ + count: count + }); +}); diff --git a/src/server/api/endpoints/meta.ts b/src/server/api/endpoints/meta.ts new file mode 100644 index 0000000000..10625ec66f --- /dev/null +++ b/src/server/api/endpoints/meta.ts @@ -0,0 +1,59 @@ +/** + * Module dependencies + */ +import * as os from 'os'; +import version from '../../../version'; +import config from '../../../conf'; +import Meta from '../models/meta'; + +/** + * @swagger + * /meta: + * post: + * summary: Show the misskey's information + * responses: + * 200: + * description: Success + * schema: + * type: object + * properties: + * maintainer: + * description: maintainer's name + * type: string + * commit: + * description: latest commit's hash + * type: string + * secure: + * description: whether the server supports secure protocols + * type: boolean + * + * default: + * description: Failed + * schema: + * $ref: "#/definitions/Error" + */ + +/** + * Show core info + * + * @param {any} params + * @return {Promise<any>} + */ +module.exports = (params) => new Promise(async (res, rej) => { + const meta = (await Meta.findOne()) || {}; + + res({ + maintainer: config.maintainer, + version: version, + secure: config.https != null, + machine: os.hostname(), + os: os.platform(), + node: process.version, + cpu: { + model: os.cpus()[0].model, + cores: os.cpus().length + }, + top_image: meta.top_image, + broadcasts: meta.broadcasts + }); +}); diff --git a/src/server/api/endpoints/mute/create.ts b/src/server/api/endpoints/mute/create.ts new file mode 100644 index 0000000000..f99b40d32e --- /dev/null +++ b/src/server/api/endpoints/mute/create.ts @@ -0,0 +1,61 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import User from '../../models/user'; +import Mute from '../../models/mute'; + +/** + * Mute a user + * + * @param {any} params + * @param {any} user + * @return {Promise<any>} + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + const muter = user; + + // Get 'user_id' parameter + const [userId, userIdErr] = $(params.user_id).id().$; + if (userIdErr) return rej('invalid user_id param'); + + // 自分自身 + if (user._id.equals(userId)) { + return rej('mutee is yourself'); + } + + // Get mutee + const mutee = await User.findOne({ + _id: userId + }, { + fields: { + data: false, + 'account.profile': false + } + }); + + if (mutee === null) { + return rej('user not found'); + } + + // Check if already muting + const exist = await Mute.findOne({ + muter_id: muter._id, + mutee_id: mutee._id, + deleted_at: { $exists: false } + }); + + if (exist !== null) { + return rej('already muting'); + } + + // Create mute + await Mute.insert({ + created_at: new Date(), + muter_id: muter._id, + mutee_id: mutee._id, + }); + + // Send response + res(); +}); diff --git a/src/server/api/endpoints/mute/delete.ts b/src/server/api/endpoints/mute/delete.ts new file mode 100644 index 0000000000..36e2fd101a --- /dev/null +++ b/src/server/api/endpoints/mute/delete.ts @@ -0,0 +1,63 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import User from '../../models/user'; +import Mute from '../../models/mute'; + +/** + * Unmute a user + * + * @param {any} params + * @param {any} user + * @return {Promise<any>} + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + const muter = user; + + // Get 'user_id' parameter + const [userId, userIdErr] = $(params.user_id).id().$; + if (userIdErr) return rej('invalid user_id param'); + + // Check if the mutee is yourself + if (user._id.equals(userId)) { + return rej('mutee is yourself'); + } + + // Get mutee + const mutee = await User.findOne({ + _id: userId + }, { + fields: { + data: false, + 'account.profile': false + } + }); + + if (mutee === null) { + return rej('user not found'); + } + + // Check not muting + const exist = await Mute.findOne({ + muter_id: muter._id, + mutee_id: mutee._id, + deleted_at: { $exists: false } + }); + + if (exist === null) { + return rej('already not muting'); + } + + // Delete mute + await Mute.update({ + _id: exist._id + }, { + $set: { + deleted_at: new Date() + } + }); + + // Send response + res(); +}); diff --git a/src/server/api/endpoints/mute/list.ts b/src/server/api/endpoints/mute/list.ts new file mode 100644 index 0000000000..19e3b157e6 --- /dev/null +++ b/src/server/api/endpoints/mute/list.ts @@ -0,0 +1,73 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import Mute from '../../models/mute'; +import { pack } from '../../models/user'; +import getFriends from '../../common/get-friends'; + +/** + * Get muted users of a user + * + * @param {any} params + * @param {any} me + * @return {Promise<any>} + */ +module.exports = (params, me) => new Promise(async (res, rej) => { + // Get 'iknow' parameter + const [iknow = false, iknowErr] = $(params.iknow).optional.boolean().$; + if (iknowErr) return rej('invalid iknow param'); + + // Get 'limit' parameter + const [limit = 30, limitErr] = $(params.limit).optional.number().range(1, 100).$; + if (limitErr) return rej('invalid limit param'); + + // Get 'cursor' parameter + const [cursor = null, cursorErr] = $(params.cursor).optional.id().$; + if (cursorErr) return rej('invalid cursor param'); + + // Construct query + const query = { + muter_id: me._id, + deleted_at: { $exists: false } + } as any; + + if (iknow) { + // Get my friends + const myFriends = await getFriends(me._id); + + query.mutee_id = { + $in: myFriends + }; + } + + // カーソルが指定されている場合 + if (cursor) { + query._id = { + $lt: cursor + }; + } + + // Get mutes + const mutes = await Mute + .find(query, { + limit: limit + 1, + sort: { _id: -1 } + }); + + // 「次のページ」があるかどうか + const inStock = mutes.length === limit + 1; + if (inStock) { + mutes.pop(); + } + + // Serialize + const users = await Promise.all(mutes.map(async m => + await pack(m.mutee_id, me, { detail: true }))); + + // Response + res({ + users: users, + next: inStock ? mutes[mutes.length - 1]._id : null, + }); +}); diff --git a/src/server/api/endpoints/my/apps.ts b/src/server/api/endpoints/my/apps.ts new file mode 100644 index 0000000000..b236190506 --- /dev/null +++ b/src/server/api/endpoints/my/apps.ts @@ -0,0 +1,40 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import App, { pack } from '../../models/app'; + +/** + * Get my apps + * + * @param {any} params + * @param {any} user + * @return {Promise<any>} + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + // Get 'limit' parameter + const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$; + if (limitErr) return rej('invalid limit param'); + + // Get 'offset' parameter + const [offset = 0, offsetErr] = $(params.offset).optional.number().min(0).$; + if (offsetErr) return rej('invalid offset param'); + + const query = { + user_id: user._id + }; + + // Execute query + const apps = await App + .find(query, { + limit: limit, + skip: offset, + sort: { + _id: -1 + } + }); + + // Reply + res(await Promise.all(apps.map(async app => + await pack(app)))); +}); diff --git a/src/server/api/endpoints/notifications/get_unread_count.ts b/src/server/api/endpoints/notifications/get_unread_count.ts new file mode 100644 index 0000000000..845d6b29ce --- /dev/null +++ b/src/server/api/endpoints/notifications/get_unread_count.ts @@ -0,0 +1,33 @@ +/** + * Module dependencies + */ +import Notification from '../../models/notification'; +import Mute from '../../models/mute'; + +/** + * Get count of unread notifications + * + * @param {any} params + * @param {any} user + * @return {Promise<any>} + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + const mute = await Mute.find({ + muter_id: user._id, + deleted_at: { $exists: false } + }); + const mutedUserIds = mute.map(m => m.mutee_id); + + const count = await Notification + .count({ + notifiee_id: user._id, + notifier_id: { + $nin: mutedUserIds + }, + is_read: false + }); + + res({ + count: count + }); +}); diff --git a/src/server/api/endpoints/notifications/mark_as_read_all.ts b/src/server/api/endpoints/notifications/mark_as_read_all.ts new file mode 100644 index 0000000000..3550e344c4 --- /dev/null +++ b/src/server/api/endpoints/notifications/mark_as_read_all.ts @@ -0,0 +1,32 @@ +/** + * Module dependencies + */ +import Notification from '../../models/notification'; +import event from '../../event'; + +/** + * Mark as read all notifications + * + * @param {any} params + * @param {any} user + * @return {Promise<any>} + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + // Update documents + await Notification.update({ + notifiee_id: user._id, + is_read: false + }, { + $set: { + is_read: true + } + }, { + multi: true + }); + + // Response + res(); + + // 全ての通知を読みましたよというイベントを発行 + event(user._id, 'read_all_notifications'); +}); diff --git a/src/server/api/endpoints/othello/games.ts b/src/server/api/endpoints/othello/games.ts new file mode 100644 index 0000000000..2a6bbb4043 --- /dev/null +++ b/src/server/api/endpoints/othello/games.ts @@ -0,0 +1,62 @@ +import $ from 'cafy'; +import Game, { pack } from '../../models/othello-game'; + +module.exports = (params, user) => new Promise(async (res, rej) => { + // Get 'my' parameter + const [my = false, myErr] = $(params.my).optional.boolean().$; + if (myErr) return rej('invalid my param'); + + // Get 'limit' parameter + const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$; + if (limitErr) return rej('invalid limit param'); + + // Get 'since_id' parameter + const [sinceId, sinceIdErr] = $(params.since_id).optional.id().$; + if (sinceIdErr) return rej('invalid since_id param'); + + // Get 'until_id' parameter + const [untilId, untilIdErr] = $(params.until_id).optional.id().$; + if (untilIdErr) return rej('invalid until_id param'); + + // Check if both of since_id and until_id is specified + if (sinceId && untilId) { + return rej('cannot set since_id and until_id'); + } + + const q: any = my ? { + is_started: true, + $or: [{ + user1_id: user._id + }, { + user2_id: user._id + }] + } : { + is_started: true + }; + + const sort = { + _id: -1 + }; + + if (sinceId) { + sort._id = 1; + q._id = { + $gt: sinceId + }; + } else if (untilId) { + q._id = { + $lt: untilId + }; + } + + // Fetch games + const games = await Game.find(q, { + sort, + limit + }); + + // Reponse + res(Promise.all(games.map(async (g) => await pack(g, user, { + detail: false + })))); +}); diff --git a/src/server/api/endpoints/othello/games/show.ts b/src/server/api/endpoints/othello/games/show.ts new file mode 100644 index 0000000000..2b0db4dd00 --- /dev/null +++ b/src/server/api/endpoints/othello/games/show.ts @@ -0,0 +1,32 @@ +import $ from 'cafy'; +import Game, { pack } from '../../../models/othello-game'; +import Othello from '../../../../common/othello/core'; + +module.exports = (params, user) => new Promise(async (res, rej) => { + // Get 'game_id' parameter + const [gameId, gameIdErr] = $(params.game_id).id().$; + if (gameIdErr) return rej('invalid game_id param'); + + const game = await Game.findOne({ _id: gameId }); + + if (game == null) { + return rej('game not found'); + } + + const o = new Othello(game.settings.map, { + isLlotheo: game.settings.is_llotheo, + canPutEverywhere: game.settings.can_put_everywhere, + loopedBoard: game.settings.looped_board + }); + + game.logs.forEach(log => { + o.put(log.color, log.pos); + }); + + const packed = await pack(game, user); + + res(Object.assign({ + board: o.board, + turn: o.turn + }, packed)); +}); diff --git a/src/server/api/endpoints/othello/invitations.ts b/src/server/api/endpoints/othello/invitations.ts new file mode 100644 index 0000000000..02fb421fbc --- /dev/null +++ b/src/server/api/endpoints/othello/invitations.ts @@ -0,0 +1,15 @@ +import Matching, { pack as packMatching } from '../../models/othello-matching'; + +module.exports = (params, user) => new Promise(async (res, rej) => { + // Find session + const invitations = await Matching.find({ + child_id: user._id + }, { + sort: { + _id: -1 + } + }); + + // Reponse + res(Promise.all(invitations.map(async (i) => await packMatching(i, user)))); +}); diff --git a/src/server/api/endpoints/othello/match.ts b/src/server/api/endpoints/othello/match.ts new file mode 100644 index 0000000000..b73e105ef0 --- /dev/null +++ b/src/server/api/endpoints/othello/match.ts @@ -0,0 +1,95 @@ +import $ from 'cafy'; +import Matching, { pack as packMatching } from '../../models/othello-matching'; +import Game, { pack as packGame } from '../../models/othello-game'; +import User from '../../models/user'; +import publishUserStream, { publishOthelloStream } from '../../event'; +import { eighteight } from '../../../common/othello/maps'; + +module.exports = (params, user) => new Promise(async (res, rej) => { + // Get 'user_id' parameter + const [childId, childIdErr] = $(params.user_id).id().$; + if (childIdErr) return rej('invalid user_id param'); + + // Myself + if (childId.equals(user._id)) { + return rej('invalid user_id param'); + } + + // Find session + const exist = await Matching.findOne({ + parent_id: childId, + child_id: user._id + }); + + if (exist) { + // Destroy session + Matching.remove({ + _id: exist._id + }); + + // Create game + const game = await Game.insert({ + created_at: new Date(), + user1_id: exist.parent_id, + user2_id: user._id, + user1_accepted: false, + user2_accepted: false, + is_started: false, + is_ended: false, + logs: [], + settings: { + map: eighteight.data, + bw: 'random', + is_llotheo: false + } + }); + + // Reponse + res(await packGame(game, user)); + + publishOthelloStream(exist.parent_id, 'matched', await packGame(game, exist.parent_id)); + + const other = await Matching.count({ + child_id: user._id + }); + + if (other == 0) { + publishUserStream(user._id, 'othello_no_invites'); + } + } else { + // Fetch child + const child = await User.findOne({ + _id: childId + }, { + fields: { + _id: true + } + }); + + if (child === null) { + return rej('user not found'); + } + + // 以前のセッションはすべて削除しておく + await Matching.remove({ + parent_id: user._id + }); + + // セッションを作成 + const matching = await Matching.insert({ + created_at: new Date(), + parent_id: user._id, + child_id: child._id + }); + + // Reponse + res(); + + const packed = await packMatching(matching, child); + + // 招待 + publishOthelloStream(child._id, 'invited', packed); + + publishUserStream(child._id, 'othello_invited', packed); + } +}); diff --git a/src/server/api/endpoints/othello/match/cancel.ts b/src/server/api/endpoints/othello/match/cancel.ts new file mode 100644 index 0000000000..6f751ef835 --- /dev/null +++ b/src/server/api/endpoints/othello/match/cancel.ts @@ -0,0 +1,9 @@ +import Matching from '../../../models/othello-matching'; + +module.exports = (params, user) => new Promise(async (res, rej) => { + await Matching.remove({ + parent_id: user._id + }); + + res(); +}); diff --git a/src/server/api/endpoints/posts.ts b/src/server/api/endpoints/posts.ts new file mode 100644 index 0000000000..7df744d2a3 --- /dev/null +++ b/src/server/api/endpoints/posts.ts @@ -0,0 +1,97 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import Post, { pack } from '../models/post'; + +/** + * Lists all posts + * + * @param {any} params + * @return {Promise<any>} + */ +module.exports = (params) => new Promise(async (res, rej) => { + // Get 'reply' parameter + const [reply, replyErr] = $(params.reply).optional.boolean().$; + if (replyErr) return rej('invalid reply param'); + + // Get 'repost' parameter + const [repost, repostErr] = $(params.repost).optional.boolean().$; + if (repostErr) return rej('invalid repost param'); + + // Get 'media' parameter + const [media, mediaErr] = $(params.media).optional.boolean().$; + if (mediaErr) return rej('invalid media param'); + + // Get 'poll' parameter + const [poll, pollErr] = $(params.poll).optional.boolean().$; + if (pollErr) return rej('invalid poll param'); + + // Get 'bot' parameter + //const [bot, botErr] = $(params.bot).optional.boolean().$; + //if (botErr) return rej('invalid bot param'); + + // Get 'limit' parameter + const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$; + if (limitErr) return rej('invalid limit param'); + + // Get 'since_id' parameter + const [sinceId, sinceIdErr] = $(params.since_id).optional.id().$; + if (sinceIdErr) return rej('invalid since_id param'); + + // Get 'until_id' parameter + const [untilId, untilIdErr] = $(params.until_id).optional.id().$; + if (untilIdErr) return rej('invalid until_id param'); + + // Check if both of since_id and until_id is specified + if (sinceId && untilId) { + return rej('cannot set since_id and until_id'); + } + + // Construct query + const sort = { + _id: -1 + }; + const query = {} as any; + if (sinceId) { + sort._id = 1; + query._id = { + $gt: sinceId + }; + } else if (untilId) { + query._id = { + $lt: untilId + }; + } + + if (reply != undefined) { + query.reply_id = reply ? { $exists: true, $ne: null } : null; + } + + if (repost != undefined) { + query.repost_id = repost ? { $exists: true, $ne: null } : null; + } + + if (media != undefined) { + query.media_ids = media ? { $exists: true, $ne: null } : null; + } + + if (poll != undefined) { + query.poll = poll ? { $exists: true, $ne: null } : null; + } + + // TODO + //if (bot != undefined) { + // query.is_bot = bot; + //} + + // Issue query + const posts = await Post + .find(query, { + limit: limit, + sort: sort + }); + + // Serialize + res(await Promise.all(posts.map(async post => await pack(post)))); +}); diff --git a/src/server/api/endpoints/posts/categorize.ts b/src/server/api/endpoints/posts/categorize.ts new file mode 100644 index 0000000000..0c85c2b4e0 --- /dev/null +++ b/src/server/api/endpoints/posts/categorize.ts @@ -0,0 +1,52 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import Post from '../../models/post'; + +/** + * Categorize a post + * + * @param {any} params + * @param {any} user + * @return {Promise<any>} + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + if (!user.account.is_pro) { + return rej('This endpoint is available only from a Pro account'); + } + + // Get 'post_id' parameter + const [postId, postIdErr] = $(params.post_id).id().$; + if (postIdErr) return rej('invalid post_id param'); + + // Get categorizee + const post = await Post.findOne({ + _id: postId + }); + + if (post === null) { + return rej('post not found'); + } + + if (post.is_category_verified) { + return rej('This post already has the verified category'); + } + + // Get 'category' parameter + const [category, categoryErr] = $(params.category).string().or([ + 'music', 'game', 'anime', 'it', 'gadgets', 'photography' + ]).$; + if (categoryErr) return rej('invalid category param'); + + // Set category + Post.update({ _id: post._id }, { + $set: { + category: category, + is_category_verified: true + } + }); + + // Send response + res(); +}); diff --git a/src/server/api/endpoints/posts/context.ts b/src/server/api/endpoints/posts/context.ts new file mode 100644 index 0000000000..5ba3758975 --- /dev/null +++ b/src/server/api/endpoints/posts/context.ts @@ -0,0 +1,63 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import Post, { pack } from '../../models/post'; + +/** + * Show a context of a post + * + * @param {any} params + * @param {any} user + * @return {Promise<any>} + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + // Get 'post_id' parameter + const [postId, postIdErr] = $(params.post_id).id().$; + if (postIdErr) return rej('invalid post_id param'); + + // Get 'limit' parameter + const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$; + if (limitErr) return rej('invalid limit param'); + + // Get 'offset' parameter + const [offset = 0, offsetErr] = $(params.offset).optional.number().min(0).$; + if (offsetErr) return rej('invalid offset param'); + + // Lookup post + const post = await Post.findOne({ + _id: postId + }); + + if (post === null) { + return rej('post not found'); + } + + const context = []; + let i = 0; + + async function get(id) { + i++; + const p = await Post.findOne({ _id: id }); + + if (i > offset) { + context.push(p); + } + + if (context.length == limit) { + return; + } + + if (p.reply_id) { + await get(p.reply_id); + } + } + + if (post.reply_id) { + await get(post.reply_id); + } + + // Serialize + res(await Promise.all(context.map(async post => + await pack(post, user)))); +}); diff --git a/src/server/api/endpoints/posts/create.ts b/src/server/api/endpoints/posts/create.ts new file mode 100644 index 0000000000..bc9af843b6 --- /dev/null +++ b/src/server/api/endpoints/posts/create.ts @@ -0,0 +1,536 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import deepEqual = require('deep-equal'); +import parse from '../../common/text'; +import { default as Post, IPost, isValidText } from '../../models/post'; +import { default as User, ILocalAccount, IUser } from '../../models/user'; +import { default as Channel, IChannel } from '../../models/channel'; +import Following from '../../models/following'; +import Mute from '../../models/mute'; +import DriveFile from '../../models/drive-file'; +import Watching from '../../models/post-watching'; +import ChannelWatching from '../../models/channel-watching'; +import { pack } from '../../models/post'; +import notify from '../../common/notify'; +import watch from '../../common/watch-post'; +import event, { pushSw, publishChannelStream } from '../../event'; +import getAcct from '../../../common/user/get-acct'; +import parseAcct from '../../../common/user/parse-acct'; +import config from '../../../../conf'; + +/** + * Create a post + * + * @param {any} params + * @param {any} user + * @param {any} app + * @return {Promise<any>} + */ +module.exports = (params, user: IUser, app) => new Promise(async (res, rej) => { + // Get 'text' parameter + const [text, textErr] = $(params.text).optional.string().pipe(isValidText).$; + if (textErr) return rej('invalid text'); + + // Get 'via_mobile' parameter + const [viaMobile = false, viaMobileErr] = $(params.via_mobile).optional.boolean().$; + if (viaMobileErr) return rej('invalid via_mobile'); + + // Get 'tags' parameter + const [tags = [], tagsErr] = $(params.tags).optional.array('string').unique().eachQ(t => t.range(1, 32)).$; + if (tagsErr) return rej('invalid tags'); + + // Get 'geo' parameter + const [geo, geoErr] = $(params.geo).optional.nullable.strict.object() + .have('latitude', $().number().range(-90, 90)) + .have('longitude', $().number().range(-180, 180)) + .have('altitude', $().nullable.number()) + .have('accuracy', $().nullable.number()) + .have('altitudeAccuracy', $().nullable.number()) + .have('heading', $().nullable.number().range(0, 360)) + .have('speed', $().nullable.number()) + .$; + if (geoErr) return rej('invalid geo'); + + // Get 'media_ids' parameter + const [mediaIds, mediaIdsErr] = $(params.media_ids).optional.array('id').unique().range(1, 4).$; + if (mediaIdsErr) return rej('invalid media_ids'); + + let files = []; + if (mediaIds !== undefined) { + // Fetch files + // forEach だと途中でエラーなどがあっても return できないので + // 敢えて for を使っています。 + for (const mediaId of mediaIds) { + // Fetch file + // SELECT _id + const entity = await DriveFile.findOne({ + _id: mediaId, + 'metadata.user_id': user._id + }); + + if (entity === null) { + return rej('file not found'); + } else { + files.push(entity); + } + } + } else { + files = null; + } + + // Get 'repost_id' parameter + const [repostId, repostIdErr] = $(params.repost_id).optional.id().$; + if (repostIdErr) return rej('invalid repost_id'); + + let repost: IPost = null; + let isQuote = false; + if (repostId !== undefined) { + // Fetch repost to post + repost = await Post.findOne({ + _id: repostId + }); + + if (repost == null) { + return rej('repostee is not found'); + } else if (repost.repost_id && !repost.text && !repost.media_ids) { + return rej('cannot repost to repost'); + } + + // Fetch recently post + const latestPost = await Post.findOne({ + user_id: user._id + }, { + sort: { + _id: -1 + } + }); + + isQuote = text != null || files != null; + + // 直近と同じRepost対象かつ引用じゃなかったらエラー + if (latestPost && + latestPost.repost_id && + latestPost.repost_id.equals(repost._id) && + !isQuote) { + return rej('cannot repost same post that already reposted in your latest post'); + } + + // 直近がRepost対象かつ引用じゃなかったらエラー + if (latestPost && + latestPost._id.equals(repost._id) && + !isQuote) { + return rej('cannot repost your latest post'); + } + } + + // Get 'reply_id' parameter + const [replyId, replyIdErr] = $(params.reply_id).optional.id().$; + if (replyIdErr) return rej('invalid reply_id'); + + let reply: IPost = null; + if (replyId !== undefined) { + // Fetch reply + reply = await Post.findOne({ + _id: replyId + }); + + if (reply === null) { + return rej('in reply to post is not found'); + } + + // 返信対象が引用でないRepostだったらエラー + if (reply.repost_id && !reply.text && !reply.media_ids) { + return rej('cannot reply to repost'); + } + } + + // Get 'channel_id' parameter + const [channelId, channelIdErr] = $(params.channel_id).optional.id().$; + if (channelIdErr) return rej('invalid channel_id'); + + let channel: IChannel = null; + if (channelId !== undefined) { + // Fetch channel + channel = await Channel.findOne({ + _id: channelId + }); + + if (channel === null) { + return rej('channel not found'); + } + + // 返信対象の投稿がこのチャンネルじゃなかったらダメ + if (reply && !channelId.equals(reply.channel_id)) { + return rej('チャンネル内部からチャンネル外部の投稿に返信することはできません'); + } + + // Repost対象の投稿がこのチャンネルじゃなかったらダメ + if (repost && !channelId.equals(repost.channel_id)) { + return rej('チャンネル内部からチャンネル外部の投稿をRepostすることはできません'); + } + + // 引用ではないRepostはダメ + if (repost && !isQuote) { + return rej('チャンネル内部では引用ではないRepostをすることはできません'); + } + } else { + // 返信対象の投稿がチャンネルへの投稿だったらダメ + if (reply && reply.channel_id != null) { + return rej('チャンネル外部からチャンネル内部の投稿に返信することはできません'); + } + + // Repost対象の投稿がチャンネルへの投稿だったらダメ + if (repost && repost.channel_id != null) { + return rej('チャンネル外部からチャンネル内部の投稿をRepostすることはできません'); + } + } + + // Get 'poll' parameter + const [poll, pollErr] = $(params.poll).optional.strict.object() + .have('choices', $().array('string') + .unique() + .range(2, 10) + .each(c => c.length > 0 && c.length < 50)) + .$; + if (pollErr) return rej('invalid poll'); + + if (poll) { + (poll as any).choices = (poll as any).choices.map((choice, i) => ({ + id: i, // IDを付与 + text: choice.trim(), + votes: 0 + })); + } + + // テキストが無いかつ添付ファイルが無いかつRepostも無いかつ投票も無かったらエラー + if (text === undefined && files === null && repost === null && poll === undefined) { + return rej('text, media_ids, repost_id or poll is required'); + } + + // 直近の投稿と重複してたらエラー + // TODO: 直近の投稿が一日前くらいなら重複とは見なさない + if (user.latest_post) { + if (deepEqual({ + text: user.latest_post.text, + reply: user.latest_post.reply_id ? user.latest_post.reply_id.toString() : null, + repost: user.latest_post.repost_id ? user.latest_post.repost_id.toString() : null, + media_ids: (user.latest_post.media_ids || []).map(id => id.toString()) + }, { + text: text, + reply: reply ? reply._id.toString() : null, + repost: repost ? repost._id.toString() : null, + media_ids: (files || []).map(file => file._id.toString()) + })) { + return rej('duplicate'); + } + } + + let tokens = null; + if (text) { + // Analyze + tokens = parse(text); + + // Extract hashtags + const hashtags = tokens + .filter(t => t.type == 'hashtag') + .map(t => t.hashtag); + + hashtags.forEach(tag => { + if (tags.indexOf(tag) == -1) { + tags.push(tag); + } + }); + } + + // 投稿を作成 + const post = await Post.insert({ + created_at: new Date(), + channel_id: channel ? channel._id : undefined, + index: channel ? channel.index + 1 : undefined, + media_ids: files ? files.map(file => file._id) : undefined, + reply_id: reply ? reply._id : undefined, + repost_id: repost ? repost._id : undefined, + poll: poll, + text: text, + tags: tags, + user_id: user._id, + app_id: app ? app._id : null, + via_mobile: viaMobile, + geo, + + // 以下非正規化データ + _reply: reply ? { user_id: reply.user_id } : undefined, + _repost: repost ? { user_id: repost.user_id } : undefined, + }); + + // Serialize + const postObj = await pack(post); + + // Reponse + res({ + created_post: postObj + }); + + //#region Post processes + + User.update({ _id: user._id }, { + $set: { + latest_post: post + } + }); + + const mentions = []; + + async function addMention(mentionee, reason) { + // Reject if already added + if (mentions.some(x => x.equals(mentionee))) return; + + // Add mention + mentions.push(mentionee); + + // Publish event + if (!user._id.equals(mentionee)) { + const mentioneeMutes = await Mute.find({ + muter_id: mentionee, + deleted_at: { $exists: false } + }); + const mentioneesMutedUserIds = mentioneeMutes.map(m => m.mutee_id.toString()); + if (mentioneesMutedUserIds.indexOf(user._id.toString()) == -1) { + event(mentionee, reason, postObj); + pushSw(mentionee, reason, postObj); + } + } + } + + // タイムラインへの投稿 + if (!channel) { + // Publish event to myself's stream + event(user._id, 'post', postObj); + + // Fetch all followers + const followers = await Following + .find({ + followee_id: user._id, + // 削除されたドキュメントは除く + deleted_at: { $exists: false } + }, { + follower_id: true, + _id: false + }); + + // Publish event to followers stream + followers.forEach(following => + event(following.follower_id, 'post', postObj)); + } + + // チャンネルへの投稿 + if (channel) { + // Increment channel index(posts count) + Channel.update({ _id: channel._id }, { + $inc: { + index: 1 + } + }); + + // Publish event to channel + publishChannelStream(channel._id, 'post', postObj); + + // Get channel watchers + const watches = await ChannelWatching.find({ + channel_id: channel._id, + // 削除されたドキュメントは除く + deleted_at: { $exists: false } + }); + + // チャンネルの視聴者(のタイムライン)に配信 + watches.forEach(w => { + event(w.user_id, 'post', postObj); + }); + } + + // Increment my posts count + User.update({ _id: user._id }, { + $inc: { + posts_count: 1 + } + }); + + // If has in reply to post + if (reply) { + // Increment replies count + Post.update({ _id: reply._id }, { + $inc: { + replies_count: 1 + } + }); + + // 自分自身へのリプライでない限りは通知を作成 + notify(reply.user_id, user._id, 'reply', { + post_id: post._id + }); + + // Fetch watchers + Watching + .find({ + post_id: reply._id, + user_id: { $ne: user._id }, + // 削除されたドキュメントは除く + deleted_at: { $exists: false } + }, { + fields: { + user_id: true + } + }) + .then(watchers => { + watchers.forEach(watcher => { + notify(watcher.user_id, user._id, 'reply', { + post_id: post._id + }); + }); + }); + + // この投稿をWatchする + if ((user.account as ILocalAccount).settings.auto_watch !== false) { + watch(user._id, reply); + } + + // Add mention + addMention(reply.user_id, 'reply'); + } + + // If it is repost + if (repost) { + // Notify + const type = text ? 'quote' : 'repost'; + notify(repost.user_id, user._id, type, { + post_id: post._id + }); + + // Fetch watchers + Watching + .find({ + post_id: repost._id, + user_id: { $ne: user._id }, + // 削除されたドキュメントは除く + deleted_at: { $exists: false } + }, { + fields: { + user_id: true + } + }) + .then(watchers => { + watchers.forEach(watcher => { + notify(watcher.user_id, user._id, type, { + post_id: post._id + }); + }); + }); + + // この投稿をWatchする + // TODO: ユーザーが「Repostしたときに自動でWatchする」設定を + // オフにしていた場合はしない + watch(user._id, repost); + + // If it is quote repost + if (text) { + // Add mention + addMention(repost.user_id, 'quote'); + } else { + // Publish event + if (!user._id.equals(repost.user_id)) { + event(repost.user_id, 'repost', postObj); + } + } + + // 今までで同じ投稿をRepostしているか + const existRepost = await Post.findOne({ + user_id: user._id, + repost_id: repost._id, + _id: { + $ne: post._id + } + }); + + if (!existRepost) { + // Update repostee status + Post.update({ _id: repost._id }, { + $inc: { + repost_count: 1 + } + }); + } + } + + // If has text content + if (text) { + /* + // Extract a hashtags + const hashtags = tokens + .filter(t => t.type == 'hashtag') + .map(t => t.hashtag) + // Drop dupulicates + .filter((v, i, s) => s.indexOf(v) == i); + + // ハッシュタグをデータベースに登録 + registerHashtags(user, hashtags); + */ + // Extract an '@' mentions + const atMentions = tokens + .filter(t => t.type == 'mention') + .map(getAcct) + // Drop dupulicates + .filter((v, i, s) => s.indexOf(v) == i); + + // Resolve all mentions + await Promise.all(atMentions.map(async (mention) => { + // Fetch mentioned user + // SELECT _id + const mentionee = await User + .findOne(parseAcct(mention), { _id: true }); + + // When mentioned user not found + if (mentionee == null) return; + + // 既に言及されたユーザーに対する返信や引用repostの場合も無視 + if (reply && reply.user_id.equals(mentionee._id)) return; + if (repost && repost.user_id.equals(mentionee._id)) return; + + // Add mention + addMention(mentionee._id, 'mention'); + + // Create notification + notify(mentionee._id, user._id, 'mention', { + post_id: post._id + }); + + return; + })); + } + + // Register to search database + if (text && config.elasticsearch.enable) { + const es = require('../../../db/elasticsearch'); + + es.index({ + index: 'misskey', + type: 'post', + id: post._id.toString(), + body: { + text: post.text + } + }); + } + + // Append mentions data + if (mentions.length > 0) { + Post.update({ _id: post._id }, { + $set: { + mentions: mentions + } + }); + } + + //#endregion +}); diff --git a/src/server/api/endpoints/posts/favorites/create.ts b/src/server/api/endpoints/posts/favorites/create.ts new file mode 100644 index 0000000000..f9dee271b5 --- /dev/null +++ b/src/server/api/endpoints/posts/favorites/create.ts @@ -0,0 +1,48 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import Favorite from '../../../models/favorite'; +import Post from '../../../models/post'; + +/** + * Favorite a post + * + * @param {any} params + * @param {any} user + * @return {Promise<any>} + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + // Get 'post_id' parameter + const [postId, postIdErr] = $(params.post_id).id().$; + if (postIdErr) return rej('invalid post_id param'); + + // Get favoritee + const post = await Post.findOne({ + _id: postId + }); + + if (post === null) { + return rej('post not found'); + } + + // if already favorited + const exist = await Favorite.findOne({ + post_id: post._id, + user_id: user._id + }); + + if (exist !== null) { + return rej('already favorited'); + } + + // Create favorite + await Favorite.insert({ + created_at: new Date(), + post_id: post._id, + user_id: user._id + }); + + // Send response + res(); +}); diff --git a/src/server/api/endpoints/posts/favorites/delete.ts b/src/server/api/endpoints/posts/favorites/delete.ts new file mode 100644 index 0000000000..c4fe7d3234 --- /dev/null +++ b/src/server/api/endpoints/posts/favorites/delete.ts @@ -0,0 +1,46 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import Favorite from '../../../models/favorite'; +import Post from '../../../models/post'; + +/** + * Unfavorite a post + * + * @param {any} params + * @param {any} user + * @return {Promise<any>} + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + // Get 'post_id' parameter + const [postId, postIdErr] = $(params.post_id).id().$; + if (postIdErr) return rej('invalid post_id param'); + + // Get favoritee + const post = await Post.findOne({ + _id: postId + }); + + if (post === null) { + return rej('post not found'); + } + + // if already favorited + const exist = await Favorite.findOne({ + post_id: post._id, + user_id: user._id + }); + + if (exist === null) { + return rej('already not favorited'); + } + + // Delete favorite + await Favorite.deleteOne({ + _id: exist._id + }); + + // Send response + res(); +}); diff --git a/src/server/api/endpoints/posts/mentions.ts b/src/server/api/endpoints/posts/mentions.ts new file mode 100644 index 0000000000..7127db0ad1 --- /dev/null +++ b/src/server/api/endpoints/posts/mentions.ts @@ -0,0 +1,78 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import Post from '../../models/post'; +import getFriends from '../../common/get-friends'; +import { pack } from '../../models/post'; + +/** + * Get mentions of myself + * + * @param {any} params + * @param {any} user + * @return {Promise<any>} + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + // Get 'following' parameter + const [following = false, followingError] = + $(params.following).optional.boolean().$; + if (followingError) return rej('invalid following param'); + + // Get 'limit' parameter + const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$; + if (limitErr) return rej('invalid limit param'); + + // Get 'since_id' parameter + const [sinceId, sinceIdErr] = $(params.since_id).optional.id().$; + if (sinceIdErr) return rej('invalid since_id param'); + + // Get 'until_id' parameter + const [untilId, untilIdErr] = $(params.until_id).optional.id().$; + if (untilIdErr) return rej('invalid until_id param'); + + // Check if both of since_id and until_id is specified + if (sinceId && untilId) { + return rej('cannot set since_id and until_id'); + } + + // Construct query + const query = { + mentions: user._id + } as any; + + const sort = { + _id: -1 + }; + + if (following) { + const followingIds = await getFriends(user._id); + + query.user_id = { + $in: followingIds + }; + } + + if (sinceId) { + sort._id = 1; + query._id = { + $gt: sinceId + }; + } else if (untilId) { + query._id = { + $lt: untilId + }; + } + + // Issue query + const mentions = await Post + .find(query, { + limit: limit, + sort: sort + }); + + // Serialize + res(await Promise.all(mentions.map(async mention => + await pack(mention, user) + ))); +}); diff --git a/src/server/api/endpoints/posts/polls/recommendation.ts b/src/server/api/endpoints/posts/polls/recommendation.ts new file mode 100644 index 0000000000..4a3fa3f55e --- /dev/null +++ b/src/server/api/endpoints/posts/polls/recommendation.ts @@ -0,0 +1,59 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import Vote from '../../../models/poll-vote'; +import Post, { pack } from '../../../models/post'; + +/** + * Get recommended polls + * + * @param {any} params + * @param {any} user + * @return {Promise<any>} + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + // Get 'limit' parameter + const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$; + if (limitErr) return rej('invalid limit param'); + + // Get 'offset' parameter + const [offset = 0, offsetErr] = $(params.offset).optional.number().min(0).$; + if (offsetErr) return rej('invalid offset param'); + + // Get votes + const votes = await Vote.find({ + user_id: user._id + }, { + fields: { + _id: false, + post_id: true + } + }); + + const nin = votes && votes.length != 0 ? votes.map(v => v.post_id) : []; + + const posts = await Post + .find({ + _id: { + $nin: nin + }, + user_id: { + $ne: user._id + }, + poll: { + $exists: true, + $ne: null + } + }, { + limit: limit, + skip: offset, + sort: { + _id: -1 + } + }); + + // Serialize + res(await Promise.all(posts.map(async post => + await pack(post, user, { detail: true })))); +}); diff --git a/src/server/api/endpoints/posts/polls/vote.ts b/src/server/api/endpoints/posts/polls/vote.ts new file mode 100644 index 0000000000..16ce76a6fa --- /dev/null +++ b/src/server/api/endpoints/posts/polls/vote.ts @@ -0,0 +1,115 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import Vote from '../../../models/poll-vote'; +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'; + +/** + * Vote poll of a post + * + * @param {any} params + * @param {any} user + * @return {Promise<any>} + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + // Get 'post_id' parameter + const [postId, postIdErr] = $(params.post_id).id().$; + if (postIdErr) return rej('invalid post_id param'); + + // Get votee + const post = await Post.findOne({ + _id: postId + }); + + if (post === null) { + return rej('post not found'); + } + + if (post.poll == null) { + return rej('poll not found'); + } + + // Get 'choice' parameter + const [choice, choiceError] = + $(params.choice).number() + .pipe(c => post.poll.choices.some(x => x.id == c)) + .$; + if (choiceError) return rej('invalid choice param'); + + // if already voted + const exist = await Vote.findOne({ + post_id: post._id, + user_id: user._id + }); + + if (exist !== null) { + return rej('already voted'); + } + + // Create vote + await Vote.insert({ + created_at: new Date(), + post_id: post._id, + user_id: user._id, + choice: choice + }); + + // Send response + res(); + + const inc = {}; + inc[`poll.choices.${findWithAttr(post.poll.choices, 'id', choice)}.votes`] = 1; + + // Increment votes count + await Post.update({ _id: post._id }, { + $inc: inc + }); + + publishPostStream(post._id, 'poll_voted'); + + // Notify + notify(post.user_id, user._id, 'poll_vote', { + post_id: post._id, + choice: choice + }); + + // Fetch watchers + Watching + .find({ + post_id: post._id, + user_id: { $ne: user._id }, + // 削除されたドキュメントは除く + deleted_at: { $exists: false } + }, { + fields: { + user_id: true + } + }) + .then(watchers => { + watchers.forEach(watcher => { + notify(watcher.user_id, user._id, 'poll_vote', { + post_id: post._id, + choice: choice + }); + }); + }); + + // この投稿をWatchする + if (user.account.settings.auto_watch !== false) { + watch(user._id, post); + } +}); + +function findWithAttr(array, attr, value) { + for (let i = 0; i < array.length; i += 1) { + if (array[i][attr] === value) { + return i; + } + } + return -1; +} diff --git a/src/server/api/endpoints/posts/reactions.ts b/src/server/api/endpoints/posts/reactions.ts new file mode 100644 index 0000000000..feb140ab41 --- /dev/null +++ b/src/server/api/endpoints/posts/reactions.ts @@ -0,0 +1,57 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import Post from '../../models/post'; +import Reaction, { pack } from '../../models/post-reaction'; + +/** + * Show reactions of a post + * + * @param {any} params + * @param {any} user + * @return {Promise<any>} + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + // Get 'post_id' parameter + const [postId, postIdErr] = $(params.post_id).id().$; + if (postIdErr) return rej('invalid post_id param'); + + // Get 'limit' parameter + const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$; + if (limitErr) return rej('invalid limit param'); + + // Get 'offset' parameter + const [offset = 0, offsetErr] = $(params.offset).optional.number().min(0).$; + if (offsetErr) return rej('invalid offset param'); + + // Get 'sort' parameter + const [sort = 'desc', sortError] = $(params.sort).optional.string().or('desc asc').$; + if (sortError) return rej('invalid sort param'); + + // Lookup post + const post = await Post.findOne({ + _id: postId + }); + + if (post === null) { + return rej('post not found'); + } + + // Issue query + const reactions = await Reaction + .find({ + post_id: post._id, + deleted_at: { $exists: false } + }, { + limit: limit, + skip: offset, + sort: { + _id: sort == 'asc' ? 1 : -1 + } + }); + + // Serialize + res(await Promise.all(reactions.map(async reaction => + await pack(reaction, user)))); +}); diff --git a/src/server/api/endpoints/posts/reactions/create.ts b/src/server/api/endpoints/posts/reactions/create.ts new file mode 100644 index 0000000000..f77afed40c --- /dev/null +++ b/src/server/api/endpoints/posts/reactions/create.ts @@ -0,0 +1,122 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import Reaction from '../../../models/post-reaction'; +import Post, { pack as packPost } from '../../../models/post'; +import { pack as packUser } from '../../../models/user'; +import Watching from '../../../models/post-watching'; +import notify from '../../../common/notify'; +import watch from '../../../common/watch-post'; +import { publishPostStream, pushSw } from '../../../event'; + +/** + * React to a post + * + * @param {any} params + * @param {any} user + * @return {Promise<any>} + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + // Get 'post_id' parameter + const [postId, postIdErr] = $(params.post_id).id().$; + if (postIdErr) return rej('invalid post_id param'); + + // Get 'reaction' parameter + const [reaction, reactionErr] = $(params.reaction).string().or([ + 'like', + 'love', + 'laugh', + 'hmm', + 'surprise', + 'congrats', + 'angry', + 'confused', + 'pudding' + ]).$; + if (reactionErr) return rej('invalid reaction param'); + + // Fetch reactee + const post = await Post.findOne({ + _id: postId + }); + + if (post === null) { + return rej('post not found'); + } + + // Myself + if (post.user_id.equals(user._id)) { + return rej('cannot react to my post'); + } + + // if already reacted + const exist = await Reaction.findOne({ + post_id: post._id, + user_id: user._id, + deleted_at: { $exists: false } + }); + + if (exist !== null) { + return rej('already reacted'); + } + + // Create reaction + await Reaction.insert({ + created_at: new Date(), + post_id: post._id, + user_id: user._id, + reaction: reaction + }); + + // Send response + res(); + + const inc = {}; + inc[`reaction_counts.${reaction}`] = 1; + + // Increment reactions count + await Post.update({ _id: post._id }, { + $inc: inc + }); + + publishPostStream(post._id, 'reacted'); + + // Notify + notify(post.user_id, user._id, 'reaction', { + post_id: post._id, + reaction: reaction + }); + + pushSw(post.user_id, 'reaction', { + user: await packUser(user, post.user_id), + post: await packPost(post, post.user_id), + reaction: reaction + }); + + // Fetch watchers + Watching + .find({ + post_id: post._id, + user_id: { $ne: user._id }, + // 削除されたドキュメントは除く + deleted_at: { $exists: false } + }, { + fields: { + user_id: true + } + }) + .then(watchers => { + watchers.forEach(watcher => { + notify(watcher.user_id, user._id, 'reaction', { + post_id: post._id, + reaction: reaction + }); + }); + }); + + // この投稿をWatchする + if (user.account.settings.auto_watch !== false) { + watch(user._id, post); + } +}); diff --git a/src/server/api/endpoints/posts/reactions/delete.ts b/src/server/api/endpoints/posts/reactions/delete.ts new file mode 100644 index 0000000000..922c57ab18 --- /dev/null +++ b/src/server/api/endpoints/posts/reactions/delete.ts @@ -0,0 +1,60 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import Reaction from '../../../models/post-reaction'; +import Post from '../../../models/post'; +// import event from '../../../event'; + +/** + * Unreact to a post + * + * @param {any} params + * @param {any} user + * @return {Promise<any>} + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + // Get 'post_id' parameter + const [postId, postIdErr] = $(params.post_id).id().$; + if (postIdErr) return rej('invalid post_id param'); + + // Fetch unreactee + const post = await Post.findOne({ + _id: postId + }); + + if (post === null) { + return rej('post not found'); + } + + // if already unreacted + const exist = await Reaction.findOne({ + post_id: post._id, + user_id: user._id, + deleted_at: { $exists: false } + }); + + if (exist === null) { + return rej('never reacted'); + } + + // Delete reaction + await Reaction.update({ + _id: exist._id + }, { + $set: { + deleted_at: new Date() + } + }); + + // Send response + res(); + + const dec = {}; + dec[`reaction_counts.${exist.reaction}`] = -1; + + // Decrement reactions count + Post.update({ _id: post._id }, { + $inc: dec + }); +}); diff --git a/src/server/api/endpoints/posts/replies.ts b/src/server/api/endpoints/posts/replies.ts new file mode 100644 index 0000000000..613c4fa24c --- /dev/null +++ b/src/server/api/endpoints/posts/replies.ts @@ -0,0 +1,53 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import Post, { pack } from '../../models/post'; + +/** + * Show a replies of a post + * + * @param {any} params + * @param {any} user + * @return {Promise<any>} + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + // Get 'post_id' parameter + const [postId, postIdErr] = $(params.post_id).id().$; + if (postIdErr) return rej('invalid post_id param'); + + // Get 'limit' parameter + const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$; + if (limitErr) return rej('invalid limit param'); + + // Get 'offset' parameter + const [offset = 0, offsetErr] = $(params.offset).optional.number().min(0).$; + if (offsetErr) return rej('invalid offset param'); + + // Get 'sort' parameter + const [sort = 'desc', sortError] = $(params.sort).optional.string().or('desc asc').$; + if (sortError) return rej('invalid sort param'); + + // Lookup post + const post = await Post.findOne({ + _id: postId + }); + + if (post === null) { + return rej('post not found'); + } + + // Issue query + const replies = await Post + .find({ reply_id: post._id }, { + limit: limit, + skip: offset, + sort: { + _id: sort == 'asc' ? 1 : -1 + } + }); + + // Serialize + res(await Promise.all(replies.map(async post => + await pack(post, user)))); +}); diff --git a/src/server/api/endpoints/posts/reposts.ts b/src/server/api/endpoints/posts/reposts.ts new file mode 100644 index 0000000000..89ab0e3d55 --- /dev/null +++ b/src/server/api/endpoints/posts/reposts.ts @@ -0,0 +1,73 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import Post, { pack } from '../../models/post'; + +/** + * Show a reposts of a post + * + * @param {any} params + * @param {any} user + * @return {Promise<any>} + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + // Get 'post_id' parameter + const [postId, postIdErr] = $(params.post_id).id().$; + if (postIdErr) return rej('invalid post_id param'); + + // Get 'limit' parameter + const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$; + if (limitErr) return rej('invalid limit param'); + + // Get 'since_id' parameter + const [sinceId, sinceIdErr] = $(params.since_id).optional.id().$; + if (sinceIdErr) return rej('invalid since_id param'); + + // Get 'until_id' parameter + const [untilId, untilIdErr] = $(params.until_id).optional.id().$; + if (untilIdErr) return rej('invalid until_id param'); + + // Check if both of since_id and until_id is specified + if (sinceId && untilId) { + return rej('cannot set since_id and until_id'); + } + + // Lookup post + const post = await Post.findOne({ + _id: postId + }); + + if (post === null) { + return rej('post not found'); + } + + // Construct query + const sort = { + _id: -1 + }; + const query = { + repost_id: post._id + } as any; + if (sinceId) { + sort._id = 1; + query._id = { + $gt: sinceId + }; + } else if (untilId) { + query._id = { + $lt: untilId + }; + } + + // Issue query + const reposts = await Post + .find(query, { + limit: limit, + sort: sort + }); + + // Serialize + res(await Promise.all(reposts.map(async post => + await pack(post, user)))); +}); diff --git a/src/server/api/endpoints/posts/search.ts b/src/server/api/endpoints/posts/search.ts new file mode 100644 index 0000000000..a36d1178a5 --- /dev/null +++ b/src/server/api/endpoints/posts/search.ts @@ -0,0 +1,364 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +const escapeRegexp = require('escape-regexp'); +import Post from '../../models/post'; +import User from '../../models/user'; +import Mute from '../../models/mute'; +import getFriends from '../../common/get-friends'; +import { pack } from '../../models/post'; + +/** + * Search a post + * + * @param {any} params + * @param {any} me + * @return {Promise<any>} + */ +module.exports = (params, me) => new Promise(async (res, rej) => { + // Get 'text' parameter + const [text, textError] = $(params.text).optional.string().$; + if (textError) return rej('invalid text param'); + + // Get 'include_user_ids' parameter + const [includeUserIds = [], includeUserIdsErr] = $(params.include_user_ids).optional.array('id').$; + if (includeUserIdsErr) return rej('invalid include_user_ids param'); + + // Get 'exclude_user_ids' parameter + const [excludeUserIds = [], excludeUserIdsErr] = $(params.exclude_user_ids).optional.array('id').$; + if (excludeUserIdsErr) return rej('invalid exclude_user_ids param'); + + // Get 'include_user_usernames' parameter + const [includeUserUsernames = [], includeUserUsernamesErr] = $(params.include_user_usernames).optional.array('string').$; + if (includeUserUsernamesErr) return rej('invalid include_user_usernames param'); + + // Get 'exclude_user_usernames' parameter + const [excludeUserUsernames = [], excludeUserUsernamesErr] = $(params.exclude_user_usernames).optional.array('string').$; + if (excludeUserUsernamesErr) return rej('invalid exclude_user_usernames param'); + + // Get 'following' parameter + const [following = null, followingErr] = $(params.following).optional.nullable.boolean().$; + if (followingErr) return rej('invalid following param'); + + // Get 'mute' parameter + const [mute = 'mute_all', muteErr] = $(params.mute).optional.string().$; + if (muteErr) return rej('invalid mute param'); + + // Get 'reply' parameter + const [reply = null, replyErr] = $(params.reply).optional.nullable.boolean().$; + if (replyErr) return rej('invalid reply param'); + + // Get 'repost' parameter + const [repost = null, repostErr] = $(params.repost).optional.nullable.boolean().$; + if (repostErr) return rej('invalid repost param'); + + // Get 'media' parameter + const [media = null, mediaErr] = $(params.media).optional.nullable.boolean().$; + if (mediaErr) return rej('invalid media param'); + + // Get 'poll' parameter + const [poll = null, pollErr] = $(params.poll).optional.nullable.boolean().$; + if (pollErr) return rej('invalid poll param'); + + // Get 'since_date' parameter + const [sinceDate, sinceDateErr] = $(params.since_date).optional.number().$; + if (sinceDateErr) throw 'invalid since_date param'; + + // Get 'until_date' parameter + const [untilDate, untilDateErr] = $(params.until_date).optional.number().$; + if (untilDateErr) throw 'invalid until_date param'; + + // Get 'offset' parameter + const [offset = 0, offsetErr] = $(params.offset).optional.number().min(0).$; + if (offsetErr) return rej('invalid offset param'); + + // Get 'limit' parameter + const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 30).$; + if (limitErr) return rej('invalid limit param'); + + let includeUsers = includeUserIds; + if (includeUserUsernames != null) { + const ids = (await Promise.all(includeUserUsernames.map(async (username) => { + const _user = await User.findOne({ + username_lower: username.toLowerCase() + }); + return _user ? _user._id : null; + }))).filter(id => id != null); + includeUsers = includeUsers.concat(ids); + } + + let excludeUsers = excludeUserIds; + if (excludeUserUsernames != null) { + const ids = (await Promise.all(excludeUserUsernames.map(async (username) => { + const _user = await User.findOne({ + username_lower: username.toLowerCase() + }); + return _user ? _user._id : null; + }))).filter(id => id != null); + excludeUsers = excludeUsers.concat(ids); + } + + search(res, rej, me, text, includeUsers, excludeUsers, following, + mute, reply, repost, media, poll, sinceDate, untilDate, offset, limit); +}); + +async function search( + res, rej, me, text, includeUserIds, excludeUserIds, following, + mute, reply, repost, media, poll, sinceDate, untilDate, offset, max) { + + let q: any = { + $and: [] + }; + + const push = x => q.$and.push(x); + + if (text) { + // 完全一致検索 + if (/"""(.+?)"""/.test(text)) { + const x = text.match(/"""(.+?)"""/)[1]; + push({ + text: x + }); + } else { + const tags = text.split(' ').filter(x => x[0] == '#'); + if (tags) { + push({ + $and: tags.map(x => ({ + tags: x + })) + }); + } + + push({ + $and: text.split(' ').map(x => ({ + // キーワードが-で始まる場合そのキーワードを除外する + text: x[0] == '-' ? { + $not: new RegExp(escapeRegexp(x.substr(1))) + } : new RegExp(escapeRegexp(x)) + })) + }); + } + } + + if (includeUserIds && includeUserIds.length != 0) { + push({ + user_id: { + $in: includeUserIds + } + }); + } else if (excludeUserIds && excludeUserIds.length != 0) { + push({ + user_id: { + $nin: excludeUserIds + } + }); + } + + if (following != null && me != null) { + const ids = await getFriends(me._id, false); + push({ + user_id: following ? { + $in: ids + } : { + $nin: ids.concat(me._id) + } + }); + } + + if (me != null) { + const mutes = await Mute.find({ + muter_id: me._id, + deleted_at: { $exists: false } + }); + const mutedUserIds = mutes.map(m => m.mutee_id); + + switch (mute) { + case 'mute_all': + push({ + user_id: { + $nin: mutedUserIds + }, + '_reply.user_id': { + $nin: mutedUserIds + }, + '_repost.user_id': { + $nin: mutedUserIds + } + }); + break; + case 'mute_related': + push({ + '_reply.user_id': { + $nin: mutedUserIds + }, + '_repost.user_id': { + $nin: mutedUserIds + } + }); + break; + case 'mute_direct': + push({ + user_id: { + $nin: mutedUserIds + } + }); + break; + case 'direct_only': + push({ + user_id: { + $in: mutedUserIds + } + }); + break; + case 'related_only': + push({ + $or: [{ + '_reply.user_id': { + $in: mutedUserIds + } + }, { + '_repost.user_id': { + $in: mutedUserIds + } + }] + }); + break; + case 'all_only': + push({ + $or: [{ + user_id: { + $in: mutedUserIds + } + }, { + '_reply.user_id': { + $in: mutedUserIds + } + }, { + '_repost.user_id': { + $in: mutedUserIds + } + }] + }); + break; + } + } + + if (reply != null) { + if (reply) { + push({ + reply_id: { + $exists: true, + $ne: null + } + }); + } else { + push({ + $or: [{ + reply_id: { + $exists: false + } + }, { + reply_id: null + }] + }); + } + } + + if (repost != null) { + if (repost) { + push({ + repost_id: { + $exists: true, + $ne: null + } + }); + } else { + push({ + $or: [{ + repost_id: { + $exists: false + } + }, { + repost_id: null + }] + }); + } + } + + if (media != null) { + if (media) { + push({ + media_ids: { + $exists: true, + $ne: null + } + }); + } else { + push({ + $or: [{ + media_ids: { + $exists: false + } + }, { + media_ids: null + }] + }); + } + } + + if (poll != null) { + if (poll) { + push({ + poll: { + $exists: true, + $ne: null + } + }); + } else { + push({ + $or: [{ + poll: { + $exists: false + } + }, { + poll: null + }] + }); + } + } + + if (sinceDate) { + push({ + created_at: { + $gt: new Date(sinceDate) + } + }); + } + + if (untilDate) { + push({ + created_at: { + $lt: new Date(untilDate) + } + }); + } + + if (q.$and.length == 0) { + q = {}; + } + + // Search posts + const posts = await Post + .find(q, { + sort: { + _id: -1 + }, + limit: max, + skip: offset + }); + + // Serialize + res(await Promise.all(posts.map(async post => + await pack(post, me)))); +} diff --git a/src/server/api/endpoints/posts/show.ts b/src/server/api/endpoints/posts/show.ts new file mode 100644 index 0000000000..3839490597 --- /dev/null +++ b/src/server/api/endpoints/posts/show.ts @@ -0,0 +1,32 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import Post, { pack } from '../../models/post'; + +/** + * Show a post + * + * @param {any} params + * @param {any} user + * @return {Promise<any>} + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + // Get 'post_id' parameter + const [postId, postIdErr] = $(params.post_id).id().$; + if (postIdErr) return rej('invalid post_id param'); + + // Get post + const post = await Post.findOne({ + _id: postId + }); + + if (post === null) { + return rej('post not found'); + } + + // Serialize + res(await pack(post, user, { + detail: true + })); +}); diff --git a/src/server/api/endpoints/posts/timeline.ts b/src/server/api/endpoints/posts/timeline.ts new file mode 100644 index 0000000000..c41cfdb8bd --- /dev/null +++ b/src/server/api/endpoints/posts/timeline.ts @@ -0,0 +1,132 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import rap from '@prezzemolo/rap'; +import Post from '../../models/post'; +import Mute from '../../models/mute'; +import ChannelWatching from '../../models/channel-watching'; +import getFriends from '../../common/get-friends'; +import { pack } from '../../models/post'; + +/** + * Get timeline of myself + * + * @param {any} params + * @param {any} user + * @param {any} app + * @return {Promise<any>} + */ +module.exports = async (params, user, app) => { + // Get 'limit' parameter + const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$; + if (limitErr) throw 'invalid limit param'; + + // Get 'since_id' parameter + const [sinceId, sinceIdErr] = $(params.since_id).optional.id().$; + if (sinceIdErr) throw 'invalid since_id param'; + + // Get 'until_id' parameter + const [untilId, untilIdErr] = $(params.until_id).optional.id().$; + if (untilIdErr) throw 'invalid until_id param'; + + // Get 'since_date' parameter + const [sinceDate, sinceDateErr] = $(params.since_date).optional.number().$; + if (sinceDateErr) throw 'invalid since_date param'; + + // Get 'until_date' parameter + const [untilDate, untilDateErr] = $(params.until_date).optional.number().$; + if (untilDateErr) throw 'invalid until_date param'; + + // Check if only one of since_id, until_id, since_date, until_date specified + if ([sinceId, untilId, sinceDate, untilDate].filter(x => x != null).length > 1) { + throw 'only one of since_id, until_id, since_date, until_date can be specified'; + } + + const { followingIds, watchingChannelIds, mutedUserIds } = await rap({ + // ID list of the user itself and other users who the user follows + followingIds: getFriends(user._id), + + // Watchしているチャンネルを取得 + watchingChannelIds: ChannelWatching.find({ + user_id: user._id, + // 削除されたドキュメントは除く + deleted_at: { $exists: false } + }).then(watches => watches.map(w => w.channel_id)), + + // ミュートしているユーザーを取得 + mutedUserIds: Mute.find({ + muter_id: user._id, + // 削除されたドキュメントは除く + deleted_at: { $exists: false } + }).then(ms => ms.map(m => m.mutee_id)) + }); + + //#region Construct query + const sort = { + _id: -1 + }; + + const query = { + $or: [{ + // フォローしている人のタイムラインへの投稿 + user_id: { + $in: followingIds + }, + // 「タイムラインへの」投稿に限定するためにチャンネルが指定されていないもののみに限る + $or: [{ + channel_id: { + $exists: false + } + }, { + channel_id: null + }] + }, { + // Watchしているチャンネルへの投稿 + channel_id: { + $in: watchingChannelIds + } + }], + // mute + user_id: { + $nin: mutedUserIds + }, + '_reply.user_id': { + $nin: mutedUserIds + }, + '_repost.user_id': { + $nin: mutedUserIds + }, + } as any; + + if (sinceId) { + sort._id = 1; + query._id = { + $gt: sinceId + }; + } else if (untilId) { + query._id = { + $lt: untilId + }; + } else if (sinceDate) { + sort._id = 1; + query.created_at = { + $gt: new Date(sinceDate) + }; + } else if (untilDate) { + query.created_at = { + $lt: new Date(untilDate) + }; + } + //#endregion + + // Issue query + const timeline = await Post + .find(query, { + limit: limit, + sort: sort + }); + + // Serialize + return await Promise.all(timeline.map(post => pack(post, user))); +}; diff --git a/src/server/api/endpoints/posts/trend.ts b/src/server/api/endpoints/posts/trend.ts new file mode 100644 index 0000000000..caded92bf5 --- /dev/null +++ b/src/server/api/endpoints/posts/trend.ts @@ -0,0 +1,79 @@ +/** + * Module dependencies + */ +const ms = require('ms'); +import $ from 'cafy'; +import Post, { pack } from '../../models/post'; + +/** + * Get trend posts + * + * @param {any} params + * @param {any} user + * @return {Promise<any>} + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + // Get 'limit' parameter + const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$; + if (limitErr) return rej('invalid limit param'); + + // Get 'offset' parameter + const [offset = 0, offsetErr] = $(params.offset).optional.number().min(0).$; + if (offsetErr) return rej('invalid offset param'); + + // Get 'reply' parameter + const [reply, replyErr] = $(params.reply).optional.boolean().$; + if (replyErr) return rej('invalid reply param'); + + // Get 'repost' parameter + const [repost, repostErr] = $(params.repost).optional.boolean().$; + if (repostErr) return rej('invalid repost param'); + + // Get 'media' parameter + const [media, mediaErr] = $(params.media).optional.boolean().$; + if (mediaErr) return rej('invalid media param'); + + // Get 'poll' parameter + const [poll, pollErr] = $(params.poll).optional.boolean().$; + if (pollErr) return rej('invalid poll param'); + + const query = { + created_at: { + $gte: new Date(Date.now() - ms('1days')) + }, + repost_count: { + $gt: 0 + } + } as any; + + if (reply != undefined) { + query.reply_id = reply ? { $exists: true, $ne: null } : null; + } + + if (repost != undefined) { + query.repost_id = repost ? { $exists: true, $ne: null } : null; + } + + if (media != undefined) { + query.media_ids = media ? { $exists: true, $ne: null } : null; + } + + if (poll != undefined) { + query.poll = poll ? { $exists: true, $ne: null } : null; + } + + // Issue query + const posts = await Post + .find(query, { + limit: limit, + skip: offset, + sort: { + repost_count: -1, + _id: -1 + } + }); + + // Serialize + res(await Promise.all(posts.map(async post => + await pack(post, user, { detail: true })))); +}); diff --git a/src/server/api/endpoints/stats.ts b/src/server/api/endpoints/stats.ts new file mode 100644 index 0000000000..a6084cd17a --- /dev/null +++ b/src/server/api/endpoints/stats.ts @@ -0,0 +1,48 @@ +/** + * Module dependencies + */ +import Post from '../models/post'; +import User from '../models/user'; + +/** + * @swagger + * /stats: + * post: + * summary: Show the misskey's statistics + * responses: + * 200: + * description: Success + * schema: + * type: object + * properties: + * posts_count: + * description: count of all posts of misskey + * type: number + * users_count: + * description: count of all users of misskey + * type: number + * + * default: + * description: Failed + * schema: + * $ref: "#/definitions/Error" + */ + +/** + * Show the misskey's statistics + * + * @param {any} params + * @return {Promise<any>} + */ +module.exports = params => new Promise(async (res, rej) => { + const postsCount = await Post + .count(); + + const usersCount = await User + .count(); + + res({ + posts_count: postsCount, + users_count: usersCount + }); +}); diff --git a/src/server/api/endpoints/sw/register.ts b/src/server/api/endpoints/sw/register.ts new file mode 100644 index 0000000000..99406138db --- /dev/null +++ b/src/server/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/server/api/endpoints/username/available.ts b/src/server/api/endpoints/username/available.ts new file mode 100644 index 0000000000..aac7fadf5a --- /dev/null +++ b/src/server/api/endpoints/username/available.ts @@ -0,0 +1,32 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import User from '../../models/user'; +import { validateUsername } from '../../models/user'; + +/** + * Check available username + * + * @param {any} params + * @return {Promise<any>} + */ +module.exports = async (params) => new Promise(async (res, rej) => { + // Get 'username' parameter + const [username, usernameError] = $(params.username).string().pipe(validateUsername).$; + if (usernameError) return rej('invalid username param'); + + // Get exist + const exist = await User + .count({ + host: null, + username_lower: username.toLowerCase() + }, { + limit: 1 + }); + + // Reply + res({ + available: exist === 0 + }); +}); diff --git a/src/server/api/endpoints/users.ts b/src/server/api/endpoints/users.ts new file mode 100644 index 0000000000..4acc13c281 --- /dev/null +++ b/src/server/api/endpoints/users.ts @@ -0,0 +1,56 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import User, { pack } from '../models/user'; + +/** + * Lists all users + * + * @param {any} params + * @param {any} me + * @return {Promise<any>} + */ +module.exports = (params, me) => new Promise(async (res, rej) => { + // Get 'limit' parameter + const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$; + if (limitErr) return rej('invalid limit param'); + + // Get 'offset' parameter + const [offset = 0, offsetErr] = $(params.offset).optional.number().min(0).$; + if (offsetErr) return rej('invalid offset param'); + + // Get 'sort' parameter + const [sort, sortError] = $(params.sort).optional.string().or('+follower|-follower').$; + if (sortError) return rej('invalid sort param'); + + // Construct query + let _sort; + if (sort) { + if (sort == '+follower') { + _sort = { + followers_count: -1 + }; + } else if (sort == '-follower') { + _sort = { + followers_count: 1 + }; + } + } else { + _sort = { + _id: -1 + }; + } + + // Issue query + const users = await User + .find({}, { + limit: limit, + sort: _sort, + skip: offset + }); + + // Serialize + res(await Promise.all(users.map(async user => + await pack(user, me)))); +}); diff --git a/src/server/api/endpoints/users/followers.ts b/src/server/api/endpoints/users/followers.ts new file mode 100644 index 0000000000..b0fb83c683 --- /dev/null +++ b/src/server/api/endpoints/users/followers.ts @@ -0,0 +1,92 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import User from '../../models/user'; +import Following from '../../models/following'; +import { pack } from '../../models/user'; +import getFriends from '../../common/get-friends'; + +/** + * Get followers of a user + * + * @param {any} params + * @param {any} me + * @return {Promise<any>} + */ +module.exports = (params, me) => new Promise(async (res, rej) => { + // Get 'user_id' parameter + const [userId, userIdErr] = $(params.user_id).id().$; + if (userIdErr) return rej('invalid user_id param'); + + // Get 'iknow' parameter + const [iknow = false, iknowErr] = $(params.iknow).optional.boolean().$; + if (iknowErr) return rej('invalid iknow param'); + + // Get 'limit' parameter + const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$; + if (limitErr) return rej('invalid limit param'); + + // Get 'cursor' parameter + const [cursor = null, cursorErr] = $(params.cursor).optional.id().$; + if (cursorErr) return rej('invalid cursor param'); + + // Lookup user + const user = await User.findOne({ + _id: userId + }, { + fields: { + _id: true + } + }); + + if (user === null) { + return rej('user not found'); + } + + // Construct query + const query = { + followee_id: user._id, + deleted_at: { $exists: false } + } as any; + + // ログインしていてかつ iknow フラグがあるとき + if (me && iknow) { + // Get my friends + const myFriends = await getFriends(me._id); + + query.follower_id = { + $in: myFriends + }; + } + + // カーソルが指定されている場合 + if (cursor) { + query._id = { + $lt: cursor + }; + } + + // Get followers + const following = await Following + .find(query, { + limit: limit + 1, + sort: { _id: -1 } + }); + + // 「次のページ」があるかどうか + const inStock = following.length === limit + 1; + if (inStock) { + following.pop(); + } + + // Serialize + const users = await Promise.all(following.map(async f => + await pack(f.follower_id, me, { detail: true }))); + + // Response + res({ + users: users, + next: inStock ? following[following.length - 1]._id : null, + }); +}); diff --git a/src/server/api/endpoints/users/following.ts b/src/server/api/endpoints/users/following.ts new file mode 100644 index 0000000000..8e88431e92 --- /dev/null +++ b/src/server/api/endpoints/users/following.ts @@ -0,0 +1,92 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import User from '../../models/user'; +import Following from '../../models/following'; +import { pack } from '../../models/user'; +import getFriends from '../../common/get-friends'; + +/** + * Get following users of a user + * + * @param {any} params + * @param {any} me + * @return {Promise<any>} + */ +module.exports = (params, me) => new Promise(async (res, rej) => { + // Get 'user_id' parameter + const [userId, userIdErr] = $(params.user_id).id().$; + if (userIdErr) return rej('invalid user_id param'); + + // Get 'iknow' parameter + const [iknow = false, iknowErr] = $(params.iknow).optional.boolean().$; + if (iknowErr) return rej('invalid iknow param'); + + // Get 'limit' parameter + const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$; + if (limitErr) return rej('invalid limit param'); + + // Get 'cursor' parameter + const [cursor = null, cursorErr] = $(params.cursor).optional.id().$; + if (cursorErr) return rej('invalid cursor param'); + + // Lookup user + const user = await User.findOne({ + _id: userId + }, { + fields: { + _id: true + } + }); + + if (user === null) { + return rej('user not found'); + } + + // Construct query + const query = { + follower_id: user._id, + deleted_at: { $exists: false } + } as any; + + // ログインしていてかつ iknow フラグがあるとき + if (me && iknow) { + // Get my friends + const myFriends = await getFriends(me._id); + + query.followee_id = { + $in: myFriends + }; + } + + // カーソルが指定されている場合 + if (cursor) { + query._id = { + $lt: cursor + }; + } + + // Get followers + const following = await Following + .find(query, { + limit: limit + 1, + sort: { _id: -1 } + }); + + // 「次のページ」があるかどうか + const inStock = following.length === limit + 1; + if (inStock) { + following.pop(); + } + + // Serialize + const users = await Promise.all(following.map(async f => + await pack(f.followee_id, me, { detail: true }))); + + // Response + res({ + users: users, + next: inStock ? following[following.length - 1]._id : null, + }); +}); diff --git a/src/server/api/endpoints/users/get_frequently_replied_users.ts b/src/server/api/endpoints/users/get_frequently_replied_users.ts new file mode 100644 index 0000000000..87f4f77a5b --- /dev/null +++ b/src/server/api/endpoints/users/get_frequently_replied_users.ts @@ -0,0 +1,99 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import Post from '../../models/post'; +import User, { pack } from '../../models/user'; + +module.exports = (params, me) => new Promise(async (res, rej) => { + // Get 'user_id' parameter + 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 + }, { + fields: { + _id: true + } + }); + + if (user === null) { + return rej('user not found'); + } + + // Fetch recent posts + const recentPosts = await Post.find({ + user_id: user._id, + reply_id: { + $exists: true, + $ne: null + } + }, { + sort: { + _id: -1 + }, + limit: 1000, + fields: { + _id: false, + reply_id: true + } + }); + + // 投稿が少なかったら中断 + if (recentPosts.length === 0) { + return res([]); + } + + const replyTargetPosts = await Post.find({ + _id: { + $in: recentPosts.map(p => p.reply_id) + }, + user_id: { + $ne: user._id + } + }, { + fields: { + _id: false, + user_id: true + } + }); + + const repliedUsers = {}; + + // Extract replies from recent posts + replyTargetPosts.forEach(post => { + const userId = post.user_id.toString(); + if (repliedUsers[userId]) { + repliedUsers[userId]++; + } else { + repliedUsers[userId] = 1; + } + }); + + // Calc peak + let peak = 0; + Object.keys(repliedUsers).forEach(user => { + if (repliedUsers[user] > peak) peak = repliedUsers[user]; + }); + + // Sort replies by frequency + const repliedUsersSorted = Object.keys(repliedUsers).sort((a, b) => repliedUsers[b] - repliedUsers[a]); + + // Extract top replied users + const topRepliedUsers = repliedUsersSorted.slice(0, limit); + + // Make replies object (includes weights) + const repliesObj = await Promise.all(topRepliedUsers.map(async (user) => ({ + user: await pack(user, me, { detail: true }), + weight: repliedUsers[user] / peak + }))); + + // Response + res(repliesObj); +}); diff --git a/src/server/api/endpoints/users/posts.ts b/src/server/api/endpoints/users/posts.ts new file mode 100644 index 0000000000..3c84bf0d80 --- /dev/null +++ b/src/server/api/endpoints/users/posts.ts @@ -0,0 +1,137 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import getHostLower from '../../common/get-host-lower'; +import Post, { pack } from '../../models/post'; +import User from '../../models/user'; + +/** + * Get posts of a user + * + * @param {any} params + * @param {any} me + * @return {Promise<any>} + */ +module.exports = (params, me) => new Promise(async (res, rej) => { + // Get 'user_id' parameter + const [userId, userIdErr] = $(params.user_id).optional.id().$; + if (userIdErr) return rej('invalid user_id param'); + + // Get 'username' parameter + const [username, usernameErr] = $(params.username).optional.string().$; + if (usernameErr) return rej('invalid username param'); + + if (userId === undefined && username === undefined) { + return rej('user_id or pair of username and host is required'); + } + + // Get 'host' parameter + const [host, hostErr] = $(params.host).optional.string().$; + if (hostErr) return rej('invalid host param'); + + if (userId === undefined && host === undefined) { + return rej('user_id or pair of username and host is required'); + } + + // Get 'include_replies' parameter + const [includeReplies = true, includeRepliesErr] = $(params.include_replies).optional.boolean().$; + if (includeRepliesErr) return rej('invalid include_replies param'); + + // Get 'with_media' parameter + const [withMedia = false, withMediaErr] = $(params.with_media).optional.boolean().$; + if (withMediaErr) return rej('invalid with_media param'); + + // Get 'limit' parameter + const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$; + if (limitErr) return rej('invalid limit param'); + + // Get 'since_id' parameter + const [sinceId, sinceIdErr] = $(params.since_id).optional.id().$; + if (sinceIdErr) return rej('invalid since_id param'); + + // Get 'until_id' parameter + const [untilId, untilIdErr] = $(params.until_id).optional.id().$; + if (untilIdErr) return rej('invalid until_id param'); + + // Get 'since_date' parameter + const [sinceDate, sinceDateErr] = $(params.since_date).optional.number().$; + if (sinceDateErr) throw 'invalid since_date param'; + + // Get 'until_date' parameter + const [untilDate, untilDateErr] = $(params.until_date).optional.number().$; + if (untilDateErr) throw 'invalid until_date param'; + + // Check if only one of since_id, until_id, since_date, until_date specified + if ([sinceId, untilId, sinceDate, untilDate].filter(x => x != null).length > 1) { + throw 'only one of since_id, until_id, since_date, until_date can be specified'; + } + + const q = userId !== undefined + ? { _id: userId } + : { username_lower: username.toLowerCase(), host_lower: getHostLower(host) } ; + + // Lookup user + const user = await User.findOne(q, { + fields: { + _id: true + } + }); + + if (user === null) { + return rej('user not found'); + } + + //#region Construct query + const sort = { + _id: -1 + }; + + const query = { + user_id: user._id + } as any; + + if (sinceId) { + sort._id = 1; + query._id = { + $gt: sinceId + }; + } else if (untilId) { + query._id = { + $lt: untilId + }; + } else if (sinceDate) { + sort._id = 1; + query.created_at = { + $gt: new Date(sinceDate) + }; + } else if (untilDate) { + query.created_at = { + $lt: new Date(untilDate) + }; + } + + if (!includeReplies) { + query.reply_id = null; + } + + if (withMedia) { + query.media_ids = { + $exists: true, + $ne: null + }; + } + //#endregion + + // Issue query + const posts = await Post + .find(query, { + limit: limit, + sort: sort + }); + + // Serialize + res(await Promise.all(posts.map(async (post) => + await pack(post, me) + ))); +}); diff --git a/src/server/api/endpoints/users/recommendation.ts b/src/server/api/endpoints/users/recommendation.ts new file mode 100644 index 0000000000..45d90f422b --- /dev/null +++ b/src/server/api/endpoints/users/recommendation.ts @@ -0,0 +1,53 @@ +/** + * Module dependencies + */ +const ms = require('ms'); +import $ from 'cafy'; +import User, { pack } from '../../models/user'; +import getFriends from '../../common/get-friends'; + +/** + * Get recommended users + * + * @param {any} params + * @param {any} me + * @return {Promise<any>} + */ +module.exports = (params, me) => new Promise(async (res, rej) => { + // Get 'limit' parameter + const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$; + if (limitErr) return rej('invalid limit param'); + + // Get 'offset' parameter + const [offset = 0, offsetErr] = $(params.offset).optional.number().min(0).$; + if (offsetErr) return rej('invalid offset param'); + + // ID list of the user itself and other users who the user follows + const followingIds = await getFriends(me._id); + + const users = await User + .find({ + _id: { + $nin: followingIds + }, + $or: [ + { + 'account.last_used_at': { + $gte: new Date(Date.now() - ms('7days')) + } + }, { + host: { $not: null } + } + ] + }, { + limit: limit, + skip: offset, + sort: { + followers_count: -1 + } + }); + + // Serialize + res(await Promise.all(users.map(async user => + await pack(user, me, { detail: true })))); +}); diff --git a/src/server/api/endpoints/users/search.ts b/src/server/api/endpoints/users/search.ts new file mode 100644 index 0000000000..3c81576440 --- /dev/null +++ b/src/server/api/endpoints/users/search.ts @@ -0,0 +1,98 @@ +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import $ from 'cafy'; +import User, { pack } from '../../models/user'; +import config from '../../../../conf'; +const escapeRegexp = require('escape-regexp'); + +/** + * Search a user + * + * @param {any} params + * @param {any} me + * @return {Promise<any>} + */ +module.exports = (params, me) => new Promise(async (res, rej) => { + // Get 'query' parameter + const [query, queryError] = $(params.query).string().pipe(x => x != '').$; + if (queryError) return rej('invalid query param'); + + // Get 'offset' parameter + const [offset = 0, offsetErr] = $(params.offset).optional.number().min(0).$; + if (offsetErr) return rej('invalid offset param'); + + // Get 'max' parameter + const [max = 10, maxErr] = $(params.max).optional.number().range(1, 30).$; + if (maxErr) return rej('invalid max param'); + + // If Elasticsearch is available, search by $ + // If not, search by MongoDB + (config.elasticsearch.enable ? byElasticsearch : byNative) + (res, rej, me, query, offset, max); +}); + +// Search by MongoDB +async function byNative(res, rej, me, query, offset, max) { + const escapedQuery = escapeRegexp(query); + + // Search users + const users = await User + .find({ + $or: [{ + username_lower: new RegExp(escapedQuery.replace('@', '').toLowerCase()) + }, { + name: new RegExp(escapedQuery) + }] + }, { + limit: max + }); + + // Serialize + res(await Promise.all(users.map(async user => + await pack(user, me, { detail: true })))); +} + +// Search by Elasticsearch +async function byElasticsearch(res, rej, me, query, offset, max) { + const es = require('../../db/elasticsearch'); + + es.search({ + index: 'misskey', + type: 'user', + body: { + size: max, + from: offset, + query: { + simple_query_string: { + fields: ['username', 'name', 'bio'], + query: query, + default_operator: 'and' + } + } + } + }, async (error, response) => { + if (error) { + console.error(error); + return res(500); + } + + if (response.hits.total === 0) { + return res([]); + } + + const hits = response.hits.hits.map(hit => new mongo.ObjectID(hit._id)); + + const users = await User + .find({ + _id: { + $in: hits + } + }); + + // Serialize + res(await Promise.all(users.map(async user => + await pack(user, me, { detail: true })))); + }); +} diff --git a/src/server/api/endpoints/users/search_by_username.ts b/src/server/api/endpoints/users/search_by_username.ts new file mode 100644 index 0000000000..9c5e1905aa --- /dev/null +++ b/src/server/api/endpoints/users/search_by_username.ts @@ -0,0 +1,38 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import User, { pack } from '../../models/user'; + +/** + * Search a user by username + * + * @param {any} params + * @param {any} me + * @return {Promise<any>} + */ +module.exports = (params, me) => new Promise(async (res, rej) => { + // Get 'query' parameter + const [query, queryError] = $(params.query).string().$; + if (queryError) return rej('invalid query param'); + + // Get 'offset' parameter + const [offset = 0, offsetErr] = $(params.offset).optional.number().min(0).$; + if (offsetErr) return rej('invalid offset param'); + + // Get 'limit' parameter + const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$; + if (limitErr) return rej('invalid limit param'); + + const users = await User + .find({ + username_lower: new RegExp(query.toLowerCase()) + }, { + limit: limit, + skip: offset + }); + + // Serialize + res(await Promise.all(users.map(async user => + await pack(user, me, { detail: true })))); +}); diff --git a/src/server/api/endpoints/users/show.ts b/src/server/api/endpoints/users/show.ts new file mode 100644 index 0000000000..78df23f339 --- /dev/null +++ b/src/server/api/endpoints/users/show.ts @@ -0,0 +1,209 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import { JSDOM } from 'jsdom'; +import { toUnicode, toASCII } from 'punycode'; +import uploadFromUrl from '../../common/drive/upload_from_url'; +import User, { pack, validateUsername, isValidName, isValidDescription } from '../../models/user'; +const request = require('request-promise-native'); +const WebFinger = require('webfinger.js'); + +const webFinger = new WebFinger({}); + +async function getCollectionCount(url) { + if (!url) { + return null; + } + + try { + const collection = await request({ url, json: true }); + return collection ? collection.totalItems : null; + } catch (exception) { + return null; + } +} + +function findUser(q) { + return User.findOne(q, { + fields: { + data: false + } + }); +} + +function webFingerAndVerify(query, verifier) { + return new Promise((res, rej) => webFinger.lookup(query, (error, result) => { + if (error) { + return rej(error); + } + + if (result.object.subject.toLowerCase().replace(/^acct:/, '') !== verifier) { + return rej('WebFinger verfification failed'); + } + + res(result.object); + })); +} + +/** + * Show a user + * + * @param {any} params + * @param {any} me + * @return {Promise<any>} + */ +module.exports = (params, me) => new Promise(async (res, rej) => { + let user; + + // Get 'user_id' parameter + const [userId, userIdErr] = $(params.user_id).optional.id().$; + if (userIdErr) return rej('invalid user_id param'); + + // Get 'username' parameter + const [username, usernameErr] = $(params.username).optional.string().$; + if (usernameErr) return rej('invalid username param'); + + // Get 'host' parameter + const [host, hostErr] = $(params.host).optional.string().$; + if (hostErr) return rej('invalid username param'); + + if (userId === undefined && typeof username !== 'string') { + return rej('user_id or pair of username and host is required'); + } + + // Lookup user + if (typeof host === 'string') { + const username_lower = username.toLowerCase(); + const host_lower_ascii = toASCII(host).toLowerCase(); + const host_lower = toUnicode(host_lower_ascii); + + user = await findUser({ username_lower, host_lower }); + + if (user === null) { + const acct_lower = `${username_lower}@${host_lower_ascii}`; + let activityStreams; + let finger; + let followers_count; + let following_count; + let likes_count; + let posts_count; + + if (!validateUsername(username)) { + return rej('username validation failed'); + } + + try { + finger = await webFingerAndVerify(acct_lower, acct_lower); + } catch (exception) { + return rej('WebFinger lookup failed'); + } + + const self = finger.links.find(link => link.rel && link.rel.toLowerCase() === 'self'); + if (!self) { + return rej('WebFinger has no reference to self representation'); + } + + try { + activityStreams = await request({ + url: self.href, + headers: { + Accept: 'application/activity+json, application/ld+json' + }, + json: true + }); + } catch (exception) { + return rej('failed to retrieve ActivityStreams representation'); + } + + if (!(activityStreams && + (Array.isArray(activityStreams['@context']) ? + activityStreams['@context'].includes('https://www.w3.org/ns/activitystreams') : + activityStreams['@context'] === 'https://www.w3.org/ns/activitystreams') && + activityStreams.type === 'Person' && + typeof activityStreams.preferredUsername === 'string' && + activityStreams.preferredUsername.toLowerCase() === username_lower && + isValidName(activityStreams.name) && + isValidDescription(activityStreams.summary) + )) { + return rej('failed ActivityStreams validation'); + } + + try { + [followers_count, following_count, likes_count, posts_count] = await Promise.all([ + getCollectionCount(activityStreams.followers), + getCollectionCount(activityStreams.following), + getCollectionCount(activityStreams.liked), + getCollectionCount(activityStreams.outbox), + webFingerAndVerify(activityStreams.id, acct_lower), + ]); + } catch (exception) { + return rej('failed to fetch assets'); + } + + const summaryDOM = JSDOM.fragment(activityStreams.summary); + + // Create user + user = await User.insert({ + avatar_id: null, + banner_id: null, + created_at: new Date(), + description: summaryDOM.textContent, + followers_count, + following_count, + name: activityStreams.name, + posts_count, + likes_count, + liked_count: 0, + drive_capacity: 1073741824, // 1GB + username: username, + username_lower, + host: toUnicode(finger.subject.replace(/^.*?@/, '')), + host_lower, + account: { + uri: activityStreams.id, + }, + }); + + const [icon, image] = await Promise.all([ + activityStreams.icon, + activityStreams.image, + ].map(async image => { + if (!image || image.type !== 'Image') { + return { _id: null }; + } + + try { + return await uploadFromUrl(image.url, user); + } catch (exception) { + return { _id: null }; + } + })); + + User.update({ _id: user._id }, { + $set: { + avatar_id: icon._id, + banner_id: image._id, + }, + }); + + user.avatar_id = icon._id; + user.banner_id = icon._id; + } + } else { + const q = userId !== undefined + ? { _id: userId } + : { username_lower: username.toLowerCase(), host: null }; + + user = await findUser(q); + + if (user === null) { + return rej('user not found'); + } + } + + // Send response + res(await pack(user, me, { + detail: true + })); +}); diff --git a/src/server/api/event.ts b/src/server/api/event.ts new file mode 100644 index 0000000000..98bf161137 --- /dev/null +++ b/src/server/api/event.ts @@ -0,0 +1,80 @@ +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; + +class MisskeyEvent { + private redisClient: redis.RedisClient; + + constructor() { + // Connect to Redis + this.redisClient = redis.createClient( + config.redis.port, config.redis.host); + } + + public publishUserStream(userId: ID, type: string, value?: any): void { + 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); + } + + public publishMessagingStream(userId: ID, otherpartyId: ID, type: string, value?: any): void { + 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 publishOthelloStream(userId: ID, type: string, value?: any): void { + this.publish(`othello-stream:${userId}`, type, typeof value === 'undefined' ? null : value); + } + + public publishOthelloGameStream(gameId: ID, type: string, value?: any): void { + this.publish(`othello-game-stream:${gameId}`, type, typeof value === 'undefined' ? null : value); + } + + public publishChannelStream(channelId: ID, type: string, value?: any): void { + this.publish(`channel-stream:${channelId}`, type, typeof value === 'undefined' ? null : value); + } + + private publish(channel: string, type: string, value?: any): void { + const message = value == null ? + { type: type } : + { type: type, body: value }; + + this.redisClient.publish(`misskey:${channel}`, JSON.stringify(message)); + } +} + +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 publishOthelloStream = ev.publishOthelloStream.bind(ev); + +export const publishOthelloGameStream = ev.publishOthelloGameStream.bind(ev); + +export const publishChannelStream = ev.publishChannelStream.bind(ev); diff --git a/src/server/api/limitter.ts b/src/server/api/limitter.ts new file mode 100644 index 0000000000..33337fbb1b --- /dev/null +++ b/src/server/api/limitter.ts @@ -0,0 +1,83 @@ +import * as Limiter from 'ratelimiter'; +import * as debug from 'debug'; +import limiterDB from '../../db/redis'; +import { Endpoint } from './endpoints'; +import { IAuthContext } from './authenticate'; +import getAcct from '../common/user/get-acct'; + +const log = debug('misskey:limitter'); + +export default (endpoint: Endpoint, ctx: IAuthContext) => new Promise((ok, reject) => { + const limitation = endpoint.limit; + + const key = limitation.hasOwnProperty('key') + ? limitation.key + : endpoint.name; + + const hasShortTermLimit = + limitation.hasOwnProperty('minInterval'); + + const hasLongTermLimit = + limitation.hasOwnProperty('duration') && + limitation.hasOwnProperty('max'); + + if (hasShortTermLimit) { + min(); + } else if (hasLongTermLimit) { + max(); + } else { + ok(); + } + + // Short-term limit + function min() { + const minIntervalLimiter = new Limiter({ + id: `${ctx.user._id}:${key}:min`, + duration: limitation.minInterval, + max: 1, + db: limiterDB + }); + + minIntervalLimiter.get((err, info) => { + if (err) { + return reject('ERR'); + } + + log(`@${getAcct(ctx.user)} ${endpoint.name} min remaining: ${info.remaining}`); + + if (info.remaining === 0) { + reject('BRIEF_REQUEST_INTERVAL'); + } else { + if (hasLongTermLimit) { + max(); + } else { + ok(); + } + } + }); + } + + // Long term limit + function max() { + const limiter = new Limiter({ + id: `${ctx.user._id}:${key}`, + duration: limitation.duration, + max: limitation.max, + db: limiterDB + }); + + limiter.get((err, info) => { + if (err) { + return reject('ERR'); + } + + log(`@${getAcct(ctx.user)} ${endpoint.name} max remaining: ${info.remaining}`); + + if (info.remaining === 0) { + reject('RATE_LIMIT_EXCEEDED'); + } else { + ok(); + } + }); + } +}); diff --git a/src/server/api/models/access-token.ts b/src/server/api/models/access-token.ts new file mode 100644 index 0000000000..2bf91f3093 --- /dev/null +++ b/src/server/api/models/access-token.ts @@ -0,0 +1,8 @@ +import db from '../../../db/mongodb'; + +const collection = db.get('access_tokens'); + +(collection as any).createIndex('token'); // fuck type definition +(collection as any).createIndex('hash'); // fuck type definition + +export default collection as any; // fuck type definition diff --git a/src/server/api/models/app.ts b/src/server/api/models/app.ts new file mode 100644 index 0000000000..17db82ecac --- /dev/null +++ b/src/server/api/models/app.ts @@ -0,0 +1,97 @@ +import * as mongo from 'mongodb'; +import deepcopy = require('deepcopy'); +import AccessToken from './access-token'; +import db from '../../../db/mongodb'; +import config from '../../../conf'; + +const App = db.get<IApp>('apps'); +App.createIndex('name_id'); +App.createIndex('name_id_lower'); +App.createIndex('secret'); +export default App; + +export type IApp = { + _id: mongo.ObjectID; + created_at: Date; + user_id: mongo.ObjectID; + secret: string; +}; + +export function isValidNameId(nameId: string): boolean { + return typeof nameId == 'string' && /^[a-zA-Z0-9\-]{3,30}$/.test(nameId); +} + +/** + * Pack an app for API response + * + * @param {any} app + * @param {any} me? + * @param {any} options? + * @return {Promise<any>} + */ +export const pack = ( + app: any, + me?: any, + options?: { + includeSecret?: boolean, + includeProfileImageIds?: boolean + } +) => new Promise<any>(async (resolve, reject) => { + const opts = options || { + includeSecret: false, + includeProfileImageIds: false + }; + + let _app: any; + + // Populate the app if 'app' is ID + if (mongo.ObjectID.prototype.isPrototypeOf(app)) { + _app = await App.findOne({ + _id: app + }); + } else if (typeof app === 'string') { + _app = await App.findOne({ + _id: new mongo.ObjectID(app) + }); + } else { + _app = deepcopy(app); + } + + // Me + if (me && !mongo.ObjectID.prototype.isPrototypeOf(me)) { + if (typeof me === 'string') { + me = new mongo.ObjectID(me); + } else { + me = me._id; + } + } + + // Rename _id to id + _app.id = _app._id; + delete _app._id; + + delete _app.name_id_lower; + + // Visible by only owner + if (!opts.includeSecret) { + delete _app.secret; + } + + _app.icon_url = _app.icon != null + ? `${config.drive_url}/${_app.icon}` + : `${config.drive_url}/app-default.jpg`; + + if (me) { + // 既に連携しているか + const exist = await AccessToken.count({ + app_id: _app.id, + user_id: me, + }, { + limit: 1 + }); + + _app.is_authorized = exist === 1; + } + + resolve(_app); +}); diff --git a/src/server/api/models/appdata.ts b/src/server/api/models/appdata.ts new file mode 100644 index 0000000000..dda3c98934 --- /dev/null +++ b/src/server/api/models/appdata.ts @@ -0,0 +1,3 @@ +import db from '../../../db/mongodb'; + +export default db.get('appdata') as any; // fuck type definition diff --git a/src/server/api/models/auth-session.ts b/src/server/api/models/auth-session.ts new file mode 100644 index 0000000000..a79d901df5 --- /dev/null +++ b/src/server/api/models/auth-session.ts @@ -0,0 +1,45 @@ +import * as mongo from 'mongodb'; +import deepcopy = require('deepcopy'); +import db from '../../../db/mongodb'; +import { pack as packApp } from './app'; + +const AuthSession = db.get('auth_sessions'); +export default AuthSession; + +export interface IAuthSession { + _id: mongo.ObjectID; +} + +/** + * Pack an auth session for API response + * + * @param {any} session + * @param {any} me? + * @return {Promise<any>} + */ +export const pack = ( + session: any, + me?: any +) => new Promise<any>(async (resolve, reject) => { + let _session: any; + + // TODO: Populate session if it ID + + _session = deepcopy(session); + + // Me + if (me && !mongo.ObjectID.prototype.isPrototypeOf(me)) { + if (typeof me === 'string') { + me = new mongo.ObjectID(me); + } else { + me = me._id; + } + } + + delete _session._id; + + // Populate app + _session.app = await packApp(_session.app_id, me); + + resolve(_session); +}); diff --git a/src/server/api/models/channel-watching.ts b/src/server/api/models/channel-watching.ts new file mode 100644 index 0000000000..4c6fae28d3 --- /dev/null +++ b/src/server/api/models/channel-watching.ts @@ -0,0 +1,3 @@ +import db from '../../../db/mongodb'; + +export default db.get('channel_watching') as any; // fuck type definition diff --git a/src/server/api/models/channel.ts b/src/server/api/models/channel.ts new file mode 100644 index 0000000000..97999bd9e2 --- /dev/null +++ b/src/server/api/models/channel.ts @@ -0,0 +1,74 @@ +import * as mongo from 'mongodb'; +import deepcopy = require('deepcopy'); +import { IUser } from './user'; +import Watching from './channel-watching'; +import db from '../../../db/mongodb'; + +const Channel = db.get<IChannel>('channels'); +export default Channel; + +export type IChannel = { + _id: mongo.ObjectID; + created_at: Date; + title: string; + user_id: mongo.ObjectID; + index: number; +}; + +/** + * Pack a channel for API response + * + * @param channel target + * @param me? serializee + * @return response + */ +export const pack = ( + channel: string | mongo.ObjectID | IChannel, + me?: string | mongo.ObjectID | IUser +) => new Promise<any>(async (resolve, reject) => { + + let _channel: any; + + // Populate the channel if 'channel' is ID + if (mongo.ObjectID.prototype.isPrototypeOf(channel)) { + _channel = await Channel.findOne({ + _id: channel + }); + } else if (typeof channel === 'string') { + _channel = await Channel.findOne({ + _id: new mongo.ObjectID(channel) + }); + } else { + _channel = deepcopy(channel); + } + + // Rename _id to id + _channel.id = _channel._id; + delete _channel._id; + + // Remove needless properties + delete _channel.user_id; + + // Me + const meId: mongo.ObjectID = me + ? mongo.ObjectID.prototype.isPrototypeOf(me) + ? me as mongo.ObjectID + : typeof me === 'string' + ? new mongo.ObjectID(me) + : (me as IUser)._id + : null; + + if (me) { + //#region Watchしているかどうか + const watch = await Watching.findOne({ + user_id: meId, + channel_id: _channel.id, + deleted_at: { $exists: false } + }); + + _channel.is_watching = watch !== null; + //#endregion + } + + resolve(_channel); +}); diff --git a/src/server/api/models/drive-file.ts b/src/server/api/models/drive-file.ts new file mode 100644 index 0000000000..851a79a0e7 --- /dev/null +++ b/src/server/api/models/drive-file.ts @@ -0,0 +1,113 @@ +import * as mongodb from 'mongodb'; +import deepcopy = require('deepcopy'); +import { pack as packFolder } from './drive-folder'; +import config from '../../../conf'; +import monkDb, { nativeDbConn } from '../../../db/mongodb'; + +const DriveFile = monkDb.get<IDriveFile>('drive_files.files'); + +export default DriveFile; + +const getGridFSBucket = async (): Promise<mongodb.GridFSBucket> => { + const db = await nativeDbConn(); + const bucket = new mongodb.GridFSBucket(db, { + bucketName: 'drive_files' + }); + return bucket; +}; + +export { getGridFSBucket }; + +export type IDriveFile = { + _id: mongodb.ObjectID; + uploadDate: Date; + md5: string; + filename: string; + contentType: string; + metadata: { + properties: any; + user_id: mongodb.ObjectID; + folder_id: mongodb.ObjectID; + } +}; + +export function validateFileName(name: string): boolean { + return ( + (name.trim().length > 0) && + (name.length <= 200) && + (name.indexOf('\\') === -1) && + (name.indexOf('/') === -1) && + (name.indexOf('..') === -1) + ); +} + +/** + * Pack a drive file for API response + * + * @param {any} file + * @param {any} options? + * @return {Promise<any>} + */ +export const pack = ( + file: any, + options?: { + detail: boolean + } +) => new Promise<any>(async (resolve, reject) => { + const opts = Object.assign({ + detail: false + }, options); + + let _file: any; + + // Populate the file if 'file' is ID + if (mongodb.ObjectID.prototype.isPrototypeOf(file)) { + _file = await DriveFile.findOne({ + _id: file + }); + } else if (typeof file === 'string') { + _file = await DriveFile.findOne({ + _id: new mongodb.ObjectID(file) + }); + } else { + _file = deepcopy(file); + } + + 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; + + _target = Object.assign(_target, _file.metadata); + + _target.url = `${config.drive_url}/${_target.id}/${encodeURIComponent(_target.name)}`; + + if (_target.properties == null) _target.properties = {}; + + if (opts.detail) { + if (_target.folder_id) { + // Populate folder + _target.folder = await packFolder(_target.folder_id, { + detail: true + }); + } + + /* + if (_target.tags) { + // Populate tags + _target.tags = await _target.tags.map(async (tag: any) => + await serializeDriveTag(tag) + ); + } + */ + } + + resolve(_target); +}); diff --git a/src/server/api/models/drive-folder.ts b/src/server/api/models/drive-folder.ts new file mode 100644 index 0000000000..505556376a --- /dev/null +++ b/src/server/api/models/drive-folder.ts @@ -0,0 +1,77 @@ +import * as mongo from 'mongodb'; +import deepcopy = require('deepcopy'); +import db from '../../../db/mongodb'; +import DriveFile from './drive-file'; + +const DriveFolder = db.get<IDriveFolder>('drive_folders'); +export default DriveFolder; + +export type IDriveFolder = { + _id: mongo.ObjectID; + created_at: Date; + name: string; + user_id: mongo.ObjectID; + parent_id: mongo.ObjectID; +}; + +export function isValidFolderName(name: string): boolean { + return ( + (name.trim().length > 0) && + (name.length <= 200) + ); +} + +/** + * Pack a drive folder for API response + * + * @param {any} folder + * @param {any} options? + * @return {Promise<any>} + */ +export const pack = ( + folder: any, + options?: { + detail: boolean + } +) => new Promise<any>(async (resolve, reject) => { + const opts = Object.assign({ + detail: false + }, options); + + let _folder: any; + + // Populate the folder if 'folder' is ID + if (mongo.ObjectID.prototype.isPrototypeOf(folder)) { + _folder = await DriveFolder.findOne({ _id: folder }); + } else if (typeof folder === 'string') { + _folder = await DriveFolder.findOne({ _id: new mongo.ObjectID(folder) }); + } else { + _folder = deepcopy(folder); + } + + // Rename _id to id + _folder.id = _folder._id; + delete _folder._id; + + if (opts.detail) { + const childFoldersCount = await DriveFolder.count({ + parent_id: _folder.id + }); + + const childFilesCount = await DriveFile.count({ + 'metadata.folder_id': _folder.id + }); + + _folder.folders_count = childFoldersCount; + _folder.files_count = childFilesCount; + } + + if (opts.detail && _folder.parent_id) { + // Populate parent folder + _folder.parent = await pack(_folder.parent_id, { + detail: true + }); + } + + resolve(_folder); +}); diff --git a/src/server/api/models/drive-tag.ts b/src/server/api/models/drive-tag.ts new file mode 100644 index 0000000000..d1c68365a3 --- /dev/null +++ b/src/server/api/models/drive-tag.ts @@ -0,0 +1,3 @@ +import db from '../../../db/mongodb'; + +export default db.get('drive_tags') as any; // fuck type definition diff --git a/src/server/api/models/favorite.ts b/src/server/api/models/favorite.ts new file mode 100644 index 0000000000..3142617643 --- /dev/null +++ b/src/server/api/models/favorite.ts @@ -0,0 +1,3 @@ +import db from '../../../db/mongodb'; + +export default db.get('favorites') as any; // fuck type definition diff --git a/src/server/api/models/following.ts b/src/server/api/models/following.ts new file mode 100644 index 0000000000..92d7b6d31b --- /dev/null +++ b/src/server/api/models/following.ts @@ -0,0 +1,3 @@ +import db from '../../../db/mongodb'; + +export default db.get('following') as any; // fuck type definition diff --git a/src/server/api/models/messaging-history.ts b/src/server/api/models/messaging-history.ts new file mode 100644 index 0000000000..ea9f317eee --- /dev/null +++ b/src/server/api/models/messaging-history.ts @@ -0,0 +1,3 @@ +import db from '../../../db/mongodb'; + +export default db.get('messaging_histories') as any; // fuck type definition diff --git a/src/server/api/models/messaging-message.ts b/src/server/api/models/messaging-message.ts new file mode 100644 index 0000000000..be484d635f --- /dev/null +++ b/src/server/api/models/messaging-message.ts @@ -0,0 +1,81 @@ +import * as mongo from 'mongodb'; +import deepcopy = require('deepcopy'); +import { pack as packUser } from './user'; +import { pack as packFile } from './drive-file'; +import db from '../../../db/mongodb'; +import parse from '../common/text'; + +const MessagingMessage = db.get<IMessagingMessage>('messaging_messages'); +export default MessagingMessage; + +export interface IMessagingMessage { + _id: mongo.ObjectID; + created_at: Date; + text: string; + user_id: mongo.ObjectID; + recipient_id: mongo.ObjectID; + is_read: boolean; +} + +export function isValidText(text: string): boolean { + return text.length <= 1000 && text.trim() != ''; +} + +/** + * Pack a messaging message for API response + * + * @param {any} message + * @param {any} me? + * @param {any} options? + * @return {Promise<any>} + */ +export const pack = ( + message: any, + me?: any, + options?: { + populateRecipient: boolean + } +) => new Promise<any>(async (resolve, reject) => { + const opts = options || { + populateRecipient: true + }; + + let _message: any; + + // Populate the message if 'message' is ID + if (mongo.ObjectID.prototype.isPrototypeOf(message)) { + _message = await MessagingMessage.findOne({ + _id: message + }); + } else if (typeof message === 'string') { + _message = await MessagingMessage.findOne({ + _id: new mongo.ObjectID(message) + }); + } else { + _message = deepcopy(message); + } + + // Rename _id to id + _message.id = _message._id; + delete _message._id; + + // Parse text + if (_message.text) { + _message.ast = parse(_message.text); + } + + // Populate user + _message.user = await packUser(_message.user_id, me); + + if (_message.file_id) { + // Populate file + _message.file = await packFile(_message.file_id); + } + + if (opts.populateRecipient) { + // Populate recipient + _message.recipient = await packUser(_message.recipient_id, me); + } + + resolve(_message); +}); diff --git a/src/server/api/models/meta.ts b/src/server/api/models/meta.ts new file mode 100644 index 0000000000..ee1ada18fa --- /dev/null +++ b/src/server/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/server/api/models/mute.ts b/src/server/api/models/mute.ts new file mode 100644 index 0000000000..02f652c30b --- /dev/null +++ b/src/server/api/models/mute.ts @@ -0,0 +1,3 @@ +import db from '../../../db/mongodb'; + +export default db.get('mute') as any; // fuck type definition diff --git a/src/server/api/models/notification.ts b/src/server/api/models/notification.ts new file mode 100644 index 0000000000..bcb25534dc --- /dev/null +++ b/src/server/api/models/notification.ts @@ -0,0 +1,107 @@ +import * as mongo from 'mongodb'; +import deepcopy = require('deepcopy'); +import db from '../../../db/mongodb'; +import { IUser, pack as packUser } from './user'; +import { pack as packPost } from './post'; + +const Notification = db.get<INotification>('notifications'); +export default Notification; + +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; +} + +/** + * Pack a notification for API response + * + * @param {any} notification + * @return {Promise<any>} + */ +export const pack = (notification: any) => new Promise<any>(async (resolve, reject) => { + let _notification: any; + + // Populate the notification if 'notification' is ID + if (mongo.ObjectID.prototype.isPrototypeOf(notification)) { + _notification = await Notification.findOne({ + _id: notification + }); + } else if (typeof notification === 'string') { + _notification = await Notification.findOne({ + _id: new mongo.ObjectID(notification) + }); + } else { + _notification = deepcopy(notification); + } + + // Rename _id to id + _notification.id = _notification._id; + delete _notification._id; + + // Rename notifier_id to user_id + _notification.user_id = _notification.notifier_id; + delete _notification.notifier_id; + + const me = _notification.notifiee_id; + delete _notification.notifiee_id; + + // Populate notifier + _notification.user = await packUser(_notification.user_id, me); + + switch (_notification.type) { + case 'follow': + // nope + break; + case 'mention': + case 'reply': + case 'repost': + case 'quote': + case 'reaction': + case 'poll_vote': + // Populate post + _notification.post = await packPost(_notification.post_id, me); + break; + default: + console.error(`Unknown type: ${_notification.type}`); + break; + } + + resolve(_notification); +}); diff --git a/src/server/api/models/othello-game.ts b/src/server/api/models/othello-game.ts new file mode 100644 index 0000000000..97508e46da --- /dev/null +++ b/src/server/api/models/othello-game.ts @@ -0,0 +1,109 @@ +import * as mongo from 'mongodb'; +import deepcopy = require('deepcopy'); +import db from '../../../db/mongodb'; +import { IUser, pack as packUser } from './user'; + +const Game = db.get<IGame>('othello_games'); +export default Game; + +export interface IGame { + _id: mongo.ObjectID; + created_at: Date; + started_at: Date; + user1_id: mongo.ObjectID; + user2_id: mongo.ObjectID; + user1_accepted: boolean; + user2_accepted: boolean; + + /** + * どちらのプレイヤーが先行(黒)か + * 1 ... user1 + * 2 ... user2 + */ + black: number; + + is_started: boolean; + is_ended: boolean; + winner_id: mongo.ObjectID; + logs: Array<{ + at: Date; + color: boolean; + pos: number; + }>; + settings: { + map: string[]; + bw: string | number; + is_llotheo: boolean; + can_put_everywhere: boolean; + looped_board: boolean; + }; + form1: any; + form2: any; + + // ログのposを文字列としてすべて連結したもののCRC32値 + crc32: string; +} + +/** + * Pack an othello game for API response + */ +export const pack = ( + game: any, + me?: string | mongo.ObjectID | IUser, + options?: { + detail?: boolean + } +) => new Promise<any>(async (resolve, reject) => { + const opts = Object.assign({ + detail: true + }, options); + + let _game: any; + + // Populate the game if 'game' is ID + if (mongo.ObjectID.prototype.isPrototypeOf(game)) { + _game = await Game.findOne({ + _id: game + }); + } else if (typeof game === 'string') { + _game = await Game.findOne({ + _id: new mongo.ObjectID(game) + }); + } else { + _game = deepcopy(game); + } + + // Me + const meId: mongo.ObjectID = me + ? mongo.ObjectID.prototype.isPrototypeOf(me) + ? me as mongo.ObjectID + : typeof me === 'string' + ? new mongo.ObjectID(me) + : (me as IUser)._id + : null; + + // Rename _id to id + _game.id = _game._id; + delete _game._id; + + if (opts.detail === false) { + delete _game.logs; + delete _game.settings.map; + } else { + // 互換性のため + if (_game.settings.map.hasOwnProperty('size')) { + _game.settings.map = _game.settings.map.data.match(new RegExp(`.{1,${_game.settings.map.size}}`, 'g')); + } + } + + // Populate user + _game.user1 = await packUser(_game.user1_id, meId); + _game.user2 = await packUser(_game.user2_id, meId); + if (_game.winner_id) { + _game.winner = await packUser(_game.winner_id, meId); + } else { + _game.winner = null; + } + + resolve(_game); +}); diff --git a/src/server/api/models/othello-matching.ts b/src/server/api/models/othello-matching.ts new file mode 100644 index 0000000000..3c29e6a00c --- /dev/null +++ b/src/server/api/models/othello-matching.ts @@ -0,0 +1,44 @@ +import * as mongo from 'mongodb'; +import deepcopy = require('deepcopy'); +import db from '../../../db/mongodb'; +import { IUser, pack as packUser } from './user'; + +const Matching = db.get<IMatching>('othello_matchings'); +export default Matching; + +export interface IMatching { + _id: mongo.ObjectID; + created_at: Date; + parent_id: mongo.ObjectID; + child_id: mongo.ObjectID; +} + +/** + * Pack an othello matching for API response + */ +export const pack = ( + matching: any, + me?: string | mongo.ObjectID | IUser +) => new Promise<any>(async (resolve, reject) => { + + // Me + const meId: mongo.ObjectID = me + ? mongo.ObjectID.prototype.isPrototypeOf(me) + ? me as mongo.ObjectID + : typeof me === 'string' + ? new mongo.ObjectID(me) + : (me as IUser)._id + : null; + + const _matching = deepcopy(matching); + + // Rename _id to id + _matching.id = _matching._id; + delete _matching._id; + + // Populate user + _matching.parent = await packUser(_matching.parent_id, meId); + _matching.child = await packUser(_matching.child_id, meId); + + resolve(_matching); +}); diff --git a/src/server/api/models/poll-vote.ts b/src/server/api/models/poll-vote.ts new file mode 100644 index 0000000000..c6638ccf1c --- /dev/null +++ b/src/server/api/models/poll-vote.ts @@ -0,0 +1,3 @@ +import db from '../../../db/mongodb'; + +export default db.get('poll_votes') as any; // fuck type definition diff --git a/src/server/api/models/post-reaction.ts b/src/server/api/models/post-reaction.ts new file mode 100644 index 0000000000..5cd122d76b --- /dev/null +++ b/src/server/api/models/post-reaction.ts @@ -0,0 +1,51 @@ +import * as mongo from 'mongodb'; +import deepcopy = require('deepcopy'); +import db from '../../../db/mongodb'; +import Reaction from './post-reaction'; +import { pack as packUser } from './user'; + +const PostReaction = db.get<IPostReaction>('post_reactions'); +export default PostReaction; + +export interface IPostReaction { + _id: mongo.ObjectID; + created_at: Date; + deleted_at: Date; + reaction: string; +} + +/** + * Pack a reaction for API response + * + * @param {any} reaction + * @param {any} me? + * @return {Promise<any>} + */ +export const pack = ( + reaction: any, + me?: any +) => new Promise<any>(async (resolve, reject) => { + let _reaction: any; + + // Populate the reaction if 'reaction' is ID + if (mongo.ObjectID.prototype.isPrototypeOf(reaction)) { + _reaction = await Reaction.findOne({ + _id: reaction + }); + } else if (typeof reaction === 'string') { + _reaction = await Reaction.findOne({ + _id: new mongo.ObjectID(reaction) + }); + } else { + _reaction = deepcopy(reaction); + } + + // Rename _id to id + _reaction.id = _reaction._id; + delete _reaction._id; + + // Populate user + _reaction.user = await packUser(_reaction.user_id, me); + + resolve(_reaction); +}); diff --git a/src/server/api/models/post-watching.ts b/src/server/api/models/post-watching.ts new file mode 100644 index 0000000000..9a4163c8dc --- /dev/null +++ b/src/server/api/models/post-watching.ts @@ -0,0 +1,3 @@ +import db from '../../../db/mongodb'; + +export default db.get('post_watching') as any; // fuck type definition diff --git a/src/server/api/models/post.ts b/src/server/api/models/post.ts new file mode 100644 index 0000000000..3f648e08cd --- /dev/null +++ b/src/server/api/models/post.ts @@ -0,0 +1,219 @@ +import * as mongo from 'mongodb'; +import deepcopy = require('deepcopy'); +import rap from '@prezzemolo/rap'; +import db from '../../../db/mongodb'; +import { IUser, pack as packUser } from './user'; +import { pack as packApp } from './app'; +import { pack as packChannel } from './channel'; +import Vote from './poll-vote'; +import Reaction from './post-reaction'; +import { pack as packFile } from './drive-file'; +import parse from '../common/text'; + +const Post = db.get<IPost>('posts'); + +export default Post; + +export function isValidText(text: string): boolean { + return text.length <= 1000 && text.trim() != ''; +} + +export type IPost = { + _id: mongo.ObjectID; + channel_id: mongo.ObjectID; + created_at: Date; + media_ids: mongo.ObjectID[]; + reply_id: mongo.ObjectID; + repost_id: mongo.ObjectID; + poll: any; // todo + text: string; + user_id: mongo.ObjectID; + app_id: mongo.ObjectID; + category: string; + is_category_verified: boolean; + via_mobile: boolean; + geo: { + latitude: number; + longitude: number; + altitude: number; + accuracy: number; + altitudeAccuracy: number; + heading: number; + speed: number; + }; +}; + +/** + * Pack a post for API response + * + * @param post target + * @param me? serializee + * @param options? serialize options + * @return response + */ +export const pack = async ( + post: string | mongo.ObjectID | IPost, + me?: string | mongo.ObjectID | IUser, + options?: { + detail: boolean + } +) => { + const opts = options || { + detail: true, + }; + + // Me + const meId: mongo.ObjectID = me + ? mongo.ObjectID.prototype.isPrototypeOf(me) + ? me as mongo.ObjectID + : typeof me === 'string' + ? new mongo.ObjectID(me) + : (me as IUser)._id + : null; + + let _post: any; + + // Populate the post if 'post' is ID + if (mongo.ObjectID.prototype.isPrototypeOf(post)) { + _post = await Post.findOne({ + _id: post + }); + } else if (typeof post === 'string') { + _post = await Post.findOne({ + _id: new mongo.ObjectID(post) + }); + } else { + _post = deepcopy(post); + } + + if (!_post) throw 'invalid post arg.'; + + const id = _post._id; + + // Rename _id to id + _post.id = _post._id; + delete _post._id; + + delete _post.mentions; + + // Parse text + if (_post.text) { + _post.ast = parse(_post.text); + } + + // Populate user + _post.user = packUser(_post.user_id, meId); + + // Populate app + if (_post.app_id) { + _post.app = packApp(_post.app_id); + } + + // Populate channel + if (_post.channel_id) { + _post.channel = packChannel(_post.channel_id); + } + + // Populate media + if (_post.media_ids) { + _post.media = Promise.all(_post.media_ids.map(fileId => + packFile(fileId) + )); + } + + // When requested a detailed post data + if (opts.detail) { + // Get previous post info + _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 + _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 = pack(_post.reply_id, meId, { + detail: false + }); + } + + if (_post.repost_id) { + // Populate repost + _post.repost = pack(_post.repost_id, meId, { + detail: _post.text == null + }); + } + + // Poll + if (meId && _post.poll) { + _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]; + + myChoice.is_voted = true; + } + + return poll; + })(_post.poll); + } + + // Fetch my reaction + if (meId) { + _post.my_reaction = (async () => { + const reaction = await Reaction + .findOne({ + user_id: meId, + post_id: id, + deleted_at: { $exists: false } + }); + + if (reaction) { + return reaction.reaction; + } + + return null; + })(); + } + } + + // resolve promises in _post object + _post = await rap(_post); + + return _post; +}; diff --git a/src/server/api/models/signin.ts b/src/server/api/models/signin.ts new file mode 100644 index 0000000000..5cffb3c310 --- /dev/null +++ b/src/server/api/models/signin.ts @@ -0,0 +1,29 @@ +import * as mongo from 'mongodb'; +import deepcopy = require('deepcopy'); +import db from '../../../db/mongodb'; + +const Signin = db.get<ISignin>('signin'); +export default Signin; + +export interface ISignin { + _id: mongo.ObjectID; +} + +/** + * Pack a signin record for API response + * + * @param {any} record + * @return {Promise<any>} + */ +export const pack = ( + record: any +) => new Promise<any>(async (resolve, reject) => { + + const _record = deepcopy(record); + + // Rename _id to id + _record.id = _record._id; + delete _record._id; + + resolve(_record); +}); diff --git a/src/server/api/models/sw-subscription.ts b/src/server/api/models/sw-subscription.ts new file mode 100644 index 0000000000..4506a982f2 --- /dev/null +++ b/src/server/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/server/api/models/user.ts b/src/server/api/models/user.ts new file mode 100644 index 0000000000..8e7d50baa3 --- /dev/null +++ b/src/server/api/models/user.ts @@ -0,0 +1,340 @@ +import * as mongo from 'mongodb'; +import deepcopy = require('deepcopy'); +import rap from '@prezzemolo/rap'; +import db from '../../../db/mongodb'; +import { IPost, pack as packPost } from './post'; +import Following from './following'; +import Mute from './mute'; +import getFriends from '../common/get-friends'; +import config from '../../../conf'; + +const User = db.get<IUser>('users'); + +User.createIndex('username'); +User.createIndex('account.token'); + +export default User; + +export function validateUsername(username: string): boolean { + return typeof username == 'string' && /^[a-zA-Z0-9\-]{3,20}$/.test(username); +} + +export function validatePassword(password: string): boolean { + return typeof password == 'string' && password != ''; +} + +export function isValidName(name: string): boolean { + return typeof name == 'string' && name.length < 30 && name.trim() != ''; +} + +export function isValidDescription(description: string): boolean { + return typeof description == 'string' && description.length < 500 && description.trim() != ''; +} + +export function isValidLocation(location: string): boolean { + return typeof location == 'string' && location.length < 50 && location.trim() != ''; +} + +export function isValidBirthday(birthday: string): boolean { + return typeof birthday == 'string' && /^([0-9]{4})\-([0-9]{2})-([0-9]{2})$/.test(birthday); +} + +export type ILocalAccount = { + keypair: string; + email: string; + links: string[]; + password: string; + token: string; + twitter: { + access_token: string; + access_token_secret: string; + user_id: string; + screen_name: string; + }; + line: { + user_id: string; + }; + profile: { + location: string; + birthday: string; // 'YYYY-MM-DD' + tags: string[]; + }; + last_used_at: Date; + is_bot: boolean; + is_pro: boolean; + two_factor_secret: string; + two_factor_enabled: boolean; + client_settings: any; + settings: any; +}; + +export type IRemoteAccount = { + uri: string; +}; + +export type IUser = { + _id: mongo.ObjectID; + created_at: Date; + deleted_at: Date; + followers_count: number; + following_count: number; + name: string; + posts_count: number; + drive_capacity: number; + username: string; + username_lower: string; + avatar_id: mongo.ObjectID; + banner_id: mongo.ObjectID; + data: any; + description: string; + latest_post: IPost; + pinned_post_id: mongo.ObjectID; + is_suspended: boolean; + keywords: string[]; + host: string; + host_lower: string; + account: ILocalAccount | IRemoteAccount; +}; + +export function init(user): IUser { + user._id = new mongo.ObjectID(user._id); + user.avatar_id = new mongo.ObjectID(user.avatar_id); + user.banner_id = new mongo.ObjectID(user.banner_id); + user.pinned_post_id = new mongo.ObjectID(user.pinned_post_id); + return user; +} + +/** + * Pack a user for API response + * + * @param user target + * @param me? serializee + * @param options? serialize options + * @return Packed user + */ +export const pack = ( + user: string | mongo.ObjectID | IUser, + me?: string | mongo.ObjectID | IUser, + options?: { + detail?: boolean, + includeSecrets?: boolean + } +) => new Promise<any>(async (resolve, reject) => { + + const opts = Object.assign({ + detail: false, + includeSecrets: false + }, options); + + let _user: any; + + const fields = opts.detail ? { + } : { + 'account.settings': false, + 'account.client_settings': false, + 'account.profile': false, + 'account.keywords': false, + 'account.domains': false + }; + + // Populate the user if 'user' is ID + if (mongo.ObjectID.prototype.isPrototypeOf(user)) { + _user = await User.findOne({ + _id: user + }, { fields }); + } else if (typeof user === 'string') { + _user = await User.findOne({ + _id: new mongo.ObjectID(user) + }, { fields }); + } else { + _user = deepcopy(user); + } + + if (!_user) return reject('invalid user arg.'); + + // Me + const meId: mongo.ObjectID = me + ? mongo.ObjectID.prototype.isPrototypeOf(me) + ? me as mongo.ObjectID + : typeof me === 'string' + ? new mongo.ObjectID(me) + : (me as IUser)._id + : null; + + // Rename _id to id + _user.id = _user._id; + delete _user._id; + + // Remove needless properties + delete _user.latest_post; + + if (!_user.host) { + // Remove private properties + delete _user.account.keypair; + delete _user.account.password; + delete _user.account.token; + delete _user.account.two_factor_temp_secret; + delete _user.account.two_factor_secret; + delete _user.username_lower; + if (_user.account.twitter) { + delete _user.account.twitter.access_token; + delete _user.account.twitter.access_token_secret; + } + delete _user.account.line; + + // Visible via only the official client + if (!opts.includeSecrets) { + delete _user.account.email; + delete _user.account.settings; + delete _user.account.client_settings; + } + + if (!opts.detail) { + delete _user.account.two_factor_enabled; + } + } + + _user.avatar_url = _user.avatar_id != null + ? `${config.drive_url}/${_user.avatar_id}` + : `${config.drive_url}/default-avatar.jpg`; + + _user.banner_url = _user.banner_id != null + ? `${config.drive_url}/${_user.banner_id}` + : null; + + if (!meId || !meId.equals(_user.id) || !opts.detail) { + delete _user.avatar_id; + delete _user.banner_id; + + delete _user.drive_capacity; + } + + if (meId && !meId.equals(_user.id)) { + // Whether the user is following + _user.is_following = (async () => { + const follow = await Following.findOne({ + follower_id: meId, + followee_id: _user.id, + deleted_at: { $exists: false } + }); + return follow !== null; + })(); + + // Whether the user is followed + _user.is_followed = (async () => { + const follow2 = await Following.findOne({ + follower_id: _user.id, + followee_id: meId, + deleted_at: { $exists: false } + }); + return follow2 !== null; + })(); + + // Whether the user is muted + _user.is_muted = (async () => { + const mute = await Mute.findOne({ + muter_id: meId, + mutee_id: _user.id, + deleted_at: { $exists: false } + }); + return mute !== null; + })(); + } + + if (opts.detail) { + if (_user.pinned_post_id) { + // Populate pinned post + _user.pinned_post = packPost(_user.pinned_post_id, meId, { + detail: true + }); + } + + if (meId && !meId.equals(_user.id)) { + const myFollowingIds = await getFriends(meId); + + // Get following you know count + _user.following_you_know_count = Following.count({ + followee_id: { $in: myFollowingIds }, + follower_id: _user.id, + deleted_at: { $exists: false } + }); + + // Get followers you know count + _user.followers_you_know_count = Following.count({ + followee_id: _user.id, + follower_id: { $in: myFollowingIds }, + deleted_at: { $exists: false } + }); + } + } + + // resolve promises in _user object + _user = await rap(_user); + + resolve(_user); +}); + +/** + * Pack a user for ActivityPub + * + * @param user target + * @return Packed user + */ +export const packForAp = ( + user: string | mongo.ObjectID | IUser +) => new Promise<any>(async (resolve, reject) => { + + let _user: any; + + const fields = { + // something + }; + + // Populate the user if 'user' is ID + if (mongo.ObjectID.prototype.isPrototypeOf(user)) { + _user = await User.findOne({ + _id: user + }, { fields }); + } else if (typeof user === 'string') { + _user = await User.findOne({ + _id: new mongo.ObjectID(user) + }, { fields }); + } else { + _user = deepcopy(user); + } + + if (!_user) return reject('invalid user arg.'); + + const userUrl = `${config.url}/@${_user.username}`; + + resolve({ + "@context": ["https://www.w3.org/ns/activitystreams", { + "@language": "ja" + }], + "type": "Person", + "id": userUrl, + "following": `${userUrl}/following.json`, + "followers": `${userUrl}/followers.json`, + "liked": `${userUrl}/liked.json`, + "inbox": `${userUrl}/inbox.json`, + "outbox": `${userUrl}/outbox.json`, + "preferredUsername": _user.username, + "name": _user.name, + "summary": _user.description, + "icon": [ + `${config.drive_url}/${_user.avatar_id}` + ] + }); +}); + +/* +function img(url) { + return { + thumbnail: { + large: `${url}`, + medium: '', + small: '' + } + }; +} +*/ diff --git a/src/server/api/private/signin.ts b/src/server/api/private/signin.ts new file mode 100644 index 0000000000..bbc9908991 --- /dev/null +++ b/src/server/api/private/signin.ts @@ -0,0 +1,91 @@ +import * as express from 'express'; +import * as bcrypt from 'bcryptjs'; +import * as speakeasy from 'speakeasy'; +import { default as User, ILocalAccount, IUser } from '../models/user'; +import Signin, { pack } from '../models/signin'; +import event from '../event'; +import signin from '../common/signin'; +import config from '../../../conf'; + +export default async (req: express.Request, res: express.Response) => { + res.header('Access-Control-Allow-Origin', config.url); + res.header('Access-Control-Allow-Credentials', 'true'); + + const username = req.body['username']; + const password = req.body['password']; + const token = req.body['token']; + + if (typeof username != 'string') { + res.sendStatus(400); + return; + } + + if (typeof password != 'string') { + res.sendStatus(400); + return; + } + + if (token != null && typeof token != 'string') { + res.sendStatus(400); + return; + } + + // Fetch user + const user: IUser = await User.findOne({ + username_lower: username.toLowerCase(), + host: null + }, { + fields: { + data: false, + 'account.profile': false + } + }); + + if (user === null) { + res.status(404).send({ + error: 'user not found' + }); + return; + } + + const account = user.account as ILocalAccount; + + // Compare password + const same = await bcrypt.compare(password, account.password); + + if (same) { + if (account.two_factor_enabled) { + const verified = (speakeasy as any).totp.verify({ + secret: account.two_factor_secret, + encoding: 'base32', + token: token + }); + + if (verified) { + signin(res, user, false); + } else { + res.status(400).send({ + error: 'invalid token' + }); + } + } else { + signin(res, user, false); + } + } else { + res.status(400).send({ + error: 'incorrect password' + }); + } + + // Append signin history + const record = await Signin.insert({ + created_at: new Date(), + user_id: user._id, + ip: req.ip, + headers: req.headers, + success: same + }); + + // Publish signin event + event(user._id, 'signin', await pack(record)); +}; diff --git a/src/server/api/private/signup.ts b/src/server/api/private/signup.ts new file mode 100644 index 0000000000..9f55393313 --- /dev/null +++ b/src/server/api/private/signup.ts @@ -0,0 +1,165 @@ +import * as uuid from 'uuid'; +import * as express from 'express'; +import * as bcrypt from 'bcryptjs'; +import { generate as generateKeypair } from '../../../crypto_key'; +import recaptcha = require('recaptcha-promise'); +import User, { IUser, validateUsername, validatePassword, pack } from '../models/user'; +import generateUserToken from '../common/generate-native-user-token'; +import config from '../../../conf'; + +recaptcha.init({ + secret_key: config.recaptcha.secret_key +}); + +const home = { + left: [ + 'profile', + 'calendar', + 'activity', + 'rss', + 'trends', + 'photo-stream', + 'version' + ], + right: [ + 'broadcast', + 'notifications', + 'users', + 'polls', + 'server', + 'donation', + 'nav', + 'tips' + ] +}; + +export default async (req: express.Request, res: express.Response) => { + // Verify recaptcha + // ただしテスト時はこの機構は障害となるため無効にする + if (process.env.NODE_ENV !== 'test') { + const success = await recaptcha(req.body['g-recaptcha-response']); + + if (!success) { + res.status(400).send('recaptcha-failed'); + return; + } + } + + const username = req.body['username']; + const password = req.body['password']; + const name = '名無し'; + + // Validate username + if (!validateUsername(username)) { + res.sendStatus(400); + return; + } + + // Validate password + if (!validatePassword(password)) { + res.sendStatus(400); + return; + } + + // Fetch exist user that same username + const usernameExist = await User + .count({ + username_lower: username.toLowerCase(), + host: null + }, { + limit: 1 + }); + + // Check username already used + if (usernameExist !== 0) { + res.sendStatus(400); + return; + } + + // Generate hash of password + 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({ + avatar_id: null, + banner_id: null, + created_at: new Date(), + description: null, + followers_count: 0, + following_count: 0, + name: name, + posts_count: 0, + likes_count: 0, + liked_count: 0, + drive_capacity: 1073741824, // 1GB + username: username, + username_lower: username.toLowerCase(), + host: null, + host_lower: null, + account: { + keypair: generateKeypair(), + token: secret, + email: null, + links: null, + password: hash, + profile: { + bio: null, + birthday: null, + blood: null, + gender: null, + handedness: null, + height: null, + location: null, + weight: null + }, + settings: { + auto_watch: true + }, + client_settings: { + home: homeData + } + } + }); + + // Response + res.send(await pack(account)); + + // Create search index + if (config.elasticsearch.enable) { + const es = require('../../db/elasticsearch'); + es.index({ + index: 'misskey', + type: 'user', + id: account._id.toString(), + body: { + username: username + } + }); + } +}; diff --git a/src/server/api/reply.ts b/src/server/api/reply.ts new file mode 100644 index 0000000000..e47fc85b9b --- /dev/null +++ b/src/server/api/reply.ts @@ -0,0 +1,13 @@ +import * as express from 'express'; + +export default (res: express.Response, x?: any, y?: any) => { + if (x === undefined) { + res.sendStatus(204); + } else if (typeof x === 'number') { + res.status(x).send({ + error: x === 500 ? 'INTERNAL_ERROR' : y + }); + } else { + res.send(x); + } +}; diff --git a/src/server/api/server.ts b/src/server/api/server.ts new file mode 100644 index 0000000000..e89d196096 --- /dev/null +++ b/src/server/api/server.ts @@ -0,0 +1,55 @@ +/** + * API Server + */ + +import * as express from 'express'; +import * as bodyParser from 'body-parser'; +import * as cors from 'cors'; +import * as multer from 'multer'; + +// import authenticate from './authenticate'; +import endpoints from './endpoints'; + +/** + * Init app + */ +const app = express(); + +app.disable('x-powered-by'); +app.set('etag', false); +app.use(bodyParser.urlencoded({ extended: true })); +app.use(bodyParser.json({ + type: ['application/json', 'text/plain'], + verify: (req, res, buf, encoding) => { + if (buf && buf.length) { + (req as any).rawBody = buf.toString(encoding || 'utf8'); + } + } +})); +app.use(cors()); + +app.get('/', (req, res) => { + res.send('YEE HAW'); +}); + +/** + * Register endpoint handlers + */ +endpoints.forEach(endpoint => + endpoint.withFile ? + app.post(`/${endpoint.name}`, + 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)) +); + +app.post('/signup', require('./private/signup').default); +app.post('/signin', require('./private/signin').default); + +require('./service/github')(app); +require('./service/twitter')(app); + +require('./bot/interfaces/line')(app); + +module.exports = app; diff --git a/src/server/api/service/github.ts b/src/server/api/service/github.ts new file mode 100644 index 0000000000..a33d359753 --- /dev/null +++ b/src/server/api/service/github.ts @@ -0,0 +1,124 @@ +import * as EventEmitter from 'events'; +import * as express from 'express'; +const crypto = require('crypto'); +import User from '../models/user'; +import config from '../../../conf'; +import queue from '../../../queue'; + +module.exports = async (app: express.Application) => { + if (config.github_bot == null) return; + + const bot = await User.findOne({ + username_lower: config.github_bot.username.toLowerCase() + }); + + if (bot == null) { + console.warn(`GitHub hook bot specified, but not found: @${config.github_bot.username}`); + return; + } + + const post = text => require('../endpoints/posts/create')({ text }, bot); + + const handler = new EventEmitter(); + + app.post('/hooks/github', (req, res, next) => { + // req.headers['x-hub-signature'] および + // req.headers['x-github-event'] は常に string ですが、型定義の都合上 + // string | string[] になっているので string を明示しています +// if ((new Buffer(req.headers['x-hub-signature'] as string)).equals(new Buffer(`sha1=${crypto.createHmac('sha1', config.github_bot.hook_secret).update(JSON.stringify(req.body)).digest('hex')}`))) { + handler.emit(req.headers['x-github-event'] as string, req.body); + res.sendStatus(200); +// } else { +// res.sendStatus(400); +// } + }); + + handler.on('status', event => { + const state = event.state; + switch (state) { + case 'error': + case 'failure': + const commit = event.commit; + const parent = commit.parents[0]; + + queue.create('gitHubFailureReport', { + userId: bot._id, + parentUrl: parent.url, + htmlUrl: commit.html_url, + message: commit.commit.message, + }).save(); + break; + } + }); + + handler.on('push', event => { + const ref = event.ref; + switch (ref) { + case 'refs/heads/master': + const pusher = event.pusher; + const compare = event.compare; + const commits = event.commits; + post([ + `Pushed by **${pusher.name}** with ?[${commits.length} commit${commits.length > 1 ? 's' : ''}](${compare}):`, + commits.reverse().map(commit => `・[?[${commit.id.substr(0, 7)}](${commit.url})] ${commit.message.split('\n')[0]}`).join('\n'), + ].join('\n')); + break; + case 'refs/heads/release': + const commit = event.commits[0]; + post(`RELEASED: ${commit.message}`); + break; + } + }); + + handler.on('issues', event => { + const issue = event.issue; + const action = event.action; + let title: string; + switch (action) { + case 'opened': title = 'Issue opened'; break; + case 'closed': title = 'Issue closed'; break; + case 'reopened': title = 'Issue reopened'; break; + default: return; + } + post(`${title}: <${issue.number}>「${issue.title}」\n${issue.html_url}`); + }); + + handler.on('issue_comment', event => { + const issue = event.issue; + const comment = event.comment; + const action = event.action; + let text: string; + switch (action) { + case 'created': text = `Commented to「${issue.title}」:${comment.user.login}「${comment.body}」\n${comment.html_url}`; break; + default: return; + } + post(text); + }); + + handler.on('watch', event => { + const sender = event.sender; + post(`⭐️ Starred by **${sender.login}** ⭐️`); + }); + + handler.on('fork', event => { + const repo = event.forkee; + post(`🍴 Forked:\n${repo.html_url} 🍴`); + }); + + handler.on('pull_request', event => { + const pr = event.pull_request; + const action = event.action; + let text: string; + switch (action) { + case 'opened': text = `New Pull Request:「${pr.title}」\n${pr.html_url}`; break; + case 'reopened': text = `Pull Request Reopened:「${pr.title}」\n${pr.html_url}`; break; + case 'closed': + text = pr.merged + ? `Pull Request Merged!:「${pr.title}」\n${pr.html_url}` + : `Pull Request Closed:「${pr.title}」\n${pr.html_url}`; + break; + default: return; + } + post(text); + }); +}; diff --git a/src/server/api/service/twitter.ts b/src/server/api/service/twitter.ts new file mode 100644 index 0000000000..861f63ed67 --- /dev/null +++ b/src/server/api/service/twitter.ts @@ -0,0 +1,176 @@ +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'; +import redis from '../../../db/redis'; +import User, { pack } from '../models/user'; +import event from '../event'; +import config from '../../../conf'; +import signin from '../common/signin'; + +module.exports = (app: express.Application) => { + function getUserToken(req: express.Request) { + // req.headers['cookie'] は常に string ですが、型定義の都合上 + // string | string[] になっているので string を明示しています + return ((req.headers['cookie'] as string || '').match(/i=(!\w+)/) || [null, null])[1]; + } + + function compareOrigin(req: express.Request) { + function normalizeUrl(url: string) { + return url[url.length - 1] === '/' ? url.substr(0, url.length - 1) : url; + } + + // req.headers['referer'] は常に string ですが、型定義の都合上 + // string | string[] になっているので string を明示しています + const referer = req.headers['referer'] as string; + + return (normalizeUrl(referer) == normalizeUrl(config.url)); + } + + app.get('/disconnect/twitter', async (req, res): Promise<any> => { + if (!compareOrigin(req)) { + res.status(400).send('invalid origin'); + return; + } + + const userToken = getUserToken(req); + if (userToken == null) return res.send('plz signin'); + + const user = await User.findOneAndUpdate({ + host: null, + 'account.token': userToken + }, { + $set: { + 'account.twitter': null + } + }); + + res.send(`Twitterの連携を解除しました :v:`); + + // Publish i updated event + event(user._id, 'i_updated', await pack(user, user, { + detail: true, + includeSecrets: true + })); + }); + + if (config.twitter == null) { + app.get('/connect/twitter', (req, res) => { + res.send('現在Twitterへ接続できません (このインスタンスではTwitterはサポートされていません)'); + }); + + app.get('/signin/twitter', (req, res) => { + res.send('現在Twitterへ接続できません (このインスタンスではTwitterはサポートされていません)'); + }); + + return; + } + + const twAuth = autwh({ + consumerKey: config.twitter.consumer_key, + consumerSecret: config.twitter.consumer_secret, + callbackUrl: `${config.api_url}/tw/cb` + }); + + app.get('/connect/twitter', async (req, res): Promise<any> => { + if (!compareOrigin(req)) { + res.status(400).send('invalid origin'); + return; + } + + const userToken = getUserToken(req); + if (userToken == null) return res.send('plz signin'); + + const ctx = await twAuth.begin(); + redis.set(userToken, JSON.stringify(ctx)); + 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 => { + const userToken = getUserToken(req); + + if (userToken == 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'); + return; + } + + redis.get(sessid, async (_, ctx) => { + const result = await twAuth.done(JSON.parse(ctx), req.query.oauth_verifier); + + const user = await User.findOne({ + host: null, + 'account.twitter.user_id': result.userId + }); + + if (user == null) { + res.status(404).send(`@${result.screenName}と連携しているMisskeyアカウントはありませんでした...`); + return; + } + + signin(res, user, true); + }); + } else { + const verifier = req.query.oauth_verifier; + + if (verifier == null) { + res.status(400).send('invalid session'); + return; + } + + redis.get(userToken, async (_, ctx) => { + const result = await twAuth.done(JSON.parse(ctx), verifier); + + const user = await User.findOneAndUpdate({ + host: null, + 'account.token': userToken + }, { + $set: { + 'account.twitter': { + access_token: result.accessToken, + access_token_secret: result.accessTokenSecret, + user_id: result.userId, + screen_name: result.screenName + } + } + }); + + res.send(`Twitter: @${result.screenName} を、Misskey: @${user.username} に接続しました!`); + + // Publish i updated event + event(user._id, 'i_updated', await pack(user, user, { + detail: true, + includeSecrets: true + })); + }); + } + }); +}; diff --git a/src/server/api/stream/channel.ts b/src/server/api/stream/channel.ts new file mode 100644 index 0000000000..d67d77cbf4 --- /dev/null +++ b/src/server/api/stream/channel.ts @@ -0,0 +1,12 @@ +import * as websocket from 'websocket'; +import * as redis from 'redis'; + +export default function(request: websocket.request, connection: websocket.connection, subscriber: redis.RedisClient): void { + const channel = request.resourceURL.query.channel; + + // Subscribe channel stream + subscriber.subscribe(`misskey:channel-stream:${channel}`); + subscriber.on('message', (_, data) => { + connection.send(data); + }); +} diff --git a/src/server/api/stream/drive.ts b/src/server/api/stream/drive.ts new file mode 100644 index 0000000000..c97ab80dcc --- /dev/null +++ b/src/server/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/server/api/stream/home.ts b/src/server/api/stream/home.ts new file mode 100644 index 0000000000..1ef0f33b4b --- /dev/null +++ b/src/server/api/stream/home.ts @@ -0,0 +1,95 @@ +import * as websocket from 'websocket'; +import * as redis from 'redis'; +import * as debug from 'debug'; + +import User from '../models/user'; +import Mute from '../models/mute'; +import { pack as packPost } from '../models/post'; +import readNotification from '../common/read-notification'; + +const log = debug('misskey'); + +export default async function(request: websocket.request, connection: websocket.connection, subscriber: redis.RedisClient, user: any) { + // Subscribe Home stream channel + subscriber.subscribe(`misskey:user-stream:${user._id}`); + + const mute = await Mute.find({ + muter_id: user._id, + deleted_at: { $exists: false } + }); + const mutedUserIds = mute.map(m => m.mutee_id.toString()); + + subscriber.on('message', async (channel, data) => { + switch (channel.split(':')[1]) { + case 'user-stream': + try { + const x = JSON.parse(data); + + if (x.type == 'post') { + if (mutedUserIds.indexOf(x.body.user_id) != -1) { + return; + } + if (x.body.reply != null && mutedUserIds.indexOf(x.body.reply.user_id) != -1) { + return; + } + if (x.body.repost != null && mutedUserIds.indexOf(x.body.repost.user_id) != -1) { + return; + } + } else if (x.type == 'notification') { + if (mutedUserIds.indexOf(x.body.user_id) != -1) { + return; + } + } + + connection.send(data); + } catch (e) { + connection.send(data); + } + break; + case 'post-stream': + const postId = channel.split(':')[2]; + log(`RECEIVED: ${postId} ${data} by @${user.username}`); + const post = await packPost(postId, user, { + detail: true + }); + connection.send(JSON.stringify({ + type: 'post-updated', + body: { + post: post + } + })); + break; + } + }); + + connection.on('message', data => { + const msg = JSON.parse(data.utf8Data); + + switch (msg.type) { + case 'api': + // TODO + break; + + case 'alive': + // Update lastUsedAt + User.update({ _id: user._id }, { + $set: { + 'account.last_used_at': new Date() + } + }); + break; + + case 'read_notification': + if (!msg.id) return; + readNotification(user._id, msg.id); + break; + + case 'capture': + if (!msg.id) return; + const postId = msg.id; + log(`CAPTURE: ${postId} by @${user.username}`); + subscriber.subscribe(`misskey:post-stream:${postId}`); + break; + } + }); +} diff --git a/src/server/api/stream/messaging-index.ts b/src/server/api/stream/messaging-index.ts new file mode 100644 index 0000000000..c1b2fbc806 --- /dev/null +++ b/src/server/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/server/api/stream/messaging.ts b/src/server/api/stream/messaging.ts new file mode 100644 index 0000000000..a4a12426a3 --- /dev/null +++ b/src/server/api/stream/messaging.ts @@ -0,0 +1,24 @@ +import * as websocket from 'websocket'; +import * as redis from 'redis'; +import read from '../common/read-messaging-message'; + +export default function(request: websocket.request, connection: websocket.connection, subscriber: redis.RedisClient, user: any): void { + const otherparty = request.resourceURL.query.otherparty; + + // Subscribe messaging stream + subscriber.subscribe(`misskey:messaging-stream:${user._id}-${otherparty}`); + subscriber.on('message', (_, data) => { + connection.send(data); + }); + + connection.on('message', async (data) => { + const msg = JSON.parse(data.utf8Data); + + switch (msg.type) { + case 'read': + if (!msg.id) return; + read(user._id, otherparty, msg.id); + break; + } + }); +} diff --git a/src/server/api/stream/othello-game.ts b/src/server/api/stream/othello-game.ts new file mode 100644 index 0000000000..1c846f27ae --- /dev/null +++ b/src/server/api/stream/othello-game.ts @@ -0,0 +1,331 @@ +import * as websocket from 'websocket'; +import * as redis from 'redis'; +import * as CRC32 from 'crc-32'; +import Game, { pack } from '../models/othello-game'; +import { publishOthelloGameStream } from '../event'; +import Othello from '../../common/othello/core'; +import * as maps from '../../common/othello/maps'; + +export default function(request: websocket.request, connection: websocket.connection, subscriber: redis.RedisClient, user?: any): void { + const gameId = request.resourceURL.query.game; + + // Subscribe game stream + subscriber.subscribe(`misskey:othello-game-stream:${gameId}`); + subscriber.on('message', (_, data) => { + connection.send(data); + }); + + connection.on('message', async (data) => { + const msg = JSON.parse(data.utf8Data); + + switch (msg.type) { + case 'accept': + accept(true); + break; + + case 'cancel-accept': + accept(false); + break; + + case 'update-settings': + if (msg.settings == null) return; + updateSettings(msg.settings); + break; + + case 'init-form': + if (msg.body == null) return; + initForm(msg.body); + break; + + case 'update-form': + if (msg.id == null || msg.value === undefined) return; + updateForm(msg.id, msg.value); + break; + + case 'message': + if (msg.body == null) return; + message(msg.body); + break; + + case 'set': + if (msg.pos == null) return; + set(msg.pos); + break; + + case 'check': + if (msg.crc32 == null) return; + check(msg.crc32); + break; + } + }); + + async function updateSettings(settings) { + const game = await Game.findOne({ _id: gameId }); + + if (game.is_started) return; + if (!game.user1_id.equals(user._id) && !game.user2_id.equals(user._id)) return; + if (game.user1_id.equals(user._id) && game.user1_accepted) return; + if (game.user2_id.equals(user._id) && game.user2_accepted) return; + + await Game.update({ _id: gameId }, { + $set: { + settings + } + }); + + publishOthelloGameStream(gameId, 'update-settings', settings); + } + + async function initForm(form) { + const game = await Game.findOne({ _id: gameId }); + + if (game.is_started) return; + if (!game.user1_id.equals(user._id) && !game.user2_id.equals(user._id)) return; + + const set = game.user1_id.equals(user._id) ? { + form1: form + } : { + form2: form + }; + + await Game.update({ _id: gameId }, { + $set: set + }); + + publishOthelloGameStream(gameId, 'init-form', { + user_id: user._id, + form + }); + } + + async function updateForm(id, value) { + const game = await Game.findOne({ _id: gameId }); + + if (game.is_started) return; + if (!game.user1_id.equals(user._id) && !game.user2_id.equals(user._id)) return; + + const form = game.user1_id.equals(user._id) ? game.form2 : game.form1; + + const item = form.find(i => i.id == id); + + if (item == null) return; + + item.value = value; + + const set = game.user1_id.equals(user._id) ? { + form2: form + } : { + form1: form + }; + + await Game.update({ _id: gameId }, { + $set: set + }); + + publishOthelloGameStream(gameId, 'update-form', { + user_id: user._id, + id, + value + }); + } + + async function message(message) { + message.id = Math.random(); + publishOthelloGameStream(gameId, 'message', { + user_id: user._id, + message + }); + } + + async function accept(accept: boolean) { + const game = await Game.findOne({ _id: gameId }); + + if (game.is_started) return; + + let bothAccepted = false; + + if (game.user1_id.equals(user._id)) { + await Game.update({ _id: gameId }, { + $set: { + user1_accepted: accept + } + }); + + publishOthelloGameStream(gameId, 'change-accepts', { + user1: accept, + user2: game.user2_accepted + }); + + if (accept && game.user2_accepted) bothAccepted = true; + } else if (game.user2_id.equals(user._id)) { + await Game.update({ _id: gameId }, { + $set: { + user2_accepted: accept + } + }); + + publishOthelloGameStream(gameId, 'change-accepts', { + user1: game.user1_accepted, + user2: accept + }); + + if (accept && game.user1_accepted) bothAccepted = true; + } else { + return; + } + + if (bothAccepted) { + // 3秒後、まだacceptされていたらゲーム開始 + setTimeout(async () => { + const freshGame = await Game.findOne({ _id: gameId }); + if (freshGame == null || freshGame.is_started || freshGame.is_ended) return; + if (!freshGame.user1_accepted || !freshGame.user2_accepted) return; + + let bw: number; + if (freshGame.settings.bw == 'random') { + bw = Math.random() > 0.5 ? 1 : 2; + } else { + bw = freshGame.settings.bw as number; + } + + function getRandomMap() { + const mapCount = Object.entries(maps).length; + const rnd = Math.floor(Math.random() * mapCount); + return Object.entries(maps).find((x, i) => i == rnd)[1].data; + } + + const map = freshGame.settings.map != null ? freshGame.settings.map : getRandomMap(); + + await Game.update({ _id: gameId }, { + $set: { + started_at: new Date(), + is_started: true, + black: bw, + 'settings.map': map + } + }); + + //#region 盤面に最初から石がないなどして始まった瞬間に勝敗が決定する場合があるのでその処理 + const o = new Othello(map, { + isLlotheo: freshGame.settings.is_llotheo, + canPutEverywhere: freshGame.settings.can_put_everywhere, + loopedBoard: freshGame.settings.looped_board + }); + + if (o.isEnded) { + let winner; + if (o.winner === true) { + winner = freshGame.black == 1 ? freshGame.user1_id : freshGame.user2_id; + } else if (o.winner === false) { + winner = freshGame.black == 1 ? freshGame.user2_id : freshGame.user1_id; + } else { + winner = null; + } + + await Game.update({ + _id: gameId + }, { + $set: { + is_ended: true, + winner_id: winner + } + }); + + publishOthelloGameStream(gameId, 'ended', { + winner_id: winner, + game: await pack(gameId, user) + }); + } + //#endregion + + publishOthelloGameStream(gameId, 'started', await pack(gameId, user)); + }, 3000); + } + } + + // 石を打つ + async function set(pos) { + const game = await Game.findOne({ _id: gameId }); + + if (!game.is_started) return; + if (game.is_ended) return; + if (!game.user1_id.equals(user._id) && !game.user2_id.equals(user._id)) return; + + const o = new Othello(game.settings.map, { + isLlotheo: game.settings.is_llotheo, + canPutEverywhere: game.settings.can_put_everywhere, + loopedBoard: game.settings.looped_board + }); + + game.logs.forEach(log => { + o.put(log.color, log.pos); + }); + + const myColor = + (game.user1_id.equals(user._id) && game.black == 1) || (game.user2_id.equals(user._id) && game.black == 2) + ? true + : false; + + if (!o.canPut(myColor, pos)) return; + o.put(myColor, pos); + + let winner; + if (o.isEnded) { + if (o.winner === true) { + winner = game.black == 1 ? game.user1_id : game.user2_id; + } else if (o.winner === false) { + winner = game.black == 1 ? game.user2_id : game.user1_id; + } else { + winner = null; + } + } + + const log = { + at: new Date(), + color: myColor, + pos + }; + + const crc32 = CRC32.str(game.logs.map(x => x.pos.toString()).join('') + pos.toString()); + + await Game.update({ + _id: gameId + }, { + $set: { + crc32, + is_ended: o.isEnded, + winner_id: winner + }, + $push: { + logs: log + } + }); + + publishOthelloGameStream(gameId, 'set', Object.assign(log, { + next: o.turn + })); + + if (o.isEnded) { + publishOthelloGameStream(gameId, 'ended', { + winner_id: winner, + game: await pack(gameId, user) + }); + } + } + + async function check(crc32) { + const game = await Game.findOne({ _id: gameId }); + + if (!game.is_started) return; + + // 互換性のため + if (game.crc32 == null) return; + + if (crc32 !== game.crc32) { + connection.send(JSON.stringify({ + type: 'rescue', + body: await pack(game, user) + })); + } + } +} diff --git a/src/server/api/stream/othello.ts b/src/server/api/stream/othello.ts new file mode 100644 index 0000000000..bd3b4a7637 --- /dev/null +++ b/src/server/api/stream/othello.ts @@ -0,0 +1,29 @@ +import * as mongo from 'mongodb'; +import * as websocket from 'websocket'; +import * as redis from 'redis'; +import Matching, { pack } from '../models/othello-matching'; +import publishUserStream from '../event'; + +export default function(request: websocket.request, connection: websocket.connection, subscriber: redis.RedisClient, user: any): void { + // Subscribe othello stream + subscriber.subscribe(`misskey:othello-stream:${user._id}`); + subscriber.on('message', (_, data) => { + connection.send(data); + }); + + connection.on('message', async (data) => { + const msg = JSON.parse(data.utf8Data); + + switch (msg.type) { + case 'ping': + if (msg.id == null) return; + const matching = await Matching.findOne({ + parent_id: user._id, + child_id: new mongo.ObjectID(msg.id) + }); + if (matching == null) return; + publishUserStream(matching.child_id, 'othello_invited', await pack(matching, matching.child_id)); + break; + } + }); +} diff --git a/src/server/api/stream/requests.ts b/src/server/api/stream/requests.ts new file mode 100644 index 0000000000..d7bb5e6c5c --- /dev/null +++ b/src/server/api/stream/requests.ts @@ -0,0 +1,19 @@ +import * as websocket from 'websocket'; +import Xev from 'xev'; + +const ev = new Xev(); + +export default function(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/server/api/stream/server.ts b/src/server/api/stream/server.ts new file mode 100644 index 0000000000..4ca2ad1b10 --- /dev/null +++ b/src/server/api/stream/server.ts @@ -0,0 +1,19 @@ +import * as websocket from 'websocket'; +import Xev from 'xev'; + +const ev = new Xev(); + +export default function(request: websocket.request, connection: websocket.connection): void { + const onStats = stats => { + connection.send(JSON.stringify({ + type: 'stats', + body: stats + })); + }; + + ev.addListener('stats', onStats); + + connection.on('close', () => { + ev.removeListener('stats', onStats); + }); +} diff --git a/src/server/api/streaming.ts b/src/server/api/streaming.ts new file mode 100644 index 0000000000..95f444e00b --- /dev/null +++ b/src/server/api/streaming.ts @@ -0,0 +1,118 @@ +import * as http from 'http'; +import * as websocket from 'websocket'; +import * as redis from 'redis'; +import config from '../../conf'; +import { default as User, IUser } from './models/user'; +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 othelloGameStream from './stream/othello-game'; +import othelloStream from './stream/othello'; +import serverStream from './stream/server'; +import requestsStream from './stream/requests'; +import channelStream from './stream/channel'; + +module.exports = (server: http.Server) => { + /** + * Init websocket server + */ + const ws = new websocket.server({ + httpServer: server + }); + + ws.on('request', async (request) => { + const connection = request.accept(); + + if (request.resourceURL.pathname === '/server') { + serverStream(request, connection); + return; + } + + if (request.resourceURL.pathname === '/requests') { + requestsStream(request, connection); + return; + } + + // Connect to Redis + const subscriber = redis.createClient( + config.redis.port, config.redis.host); + + connection.on('close', () => { + subscriber.unsubscribe(); + subscriber.quit(); + }); + + if (request.resourceURL.pathname === '/channel') { + channelStream(request, connection, subscriber); + return; + } + + const user = await authenticate(request.resourceURL.query.i); + + if (request.resourceURL.pathname === '/othello-game') { + othelloGameStream(request, connection, subscriber, user); + return; + } + + if (user == null) { + connection.send('authentication-failed'); + connection.close(); + return; + } + + const channel = + request.resourceURL.pathname === '/' ? homeStream : + request.resourceURL.pathname === '/drive' ? driveStream : + request.resourceURL.pathname === '/messaging' ? messagingStream : + request.resourceURL.pathname === '/messaging-index' ? messagingIndexStream : + request.resourceURL.pathname === '/othello' ? othelloStream : + null; + + if (channel !== null) { + channel(request, connection, subscriber, user); + } else { + connection.close(); + } + }); +}; + +/** + * 接続してきたユーザーを取得します + * @param token 送信されてきたトークン + */ +function authenticate(token: string): Promise<IUser> { + if (token == null) { + return Promise.resolve(null); + } + + return new Promise(async (resolve, reject) => { + if (isNativeToken(token)) { + // Fetch user + const user: IUser = await User + .findOne({ + host: null, + 'account.token': token + }); + + resolve(user); + } else { + const accessToken = await AccessToken.findOne({ + hash: token + }); + + if (accessToken == null) { + return reject('invalid signature'); + } + + // Fetch user + const user: IUser = await User + .findOne({ _id: accessToken.user_id }); + + resolve(user); + } + }); +} diff --git a/src/server/common/get-notification-summary.ts b/src/server/common/get-notification-summary.ts new file mode 100644 index 0000000000..03db722c84 --- /dev/null +++ b/src/server/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/server/common/get-post-summary.ts b/src/server/common/get-post-summary.ts new file mode 100644 index 0000000000..6e8f65708e --- /dev/null +++ b/src/server/common/get-post-summary.ts @@ -0,0 +1,45 @@ +/** + * 投稿を表す文字列を取得します。 + * @param {*} post 投稿 + */ +const summarize = (post: any): string => { + let summary = ''; + + // チャンネル + summary += post.channel ? `${post.channel.title}:` : ''; + + // 本文 + summary += post.text ? post.text : ''; + + // メディアが添付されているとき + if (post.media) { + summary += ` (${post.media.length}つのメディア)`; + } + + // 投票が添付されているとき + if (post.poll) { + summary += ' (投票)'; + } + + // 返信のとき + if (post.reply_id) { + if (post.reply) { + summary += ` RE: ${summarize(post.reply)}`; + } else { + summary += ' RE: ...'; + } + } + + // Repostのとき + if (post.repost_id) { + if (post.repost) { + summary += ` RP: ${summarize(post.repost)}`; + } else { + summary += ' RP: ...'; + } + } + + return summary.trim(); +}; + +export default summarize; diff --git a/src/server/common/get-reaction-emoji.ts b/src/server/common/get-reaction-emoji.ts new file mode 100644 index 0000000000..c661205379 --- /dev/null +++ b/src/server/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/server/common/othello/ai/back.ts b/src/server/common/othello/ai/back.ts new file mode 100644 index 0000000000..c20c6fed25 --- /dev/null +++ b/src/server/common/othello/ai/back.ts @@ -0,0 +1,376 @@ +/** + * -AI- + * Botのバックエンド(思考を担当) + * + * 対話と思考を同じプロセスで行うと、思考時間が長引いたときにストリームから + * 切断されてしまうので、別々のプロセスで行うようにします + */ + +import * as request from 'request-promise-native'; +import Othello, { Color } from '../core'; +import conf from '../../../../conf'; + +let game; +let form; + +/** + * BotアカウントのユーザーID + */ +const id = conf.othello_ai.id; + +/** + * BotアカウントのAPIキー + */ +const i = conf.othello_ai.i; + +let post; + +process.on('message', async msg => { + // 親プロセスからデータをもらう + if (msg.type == '_init_') { + game = msg.game; + form = msg.form; + } + + // フォームが更新されたとき + if (msg.type == 'update-form') { + form.find(i => i.id == msg.body.id).value = msg.body.value; + } + + // ゲームが始まったとき + if (msg.type == 'started') { + onGameStarted(msg.body); + + //#region TLに投稿する + const game = msg.body; + const url = `${conf.url}/othello/${game.id}`; + const user = game.user1_id == id ? game.user2 : game.user1; + const isSettai = form[0].value === 0; + const text = isSettai + ? `?[${user.name}](${conf.url}/@${user.username})さんの接待を始めました!` + : `対局を?[${user.name}](${conf.url}/@${user.username})さんと始めました! (強さ${form[0].value})`; + + const res = await request.post(`${conf.api_url}/posts/create`, { + json: { i, + text: `${text}\n→[観戦する](${url})` + } + }); + + post = res.created_post; + //#endregion + } + + // ゲームが終了したとき + if (msg.type == 'ended') { + // ストリームから切断 + process.send({ + type: 'close' + }); + + //#region TLに投稿する + const user = game.user1_id == id ? game.user2 : game.user1; + const isSettai = form[0].value === 0; + const text = isSettai + ? msg.body.winner_id === null + ? `?[${user.name}](${conf.url}/@${user.username})さんに接待で引き分けました...` + : msg.body.winner_id == id + ? `?[${user.name}](${conf.url}/@${user.username})さんに接待で勝ってしまいました...` + : `?[${user.name}](${conf.url}/@${user.username})さんに接待で負けてあげました♪` + : msg.body.winner_id === null + ? `?[${user.name}](${conf.url}/@${user.username})さんと引き分けました~` + : msg.body.winner_id == id + ? `?[${user.name}](${conf.url}/@${user.username})さんに勝ちました♪` + : `?[${user.name}](${conf.url}/@${user.username})さんに負けました...`; + + await request.post(`${conf.api_url}/posts/create`, { + json: { i, + repost_id: post.id, + text: text + } + }); + //#endregion + + process.exit(); + } + + // 打たれたとき + if (msg.type == 'set') { + onSet(msg.body); + } +}); + +let o: Othello; +let botColor: Color; + +// 各マスの強さ +let cellWeights; + +/** + * ゲーム開始時 + * @param g ゲーム情報 + */ +function onGameStarted(g) { + game = g; + + // オセロエンジン初期化 + o = new Othello(game.settings.map, { + isLlotheo: game.settings.is_llotheo, + canPutEverywhere: game.settings.can_put_everywhere, + loopedBoard: game.settings.looped_board + }); + + // 各マスの価値を計算しておく + cellWeights = o.map.map((pix, i) => { + if (pix == 'null') return 0; + const [x, y] = o.transformPosToXy(i); + let count = 0; + const get = (x, y) => { + if (x < 0 || y < 0 || x >= o.mapWidth || y >= o.mapHeight) return 'null'; + return o.mapDataGet(o.transformXyToPos(x, y)); + }; + + if (get(x , y - 1) == 'null') count++; + if (get(x + 1, y - 1) == 'null') count++; + if (get(x + 1, y ) == 'null') count++; + if (get(x + 1, y + 1) == 'null') count++; + if (get(x , y + 1) == 'null') count++; + if (get(x - 1, y + 1) == 'null') count++; + if (get(x - 1, y ) == 'null') count++; + if (get(x - 1, y - 1) == 'null') count++; + //return Math.pow(count, 3); + return count >= 4 ? 1 : 0; + }); + + botColor = game.user1_id == id && game.black == 1 || game.user2_id == id && game.black == 2; + + if (botColor) { + think(); + } +} + +function onSet(x) { + o.put(x.color, x.pos); + + if (x.next === botColor) { + think(); + } +} + +const db = {}; + +function think() { + console.log('Thinking...'); + console.time('think'); + + const isSettai = form[0].value === 0; + + // 接待モードのときは、全力(5手先読みくらい)で負けるようにする + const maxDepth = isSettai ? 5 : form[0].value; + + /** + * Botにとってある局面がどれだけ有利か取得する + */ + function staticEval() { + let score = o.canPutSomewhere(botColor).length; + + cellWeights.forEach((weight, i) => { + // 係数 + const coefficient = 30; + weight = weight * coefficient; + + const stone = o.board[i]; + if (stone === botColor) { + // TODO: 価値のあるマスに設置されている自分の石に縦か横に接するマスは価値があると判断する + score += weight; + } else if (stone !== null) { + score -= weight; + } + }); + + // ロセオならスコアを反転 + if (game.settings.is_llotheo) score = -score; + + // 接待ならスコアを反転 + if (isSettai) score = -score; + + return score; + } + + /** + * αβ法での探索 + */ + const dive = (pos: number, alpha = -Infinity, beta = Infinity, depth = 0): number => { + // 試し打ち + o.put(o.turn, pos); + + const key = o.board.toString(); + let cache = db[key]; + if (cache) { + if (alpha >= cache.upper) { + o.undo(); + return cache.upper; + } + if (beta <= cache.lower) { + o.undo(); + return cache.lower; + } + alpha = Math.max(alpha, cache.lower); + beta = Math.min(beta, cache.upper); + } else { + cache = { + upper: Infinity, + lower: -Infinity + }; + } + + const isBotTurn = o.turn === botColor; + + // 勝った + if (o.turn === null) { + const winner = o.winner; + + // 勝つことによる基本スコア + const base = 10000; + + let score; + + if (game.settings.is_llotheo) { + // 勝ちは勝ちでも、より自分の石を少なくした方が美しい勝ちだと判定する + score = o.winner ? base - (o.blackCount * 100) : base - (o.whiteCount * 100); + } else { + // 勝ちは勝ちでも、より相手の石を少なくした方が美しい勝ちだと判定する + score = o.winner ? base + (o.blackCount * 100) : base + (o.whiteCount * 100); + } + + // 巻き戻し + o.undo(); + + // 接待なら自分が負けた方が高スコア + return isSettai + ? winner !== botColor ? score : -score + : winner === botColor ? score : -score; + } + + if (depth === maxDepth) { + // 静的に評価 + const score = staticEval(); + + // 巻き戻し + o.undo(); + + return score; + } else { + const cans = o.canPutSomewhere(o.turn); + + let value = isBotTurn ? -Infinity : Infinity; + let a = alpha; + let b = beta; + + // 次のターンのプレイヤーにとって最も良い手を取得 + for (const p of cans) { + if (isBotTurn) { + const score = dive(p, a, beta, depth + 1); + value = Math.max(value, score); + a = Math.max(a, value); + if (value >= beta) break; + } else { + const score = dive(p, alpha, b, depth + 1); + value = Math.min(value, score); + b = Math.min(b, value); + if (value <= alpha) break; + } + } + + // 巻き戻し + o.undo(); + + if (value <= alpha) { + cache.upper = value; + } else if (value >= beta) { + cache.lower = value; + } else { + cache.upper = value; + cache.lower = value; + } + + db[key] = cache; + + return value; + } + }; + + /** + * αβ法での探索(キャッシュ無し)(デバッグ用) + */ + const dive2 = (pos: number, alpha = -Infinity, beta = Infinity, depth = 0): number => { + // 試し打ち + o.put(o.turn, pos); + + const isBotTurn = o.turn === botColor; + + // 勝った + if (o.turn === null) { + const winner = o.winner; + + // 勝つことによる基本スコア + const base = 10000; + + let score; + + if (game.settings.is_llotheo) { + // 勝ちは勝ちでも、より自分の石を少なくした方が美しい勝ちだと判定する + score = o.winner ? base - (o.blackCount * 100) : base - (o.whiteCount * 100); + } else { + // 勝ちは勝ちでも、より相手の石を少なくした方が美しい勝ちだと判定する + score = o.winner ? base + (o.blackCount * 100) : base + (o.whiteCount * 100); + } + + // 巻き戻し + o.undo(); + + // 接待なら自分が負けた方が高スコア + return isSettai + ? winner !== botColor ? score : -score + : winner === botColor ? score : -score; + } + + if (depth === maxDepth) { + // 静的に評価 + const score = staticEval(); + + // 巻き戻し + o.undo(); + + return score; + } else { + const cans = o.canPutSomewhere(o.turn); + + // 次のターンのプレイヤーにとって最も良い手を取得 + for (const p of cans) { + if (isBotTurn) { + alpha = Math.max(alpha, dive2(p, alpha, beta, depth + 1)); + } else { + beta = Math.min(beta, dive2(p, alpha, beta, depth + 1)); + } + if (alpha >= beta) break; + } + + // 巻き戻し + o.undo(); + + return isBotTurn ? alpha : beta; + } + }; + + const cans = o.canPutSomewhere(botColor); + const scores = cans.map(p => dive(p)); + const pos = cans[scores.indexOf(Math.max(...scores))]; + + console.log('Thinked:', pos); + console.timeEnd('think'); + + process.send({ + type: 'put', + pos + }); +} diff --git a/src/server/common/othello/ai/front.ts b/src/server/common/othello/ai/front.ts new file mode 100644 index 0000000000..af0b748fc0 --- /dev/null +++ b/src/server/common/othello/ai/front.ts @@ -0,0 +1,233 @@ +/** + * -AI- + * Botのフロントエンド(ストリームとの対話を担当) + * + * 対話と思考を同じプロセスで行うと、思考時間が長引いたときにストリームから + * 切断されてしまうので、別々のプロセスで行うようにします + */ + +import * as childProcess from 'child_process'; +const WebSocket = require('ws'); +import * as ReconnectingWebSocket from 'reconnecting-websocket'; +import * as request from 'request-promise-native'; +import conf from '../../../../conf'; + +// 設定 //////////////////////////////////////////////////////// + +/** + * BotアカウントのAPIキー + */ +const i = conf.othello_ai.i; + +/** + * BotアカウントのユーザーID + */ +const id = conf.othello_ai.id; + +//////////////////////////////////////////////////////////////// + +/** + * ホームストリーム + */ +const homeStream = new ReconnectingWebSocket(`${conf.ws_url}/?i=${i}`, undefined, { + constructor: WebSocket +}); + +homeStream.on('open', () => { + console.log('home stream opened'); +}); + +homeStream.on('close', () => { + console.log('home stream closed'); +}); + +homeStream.on('message', message => { + const msg = JSON.parse(message.toString()); + + // タイムライン上でなんか言われたまたは返信されたとき + if (msg.type == 'mention' || msg.type == 'reply') { + const post = msg.body; + + if (post.user_id == id) return; + + // リアクションする + request.post(`${conf.api_url}/posts/reactions/create`, { + json: { i, + post_id: post.id, + reaction: 'love' + } + }); + + if (post.text) { + if (post.text.indexOf('オセロ') > -1) { + request.post(`${conf.api_url}/posts/create`, { + json: { i, + reply_id: post.id, + text: '良いですよ~' + } + }); + + invite(post.user_id); + } + } + } + + // メッセージでなんか言われたとき + if (msg.type == 'messaging_message') { + const message = msg.body; + if (message.text) { + if (message.text.indexOf('オセロ') > -1) { + request.post(`${conf.api_url}/messaging/messages/create`, { + json: { i, + user_id: message.user_id, + text: '良いですよ~' + } + }); + + invite(message.user_id); + } + } + } +}); + +// ユーザーを対局に誘う +function invite(userId) { + request.post(`${conf.api_url}/othello/match`, { + json: { i, + user_id: userId + } + }); +} + +/** + * オセロストリーム + */ +const othelloStream = new ReconnectingWebSocket(`${conf.ws_url}/othello?i=${i}`, undefined, { + constructor: WebSocket +}); + +othelloStream.on('open', () => { + console.log('othello stream opened'); +}); + +othelloStream.on('close', () => { + console.log('othello stream closed'); +}); + +othelloStream.on('message', message => { + const msg = JSON.parse(message.toString()); + + // 招待されたとき + if (msg.type == 'invited') { + onInviteMe(msg.body.parent); + } + + // マッチしたとき + if (msg.type == 'matched') { + gameStart(msg.body); + } +}); + +/** + * ゲーム開始 + * @param game ゲーム情報 + */ +function gameStart(game) { + // ゲームストリームに接続 + const gw = new ReconnectingWebSocket(`${conf.ws_url}/othello-game?i=${i}&game=${game.id}`, undefined, { + constructor: WebSocket + }); + + gw.on('open', () => { + console.log('othello game stream opened'); + + // フォーム + const form = [{ + id: 'strength', + type: 'radio', + label: '強さ', + value: 2, + items: [{ + label: '接待', + value: 0 + }, { + label: '弱', + value: 1 + }, { + label: '中', + value: 2 + }, { + label: '強', + value: 3 + }, { + label: '最強', + value: 5 + }] + }]; + + //#region バックエンドプロセス開始 + const ai = childProcess.fork(__dirname + '/back.js'); + + // バックエンドプロセスに情報を渡す + ai.send({ + type: '_init_', + game, + form + }); + + ai.on('message', msg => { + if (msg.type == 'put') { + gw.send(JSON.stringify({ + type: 'set', + pos: msg.pos + })); + } else if (msg.type == 'close') { + gw.close(); + } + }); + + // ゲームストリームから情報が流れてきたらそのままバックエンドプロセスに伝える + gw.on('message', message => { + const msg = JSON.parse(message.toString()); + ai.send(msg); + }); + //#endregion + + // フォーム初期化 + setTimeout(() => { + gw.send(JSON.stringify({ + type: 'init-form', + body: form + })); + }, 1000); + + // どんな設定内容の対局でも受け入れる + setTimeout(() => { + gw.send(JSON.stringify({ + type: 'accept' + })); + }, 2000); + }); + + gw.on('close', () => { + console.log('othello game stream closed'); + }); +} + +/** + * オセロの対局に招待されたとき + * @param inviter 誘ってきたユーザー + */ +async function onInviteMe(inviter) { + console.log(`Someone invited me: @${inviter.username}`); + + // 承認 + const game = await request.post(`${conf.api_url}/othello/match`, { + json: { + i, + user_id: inviter.id + } + }); + + gameStart(game); +} diff --git a/src/server/common/othello/ai/index.ts b/src/server/common/othello/ai/index.ts new file mode 100644 index 0000000000..5cd1db82da --- /dev/null +++ b/src/server/common/othello/ai/index.ts @@ -0,0 +1 @@ +require('./front'); diff --git a/src/server/common/othello/core.ts b/src/server/common/othello/core.ts new file mode 100644 index 0000000000..217066d375 --- /dev/null +++ b/src/server/common/othello/core.ts @@ -0,0 +1,340 @@ +/** + * true ... 黒 + * false ... 白 + */ +export type Color = boolean; +const BLACK = true; +const WHITE = false; + +export type MapPixel = 'null' | 'empty'; + +export type Options = { + isLlotheo: boolean; + canPutEverywhere: boolean; + loopedBoard: boolean; +}; + +export type Undo = { + /** + * 色 + */ + color: Color, + + /** + * どこに打ったか + */ + pos: number; + + /** + * 反転した石の位置の配列 + */ + effects: number[]; + + /** + * ターン + */ + turn: Color; +}; + +/** + * オセロエンジン + */ +export default class Othello { + public map: MapPixel[]; + public mapWidth: number; + public mapHeight: number; + public board: Color[]; + public turn: Color = BLACK; + public opts: Options; + + public prevPos = -1; + public prevColor: Color = null; + + private logs: Undo[] = []; + + /** + * ゲームを初期化します + */ + constructor(map: string[], opts: Options) { + //#region binds + this.put = this.put.bind(this); + //#endregion + + //#region Options + this.opts = opts; + if (this.opts.isLlotheo == null) this.opts.isLlotheo = false; + if (this.opts.canPutEverywhere == null) this.opts.canPutEverywhere = false; + if (this.opts.loopedBoard == null) this.opts.loopedBoard = false; + //#endregion + + //#region Parse map data + this.mapWidth = map[0].length; + this.mapHeight = map.length; + const mapData = map.join(''); + + this.board = mapData.split('').map(d => { + if (d == '-') return null; + if (d == 'b') return BLACK; + if (d == 'w') return WHITE; + return undefined; + }); + + this.map = mapData.split('').map(d => { + if (d == '-' || d == 'b' || d == 'w') return 'empty'; + return 'null'; + }); + //#endregion + + // ゲームが始まった時点で片方の色の石しかないか、始まった時点で勝敗が決定するようなマップの場合がある + if (this.canPutSomewhere(BLACK).length == 0) { + if (this.canPutSomewhere(WHITE).length == 0) { + this.turn = null; + } else { + this.turn = WHITE; + } + } + } + + /** + * 黒石の数 + */ + public get blackCount() { + return this.board.filter(x => x === BLACK).length; + } + + /** + * 白石の数 + */ + public get whiteCount() { + return this.board.filter(x => x === WHITE).length; + } + + /** + * 黒石の比率 + */ + public get blackP() { + if (this.blackCount == 0 && this.whiteCount == 0) return 0; + return this.blackCount / (this.blackCount + this.whiteCount); + } + + /** + * 白石の比率 + */ + public get whiteP() { + if (this.blackCount == 0 && this.whiteCount == 0) return 0; + return this.whiteCount / (this.blackCount + this.whiteCount); + } + + public transformPosToXy(pos: number): number[] { + const x = pos % this.mapWidth; + const y = Math.floor(pos / this.mapWidth); + return [x, y]; + } + + public transformXyToPos(x: number, y: number): number { + return x + (y * this.mapWidth); + } + + /** + * 指定のマスに石を打ちます + * @param color 石の色 + * @param pos 位置 + */ + public put(color: Color, pos: number) { + this.prevPos = pos; + this.prevColor = color; + + this.board[pos] = color; + + // 反転させられる石を取得 + const effects = this.effects(color, pos); + + // 反転させる + for (const pos of effects) { + this.board[pos] = color; + } + + const turn = this.turn; + + this.logs.push({ + color, + pos, + effects, + turn + }); + + this.calcTurn(); + } + + private calcTurn() { + // ターン計算 + if (this.canPutSomewhere(!this.prevColor).length > 0) { + this.turn = !this.prevColor; + } else if (this.canPutSomewhere(this.prevColor).length > 0) { + this.turn = this.prevColor; + } else { + this.turn = null; + } + } + + public undo() { + const undo = this.logs.pop(); + this.prevColor = undo.color; + this.prevPos = undo.pos; + this.board[undo.pos] = null; + for (const pos of undo.effects) { + const color = this.board[pos]; + this.board[pos] = !color; + } + this.turn = undo.turn; + } + + /** + * 指定した位置のマップデータのマスを取得します + * @param pos 位置 + */ + public mapDataGet(pos: number): MapPixel { + const [x, y] = this.transformPosToXy(pos); + if (x < 0 || y < 0 || x >= this.mapWidth || y >= this.mapHeight) return 'null'; + return this.map[pos]; + } + + /** + * 打つことができる場所を取得します + */ + public canPutSomewhere(color: Color): number[] { + const result = []; + + this.board.forEach((x, i) => { + if (this.canPut(color, i)) result.push(i); + }); + + return result; + } + + /** + * 指定のマスに石を打つことができるかどうかを取得します + * @param color 自分の色 + * @param pos 位置 + */ + public canPut(color: Color, pos: number): boolean { + // 既に石が置いてある場所には打てない + if (this.board[pos] !== null) return false; + + if (this.opts.canPutEverywhere) { + // 挟んでなくても置けるモード + return this.mapDataGet(pos) == 'empty'; + } else { + // 相手の石を1つでも反転させられるか + return this.effects(color, pos).length !== 0; + } + } + + /** + * 指定のマスに石を置いた時の、反転させられる石を取得します + * @param color 自分の色 + * @param pos 位置 + */ + public effects(color: Color, pos: number): number[] { + const enemyColor = !color; + + // ひっくり返せる石(の位置)リスト + let stones = []; + + const initPos = pos; + + // 走査 + const iterate = (fn: (i: number) => number[]) => { + let i = 1; + const found = []; + + while (true) { + let [x, y] = fn(i); + + // 座標が指し示す位置がボード外に出たとき + if (this.opts.loopedBoard) { + if (x < 0 ) x = this.mapWidth - ((-x) % this.mapWidth); + if (y < 0 ) y = this.mapHeight - ((-y) % this.mapHeight); + if (x >= this.mapWidth ) x = x % this.mapWidth; + if (y >= this.mapHeight) y = y % this.mapHeight; + + // for debug + //if (x < 0 || y < 0 || x >= this.mapWidth || y >= this.mapHeight) { + // console.log(x, y); + //} + + // 一周して自分に帰ってきたら + if (this.transformXyToPos(x, y) == initPos) { + // ↓のコメントアウトを外すと、「現時点で自分の石が隣接していないが、 + // そこに置いたとするとループして最終的に挟んだことになる」というケースを有効化します。(Test4のマップで違いが分かります) + // このケースを有効にした方が良いのか無効にした方が良いのか判断がつかなかったためとりあえず無効としておきます + // (あと無効な方がゲームとしておもしろそうだった) + stones = stones.concat(found); + break; + } + } else { + if (x == -1 || y == -1 || x == this.mapWidth || y == this.mapHeight) break; + } + + const pos = this.transformXyToPos(x, y); + + //#region 「配置不能」マスに当たった場合走査終了 + const pixel = this.mapDataGet(pos); + if (pixel == 'null') break; + //#endregion + + // 石取得 + const stone = this.board[pos]; + + // 石が置かれていないマスなら走査終了 + if (stone === null) break; + + // 相手の石なら「ひっくり返せるかもリスト」に入れておく + if (stone === enemyColor) found.push(pos); + + // 自分の石なら「ひっくり返せるかもリスト」を「ひっくり返せるリスト」に入れ、走査終了 + if (stone === color) { + stones = stones.concat(found); + break; + } + + i++; + } + }; + + const [x, y] = this.transformPosToXy(pos); + + iterate(i => [x , y - i]); // 上 + iterate(i => [x + i, y - i]); // 右上 + iterate(i => [x + i, y ]); // 右 + iterate(i => [x + i, y + i]); // 右下 + iterate(i => [x , y + i]); // 下 + iterate(i => [x - i, y + i]); // 左下 + iterate(i => [x - i, y ]); // 左 + iterate(i => [x - i, y - i]); // 左上 + + return stones; + } + + /** + * ゲームが終了したか否か + */ + public get isEnded(): boolean { + return this.turn === null; + } + + /** + * ゲームの勝者 (null = 引き分け) + */ + public get winner(): Color { + if (!this.isEnded) return undefined; + + if (this.blackCount == this.whiteCount) return null; + + if (this.opts.isLlotheo) { + return this.blackCount > this.whiteCount ? WHITE : BLACK; + } else { + return this.blackCount > this.whiteCount ? BLACK : WHITE; + } + } +} diff --git a/src/server/common/othello/maps.ts b/src/server/common/othello/maps.ts new file mode 100644 index 0000000000..68e5a446f1 --- /dev/null +++ b/src/server/common/othello/maps.ts @@ -0,0 +1,911 @@ +/** + * 組み込みマップ定義 + * + * データ値: + * (スペース) ... マス無し + * - ... マス + * b ... 初期配置される黒石 + * w ... 初期配置される白石 + */ + +export type Map = { + name?: string; + category?: string; + author?: string; + data: string[]; +}; + +export const fourfour: Map = { + name: '4x4', + category: '4x4', + data: [ + '----', + '-wb-', + '-bw-', + '----' + ] +}; + +export const sixsix: Map = { + name: '6x6', + category: '6x6', + data: [ + '------', + '------', + '--wb--', + '--bw--', + '------', + '------' + ] +}; + +export const roundedSixsix: Map = { + name: '6x6 rounded', + category: '6x6', + author: 'syuilo', + data: [ + ' ---- ', + '------', + '--wb--', + '--bw--', + '------', + ' ---- ' + ] +}; + +export const roundedSixsix2: Map = { + name: '6x6 rounded 2', + category: '6x6', + author: 'syuilo', + data: [ + ' -- ', + ' ---- ', + '--wb--', + '--bw--', + ' ---- ', + ' -- ' + ] +}; + +export const eighteight: Map = { + name: '8x8', + category: '8x8', + data: [ + '--------', + '--------', + '--------', + '---wb---', + '---bw---', + '--------', + '--------', + '--------' + ] +}; + +export const eighteightH1: Map = { + name: '8x8 handicap 1', + category: '8x8', + data: [ + 'b-------', + '--------', + '--------', + '---wb---', + '---bw---', + '--------', + '--------', + '--------' + ] +}; + +export const eighteightH2: Map = { + name: '8x8 handicap 2', + category: '8x8', + data: [ + 'b-------', + '--------', + '--------', + '---wb---', + '---bw---', + '--------', + '--------', + '-------b' + ] +}; + +export const eighteightH3: Map = { + name: '8x8 handicap 3', + category: '8x8', + data: [ + 'b------b', + '--------', + '--------', + '---wb---', + '---bw---', + '--------', + '--------', + '-------b' + ] +}; + +export const eighteightH4: Map = { + name: '8x8 handicap 4', + category: '8x8', + data: [ + 'b------b', + '--------', + '--------', + '---wb---', + '---bw---', + '--------', + '--------', + 'b------b' + ] +}; + +export const eighteightH12: Map = { + name: '8x8 handicap 12', + category: '8x8', + data: [ + 'bb----bb', + 'b------b', + '--------', + '---wb---', + '---bw---', + '--------', + 'b------b', + 'bb----bb' + ] +}; + +export const eighteightH16: Map = { + name: '8x8 handicap 16', + category: '8x8', + data: [ + 'bbb---bb', + 'b------b', + '-------b', + '---wb---', + '---bw---', + 'b-------', + 'b------b', + 'bb---bbb' + ] +}; + +export const eighteightH20: Map = { + name: '8x8 handicap 20', + category: '8x8', + data: [ + 'bbb--bbb', + 'b------b', + 'b------b', + '---wb---', + '---bw---', + 'b------b', + 'b------b', + 'bbb---bb' + ] +}; + +export const eighteightH28: Map = { + name: '8x8 handicap 28', + category: '8x8', + data: [ + 'bbbbbbbb', + 'b------b', + 'b------b', + 'b--wb--b', + 'b--bw--b', + 'b------b', + 'b------b', + 'bbbbbbbb' + ] +}; + +export const roundedEighteight: Map = { + name: '8x8 rounded', + category: '8x8', + author: 'syuilo', + data: [ + ' ------ ', + '--------', + '--------', + '---wb---', + '---bw---', + '--------', + '--------', + ' ------ ' + ] +}; + +export const roundedEighteight2: Map = { + name: '8x8 rounded 2', + category: '8x8', + author: 'syuilo', + data: [ + ' ---- ', + ' ------ ', + '--------', + '---wb---', + '---bw---', + '--------', + ' ------ ', + ' ---- ' + ] +}; + +export const roundedEighteight3: Map = { + name: '8x8 rounded 3', + category: '8x8', + author: 'syuilo', + data: [ + ' -- ', + ' ---- ', + ' ------ ', + '---wb---', + '---bw---', + ' ------ ', + ' ---- ', + ' -- ' + ] +}; + +export const eighteightWithNotch: Map = { + name: '8x8 with notch', + category: '8x8', + author: 'syuilo', + data: [ + '--- ---', + '--------', + '--------', + ' --wb-- ', + ' --bw-- ', + '--------', + '--------', + '--- ---' + ] +}; + +export const eighteightWithSomeHoles: Map = { + name: '8x8 with some holes', + category: '8x8', + author: 'syuilo', + data: [ + '--- ----', + '----- --', + '-- -----', + '---wb---', + '---bw- -', + ' -------', + '--- ----', + '--------' + ] +}; + +export const circle: Map = { + name: 'Circle', + category: '8x8', + author: 'syuilo', + data: [ + ' -- ', + ' ------ ', + ' ------ ', + '---wb---', + '---bw---', + ' ------ ', + ' ------ ', + ' -- ' + ] +}; + +export const smile: Map = { + name: 'Smile', + category: '8x8', + author: 'syuilo', + data: [ + ' ------ ', + '--------', + '-- -- --', + '---wb---', + '-- bw --', + '--- ---', + '--------', + ' ------ ' + ] +}; + +export const window: Map = { + name: 'Window', + category: '8x8', + author: 'syuilo', + data: [ + '--------', + '- -- -', + '- -- -', + '---wb---', + '---bw---', + '- -- -', + '- -- -', + '--------' + ] +}; + +export const reserved: Map = { + name: 'Reserved', + category: '8x8', + author: 'Aya', + data: [ + 'w------b', + '--------', + '--------', + '---wb---', + '---bw---', + '--------', + '--------', + 'b------w' + ] +}; + +export const x: Map = { + name: 'X', + category: '8x8', + author: 'Aya', + data: [ + 'w------b', + '-w----b-', + '--w--b--', + '---wb---', + '---bw---', + '--b--w--', + '-b----w-', + 'b------w' + ] +}; + +export const parallel: Map = { + name: 'Parallel', + category: '8x8', + author: 'Aya', + data: [ + '--------', + '--------', + '--------', + '---bb---', + '---ww---', + '--------', + '--------', + '--------' + ] +}; + +export const lackOfBlack: Map = { + name: 'Lack of Black', + category: '8x8', + data: [ + '--------', + '--------', + '--------', + '---w----', + '---bw---', + '--------', + '--------', + '--------' + ] +}; + +export const squareParty: Map = { + name: 'Square Party', + category: '8x8', + author: 'syuilo', + data: [ + '--------', + '-wwwbbb-', + '-w-wb-b-', + '-wwwbbb-', + '-bbbwww-', + '-b-bw-w-', + '-bbbwww-', + '--------' + ] +}; + +export const minesweeper: Map = { + name: 'Minesweeper', + category: '8x8', + author: 'syuilo', + data: [ + 'b-b--w-w', + '-w-wb-b-', + 'w-b--w-b', + '-b-wb-w-', + '-w-bw-b-', + 'b-w--b-w', + '-b-bw-w-', + 'w-w--b-b' + ] +}; + +export const tenthtenth: Map = { + name: '10x10', + category: '10x10', + data: [ + '----------', + '----------', + '----------', + '----------', + '----wb----', + '----bw----', + '----------', + '----------', + '----------', + '----------' + ] +}; + +export const hole: Map = { + name: 'The Hole', + category: '10x10', + author: 'syuilo', + data: [ + '----------', + '----------', + '--wb--wb--', + '--bw--bw--', + '---- ----', + '---- ----', + '--wb--wb--', + '--bw--bw--', + '----------', + '----------' + ] +}; + +export const grid: Map = { + name: 'Grid', + category: '10x10', + author: 'syuilo', + data: [ + '----------', + '- - -- - -', + '----------', + '- - -- - -', + '----wb----', + '----bw----', + '- - -- - -', + '----------', + '- - -- - -', + '----------' + ] +}; + +export const cross: Map = { + name: 'Cross', + category: '10x10', + author: 'Aya', + data: [ + ' ---- ', + ' ---- ', + ' ---- ', + '----------', + '----wb----', + '----bw----', + '----------', + ' ---- ', + ' ---- ', + ' ---- ' + ] +}; + +export const charX: Map = { + name: 'Char X', + category: '10x10', + author: 'syuilo', + data: [ + '--- ---', + '---- ----', + '----------', + ' -------- ', + ' --wb-- ', + ' --bw-- ', + ' -------- ', + '----------', + '---- ----', + '--- ---' + ] +}; + +export const charY: Map = { + name: 'Char Y', + category: '10x10', + author: 'syuilo', + data: [ + '--- ---', + '---- ----', + '----------', + ' -------- ', + ' --wb-- ', + ' --bw-- ', + ' ------ ', + ' ------ ', + ' ------ ', + ' ------ ' + ] +}; + +export const walls: Map = { + name: 'Walls', + category: '10x10', + author: 'Aya', + data: [ + ' bbbbbbbb ', + 'w--------w', + 'w--------w', + 'w--------w', + 'w---wb---w', + 'w---bw---w', + 'w--------w', + 'w--------w', + 'w--------w', + ' bbbbbbbb ' + ] +}; + +export const cpu: Map = { + name: 'CPU', + category: '10x10', + author: 'syuilo', + data: [ + ' b b b b ', + 'w--------w', + ' -------- ', + 'w--------w', + ' ---wb--- ', + ' ---bw--- ', + 'w--------w', + ' -------- ', + 'w--------w', + ' b b b b ' + ] +}; + +export const checker: Map = { + name: 'Checker', + category: '10x10', + author: 'Aya', + data: [ + '----------', + '----------', + '----------', + '---wbwb---', + '---bwbw---', + '---wbwb---', + '---bwbw---', + '----------', + '----------', + '----------' + ] +}; + +export const japaneseCurry: Map = { + name: 'Japanese curry', + category: '10x10', + author: 'syuilo', + data: [ + 'w-b-b-b-b-', + '-w-b-b-b-b', + 'w-w-b-b-b-', + '-w-w-b-b-b', + 'w-w-wwb-b-', + '-w-wbb-b-b', + 'w-w-w-b-b-', + '-w-w-w-b-b', + 'w-w-w-w-b-', + '-w-w-w-w-b' + ] +}; + +export const mosaic: Map = { + name: 'Mosaic', + category: '10x10', + author: 'syuilo', + data: [ + '- - - - - ', + ' - - - - -', + '- - - - - ', + ' - w w - -', + '- - b b - ', + ' - w w - -', + '- - b b - ', + ' - - - - -', + '- - - - - ', + ' - - - - -', + ] +}; + +export const arena: Map = { + name: 'Arena', + category: '10x10', + author: 'syuilo', + data: [ + '- - -- - -', + ' - - - - ', + '- ------ -', + ' -------- ', + '- --wb-- -', + '- --bw-- -', + ' -------- ', + '- ------ -', + ' - - - - ', + '- - -- - -' + ] +}; + +export const reactor: Map = { + name: 'Reactor', + category: '10x10', + author: 'syuilo', + data: [ + '-w------b-', + 'b- - - -w', + '- --wb-- -', + '---b w---', + '- b wb w -', + '- w bw b -', + '---w b---', + '- --bw-- -', + 'w- - - -b', + '-b------w-' + ] +}; + +export const sixeight: Map = { + name: '6x8', + category: 'Special', + data: [ + '------', + '------', + '------', + '--wb--', + '--bw--', + '------', + '------', + '------' + ] +}; + +export const spark: Map = { + name: 'Spark', + category: 'Special', + author: 'syuilo', + data: [ + ' - - ', + '----------', + ' -------- ', + ' -------- ', + ' ---wb--- ', + ' ---bw--- ', + ' -------- ', + ' -------- ', + '----------', + ' - - ' + ] +}; + +export const islands: Map = { + name: 'Islands', + category: 'Special', + author: 'syuilo', + data: [ + '-------- ', + '---wb--- ', + '---bw--- ', + '-------- ', + ' - - ', + ' - - ', + ' --------', + ' --------', + ' --------', + ' --------' + ] +}; + +export const galaxy: Map = { + name: 'Galaxy', + category: 'Special', + author: 'syuilo', + data: [ + ' ------ ', + ' --www--- ', + ' ------w--- ', + '---bbb--w---', + '--b---b-w-b-', + '-b--wwb-w-b-', + '-b-w-bww--b-', + '-b-w-b---b--', + '---w--bbb---', + ' ---w------ ', + ' ---www-- ', + ' ------ ' + ] +}; + +export const triangle: Map = { + name: 'Triangle', + category: 'Special', + author: 'syuilo', + data: [ + ' -- ', + ' -- ', + ' ---- ', + ' ---- ', + ' --wb-- ', + ' --bw-- ', + ' -------- ', + ' -------- ', + '----------', + '----------' + ] +}; + +export const iphonex: Map = { + name: 'iPhone X', + category: 'Special', + author: 'syuilo', + data: [ + ' -- -- ', + '--------', + '--------', + '--------', + '--------', + '---wb---', + '---bw---', + '--------', + '--------', + '--------', + '--------', + ' ------ ' + ] +}; + +export const dealWithIt: Map = { + name: 'Deal with it!', + category: 'Special', + author: 'syuilo', + data: [ + '------------', + '--w-b-------', + ' --b-w------', + ' --w-b---- ', + ' ------- ' + ] +}; + +export const experiment: Map = { + name: 'Let\'s experiment', + category: 'Special', + author: 'syuilo', + data: [ + ' ------------ ', + '------wb------', + '------bw------', + '--------------', + ' - - ', + '------ ------', + 'bbbbbb wwwwww', + 'bbbbbb wwwwww', + 'bbbbbb wwwwww', + 'bbbbbb wwwwww', + 'wwwwww bbbbbb' + ] +}; + +export const bigBoard: Map = { + name: 'Big board', + category: 'Special', + data: [ + '----------------', + '----------------', + '----------------', + '----------------', + '----------------', + '----------------', + '----------------', + '-------wb-------', + '-------bw-------', + '----------------', + '----------------', + '----------------', + '----------------', + '----------------', + '----------------', + '----------------' + ] +}; + +export const twoBoard: Map = { + name: 'Two board', + category: 'Special', + author: 'Aya', + data: [ + '-------- --------', + '-------- --------', + '-------- --------', + '---wb--- ---wb---', + '---bw--- ---bw---', + '-------- --------', + '-------- --------', + '-------- --------' + ] +}; + +export const test1: Map = { + name: 'Test1', + category: 'Test', + data: [ + '--------', + '---wb---', + '---bw---', + '--------' + ] +}; + +export const test2: Map = { + name: 'Test2', + category: 'Test', + data: [ + '------', + '------', + '-b--w-', + '-w--b-', + '-w--b-' + ] +}; + +export const test3: Map = { + name: 'Test3', + category: 'Test', + data: [ + '-w-', + '--w', + 'w--', + '-w-', + '--w', + 'w--', + '-w-', + '--w', + 'w--', + '-w-', + '---', + 'b--', + ] +}; + +export const test4: Map = { + name: 'Test4', + category: 'Test', + data: [ + '-w--b-', + '-w--b-', + '------', + '-w--b-', + '-w--b-' + ] +}; + +// https://misskey.xyz/othello/5aaabf7fe126e10b5216ea09 64 +export const test5: Map = { + name: 'Test5', + category: 'Test', + data: [ + '--wwwwww--', + '--wwwbwwww', + '-bwwbwbwww', + '-bwwwbwbww', + '-bwwbwbwbw', + '-bwbwbwb-w', + 'bwbwwbbb-w', + 'w-wbbbbb--', + '--w-b-w---', + '----------' + ] +}; diff --git a/src/server/common/user/get-acct.ts b/src/server/common/user/get-acct.ts new file mode 100644 index 0000000000..9afb03d88b --- /dev/null +++ b/src/server/common/user/get-acct.ts @@ -0,0 +1,3 @@ +export default user => { + return user.host === null ? user.username : `${user.username}@${user.host}`; +}; diff --git a/src/server/common/user/get-summary.ts b/src/server/common/user/get-summary.ts new file mode 100644 index 0000000000..f9b7125e30 --- /dev/null +++ b/src/server/common/user/get-summary.ts @@ -0,0 +1,18 @@ +import { ILocalAccount, IUser } from '../../api/models/user'; +import getAcct from './get-acct'; + +/** + * ユーザーを表す文字列を取得します。 + * @param user ユーザー + */ +export default function(user: IUser): string { + let string = `${user.name} (@${getAcct(user)})\n` + + `${user.posts_count}投稿、${user.following_count}フォロー、${user.followers_count}フォロワー\n`; + + if (user.host === null) { + const account = user.account as ILocalAccount; + string += `場所: ${account.profile.location}、誕生日: ${account.profile.birthday}\n`; + } + + return string + `「${user.description}」`; +} diff --git a/src/server/common/user/parse-acct.ts b/src/server/common/user/parse-acct.ts new file mode 100644 index 0000000000..ef1f55405d --- /dev/null +++ b/src/server/common/user/parse-acct.ts @@ -0,0 +1,4 @@ +export default acct => { + const splitted = acct.split('@', 2); + return { username: splitted[0], host: splitted[1] || null }; +}; diff --git a/src/server/file/assets/avatar.jpg b/src/server/file/assets/avatar.jpg Binary files differnew file mode 100644 index 0000000000..3c803f568e --- /dev/null +++ b/src/server/file/assets/avatar.jpg diff --git a/src/server/file/assets/bad-egg.png b/src/server/file/assets/bad-egg.png Binary files differnew file mode 100644 index 0000000000..a7c5930bd4 --- /dev/null +++ b/src/server/file/assets/bad-egg.png diff --git a/src/server/file/assets/dummy.png b/src/server/file/assets/dummy.png Binary files differnew file mode 100644 index 0000000000..39332b0c1b --- /dev/null +++ b/src/server/file/assets/dummy.png diff --git a/src/server/file/assets/not-an-image.png b/src/server/file/assets/not-an-image.png Binary files differnew file mode 100644 index 0000000000..bf98b293f7 --- /dev/null +++ b/src/server/file/assets/not-an-image.png diff --git a/src/server/file/assets/thumbnail-not-available.png b/src/server/file/assets/thumbnail-not-available.png Binary files differnew file mode 100644 index 0000000000..f960ce4d00 --- /dev/null +++ b/src/server/file/assets/thumbnail-not-available.png diff --git a/src/server/file/server.ts b/src/server/file/server.ts new file mode 100644 index 0000000000..3bda5b14fe --- /dev/null +++ b/src/server/file/server.ts @@ -0,0 +1,168 @@ +/** + * File Server + */ + +import * as fs from 'fs'; +import * as express from 'express'; +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 DriveFile, { getGridFSBucket } from '../api/models/drive-file'; + +const gm = _gm.subClass({ + imageMagick: true +}); + +/** + * Init app + */ +const app = express(); + +app.disable('x-powered-by'); +app.locals.cache = true; +app.use(bodyParser.urlencoded({ extended: true })); +app.use(cors()); + +/** + * Statics + */ +app.use('/assets', express.static(`${__dirname}/assets`, { + maxAge: 1000 * 60 * 60 * 24 * 365 // 一年 +})); + +app.get('/', (req, res) => { + res.send('yee haw'); +}); + +app.get('/default-avatar.jpg', (req, res) => { + const file = fs.createReadStream(`${__dirname}/assets/avatar.jpg`); + send(file, 'image/jpeg', req, res); +}); + +app.get('/app-default.jpg', (req, res) => { + const file = fs.createReadStream(`${__dirname}/assets/dummy.png`); + send(file, 'image/png', req, res); +}); + +interface ISend { + contentType: string; + stream: stream.Readable; +} + +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`); + } + + const imageType = type.split('/')[1]; + + // 画像でもPNGかJPEGでないならダメ + if (imageType != 'png' && imageType != 'jpeg') { + // 使わないことにしたストリームはしっかり取り壊しておく + data.destroy(); + return fs.createReadStream(`${__dirname}/assets/thumbnail-not-available.png`); + } + + return data; + })(); + + let g = gm(readable); + + if (resize) { + g = g.resize(resize, resize); + } + + const stream = g + .compress('jpeg') + .quality(80) + .interlace('line') + .noProfile() // Remove EXIF + .stream(); + + return { + contentType: 'image/jpeg', + stream + }; +} + +const commonReadableHandlerGenerator = (req: express.Request, res: express.Response) => (e: Error): void => { + console.dir(e); + req.destroy(); + res.destroy(e); +}; + +function send(readable: stream.Readable, type: string, req: express.Request, res: express.Response): void { + readable.on('error', commonReadableHandlerGenerator(req, res)); + + const data = ((): ISend => { + if (req.query.thumbnail !== undefined) { + return thumbnail(readable, type, req.query.size); + } + return { + contentType: type, + stream: readable + }; + })(); + + if (readable !== data.stream) { + data.stream.on('error', commonReadableHandlerGenerator(req, res)); + } + + if (req.query.download !== undefined) { + res.header('Content-Disposition', 'attachment'); + } + + res.header('Content-Type', data.contentType); + + data.stream.pipe(res); + + data.stream.on('end', () => { + res.end(); + }); +} + +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 fileId = new mongodb.ObjectID(req.params.id); + + // Fetch (drive) file + 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; + } + + 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/server/index.ts b/src/server/index.ts new file mode 100644 index 0000000000..3908b8a52c --- /dev/null +++ b/src/server/index.ts @@ -0,0 +1,82 @@ +/** + * Core Server + */ + +import * as fs from 'fs'; +import * as http from 'http'; +import * as https from 'https'; +import * as express from 'express'; +import * as morgan from 'morgan'; +import Accesses from 'accesses'; + +import log from './log-request'; +import config from '../conf'; + +/** + * Init app + */ +const app = express(); +app.disable('x-powered-by'); +app.set('trust proxy', 'loopback'); + +// Log +if (config.accesses && config.accesses.enable) { + const accesses = new Accesses({ + appName: 'Misskey', + port: config.accesses.port + }); + + app.use(accesses.express); +} + +app.use(morgan(process.env.NODE_ENV == 'production' ? 'combined' : 'dev', { + // create a write stream (in append mode) + stream: config.accesslog ? fs.createWriteStream(config.accesslog) : null +})); + +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); + } else { + next(); + } +}); + +/** + * Register modules + */ +app.use('/api', require('./api/server')); +app.use('/files', require('./file/server')); +app.use(require('./web/server')); + +function createServer() { + 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); + } +} + +export default () => new Promise(resolve => { + const server = createServer(); + + /** + * Steaming + */ + require('./api/streaming')(server); + + /** + * Server listen + */ + server.listen(config.port, resolve); +}); diff --git a/src/server/log-request.ts b/src/server/log-request.ts new file mode 100644 index 0000000000..e431aa271d --- /dev/null +++ b/src/server/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/web/app/animation.styl b/src/server/web/app/animation.styl new file mode 100644 index 0000000000..8f121b313b --- /dev/null +++ b/src/server/web/app/animation.styl @@ -0,0 +1,12 @@ +.zoom-in-top-enter-active, +.zoom-in-top-leave-active { + opacity: 1; + transform: scaleY(1); + transition: transform 300ms cubic-bezier(0.23, 1, 0.32, 1), opacity 300ms cubic-bezier(0.23, 1, 0.32, 1); + transform-origin: center top; +} +.zoom-in-top-enter, +.zoom-in-top-leave-active { + opacity: 0; + transform: scaleY(0); +} diff --git a/src/server/web/app/app.styl b/src/server/web/app/app.styl new file mode 100644 index 0000000000..431b9daa65 --- /dev/null +++ b/src/server/web/app/app.styl @@ -0,0 +1,128 @@ +@import "../style" +@import "../animation" + +html + &.progress + &, * + cursor progress !important + +body + overflow-wrap break-word + +#error + padding 32px + color #fff + + hr + border solid 1px #fff + +#nprogress + pointer-events none + + position absolute + z-index 65536 + + .bar + background $theme-color + + position fixed + z-index 65537 + top 0 + left 0 + + width 100% + height 2px + + /* Fancy blur effect */ + .peg + display block + position absolute + right 0px + width 100px + height 100% + box-shadow 0 0 10px $theme-color, 0 0 5px $theme-color + opacity 1 + + transform rotate(3deg) translate(0px, -4px) + +#wait + display block + position fixed + z-index 65537 + top 15px + right 15px + + &:before + content "" + display block + width 18px + height 18px + box-sizing border-box + + border solid 2px transparent + border-top-color $theme-color + border-left-color $theme-color + border-radius 50% + + animation progress-spinner 400ms linear infinite + + @keyframes progress-spinner + 0% + transform rotate(0deg) + 100% + transform rotate(360deg) + +code + font-family Consolas, 'Courier New', Courier, Monaco, monospace + + .comment + opacity 0.5 + + .string + color #e96900 + + .regexp + color #e9003f + + .keyword + color #2973b7 + + &.true + &.false + &.null + &.nil + &.undefined + color #ae81ff + + .symbol + color #42b983 + + .number + .nan + color #ae81ff + + .var:not(.keyword) + font-weight bold + font-style italic + //text-decoration underline + + .method + font-style italic + color #8964c1 + + .property + color #a71d5d + + .label + color #e9003f + +pre + display block + + > code + display block + overflow auto + tab-size 2 + +[data-fa] + display inline-block diff --git a/src/server/web/app/app.vue b/src/server/web/app/app.vue new file mode 100644 index 0000000000..7a46e7dea0 --- /dev/null +++ b/src/server/web/app/app.vue @@ -0,0 +1,3 @@ +<template> +<router-view id="app"></router-view> +</template> diff --git a/src/server/web/app/auth/assets/logo.svg b/src/server/web/app/auth/assets/logo.svg new file mode 100644 index 0000000000..19b8a2737e --- /dev/null +++ b/src/server/web/app/auth/assets/logo.svg @@ -0,0 +1,7 @@ +<?xml version="1.0" encoding="utf-8"?>
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px"
+ y="0px" width="1024px" height="512px" viewBox="0 256 1024 512" enable-background="new 0 256 1024 512" xml:space="preserve">
+<polyline opacity="0.5" fill="none" stroke="#000000" stroke-width="34" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" points="
+ 896.5,608.5 800.5,416.5 704.5,608.5 608.5,416.5 512.5,608.5 416.5,416.5 320.5,608.5 224.5,416.5 128.5,608.5 "/>
+</svg>
diff --git a/src/server/web/app/auth/script.ts b/src/server/web/app/auth/script.ts new file mode 100644 index 0000000000..31c758ebc2 --- /dev/null +++ b/src/server/web/app/auth/script.ts @@ -0,0 +1,25 @@ +/** + * Authorize Form + */ + +// Style +import './style.styl'; + +import init from '../init'; + +import Index from './views/index.vue'; + +/** + * init + */ +init(async (launch) => { + document.title = 'Misskey | アプリの連携'; + + // Launch the app + const [app] = launch(); + + // Routing + app.$router.addRoutes([ + { path: '/:token', component: Index }, + ]); +}); diff --git a/src/server/web/app/auth/style.styl b/src/server/web/app/auth/style.styl new file mode 100644 index 0000000000..bd25e1b572 --- /dev/null +++ b/src/server/web/app/auth/style.styl @@ -0,0 +1,15 @@ +@import "../app" +@import "../reset" + +html + background #eee + + @media (max-width 600px) + background #fff + +body + margin 0 + padding 32px 0 + + @media (max-width 600px) + padding 0 diff --git a/src/server/web/app/auth/views/form.vue b/src/server/web/app/auth/views/form.vue new file mode 100644 index 0000000000..d86ed58b38 --- /dev/null +++ b/src/server/web/app/auth/views/form.vue @@ -0,0 +1,141 @@ +<template> +<div class="form"> + <header> + <h1><i>{{ app.name }}</i>があなたのアカウントにアクセスすることを<b>許可</b>しますか?</h1> + <img :src="`${app.icon_url}?thumbnail&size=64`"/> + </header> + <div class="app"> + <section> + <h2>{{ app.name }}</h2> + <p class="nid">{{ app.name_id }}</p> + <p class="description">{{ app.description }}</p> + </section> + <section> + <h2>このアプリは次の権限を要求しています:</h2> + <ul> + <template v-for="p in app.permission"> + <li v-if="p == 'account-read'">アカウントの情報を見る。</li> + <li v-if="p == 'account-write'">アカウントの情報を操作する。</li> + <li v-if="p == 'post-write'">投稿する。</li> + <li v-if="p == 'like-write'">いいねしたりいいね解除する。</li> + <li v-if="p == 'following-write'">フォローしたりフォロー解除する。</li> + <li v-if="p == 'drive-read'">ドライブを見る。</li> + <li v-if="p == 'drive-write'">ドライブを操作する。</li> + <li v-if="p == 'notification-read'">通知を見る。</li> + <li v-if="p == 'notification-write'">通知を操作する。</li> + </template> + </ul> + </section> + </div> + <div class="action"> + <button @click="cancel">キャンセル</button> + <button @click="accept">アクセスを許可</button> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + props: ['session'], + computed: { + app(): any { + return this.session.app; + } + }, + methods: { + cancel() { + (this as any).api('auth/deny', { + token: this.session.token + }).then(() => { + this.$emit('denied'); + }); + }, + + accept() { + (this as any).api('auth/accept', { + token: this.session.token + }).then(() => { + this.$emit('accepted'); + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +.form + + > header + > h1 + margin 0 + padding 32px 32px 20px 32px + font-size 24px + font-weight normal + color #777 + + i + color #77aeca + + &:before + content '「' + + &:after + content '」' + + b + color #666 + + > img + display block + z-index 1 + width 84px + height 84px + margin 0 auto -38px auto + border solid 5px #fff + border-radius 100% + box-shadow 0 2px 2px rgba(0, 0, 0, 0.1) + + > .app + padding 44px 16px 0 16px + color #555 + background #eee + box-shadow 0 2px 2px rgba(0, 0, 0, 0.1) inset + + &:after + content '' + display block + clear both + + > section + float left + width 50% + padding 8px + text-align left + + > h2 + margin 0 + font-size 16px + color #777 + + > .action + padding 16px + + > button + margin 0 8px + padding 0 + + @media (max-width 600px) + > header + > img + box-shadow none + + > .app + box-shadow none + + @media (max-width 500px) + > header + > h1 + font-size 16px + +</style> diff --git a/src/server/web/app/auth/views/index.vue b/src/server/web/app/auth/views/index.vue new file mode 100644 index 0000000000..17e5cc6108 --- /dev/null +++ b/src/server/web/app/auth/views/index.vue @@ -0,0 +1,149 @@ +<template> +<div class="index"> + <main v-if="os.isSignedIn"> + <p class="fetching" v-if="fetching">読み込み中<mk-ellipsis/></p> + <x-form + ref="form" + v-if="state == 'waiting'" + :session="session" + @denied="state = 'denied'" + @accepted="accepted" + /> + <div class="denied" v-if="state == 'denied'"> + <h1>アプリケーションの連携をキャンセルしました。</h1> + <p>このアプリがあなたのアカウントにアクセスすることはありません。</p> + </div> + <div class="accepted" v-if="state == 'accepted'"> + <h1>{{ session.app.is_authorized ? 'このアプリは既に連携済みです' : 'アプリケーションの連携を許可しました'}}</h1> + <p v-if="session.app.callback_url">アプリケーションに戻っています<mk-ellipsis/></p> + <p v-if="!session.app.callback_url">アプリケーションに戻って、やっていってください。</p> + </div> + <div class="error" v-if="state == 'fetch-session-error'"> + <p>セッションが存在しません。</p> + </div> + </main> + <main class="signin" v-if="!os.isSignedIn"> + <h1>サインインしてください</h1> + <mk-signin/> + </main> + <footer><img src="/assets/auth/logo.svg" alt="Misskey"/></footer> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import XForm from './form.vue'; + +export default Vue.extend({ + components: { + XForm + }, + data() { + return { + state: null, + session: null, + fetching: true + }; + }, + computed: { + token(): string { + return this.$route.params.token; + } + }, + mounted() { + if (!this.$root.$data.os.isSignedIn) return; + + // Fetch session + (this as any).api('auth/session/show', { + token: this.token + }).then(session => { + this.session = session; + this.fetching = false; + + // 既に連携していた場合 + if (this.session.app.is_authorized) { + this.$root.$data.os.api('auth/accept', { + token: this.session.token + }).then(() => { + this.accepted(); + }); + } else { + this.state = 'waiting'; + } + }).catch(error => { + this.state = 'fetch-session-error'; + }); + }, + methods: { + accepted() { + this.state = 'accepted'; + if (this.session.app.callback_url) { + location.href = this.session.app.callback_url + '?token=' + this.session.token; + } + } + } +}); +</script> + +<style lang="stylus" scoped> +.index + + > main + width 100% + max-width 500px + margin 0 auto + text-align center + background #fff + box-shadow 0px 4px 16px rgba(0, 0, 0, 0.2) + + > .fetching + margin 0 + padding 32px + color #555 + + > div + padding 64px + + > h1 + margin 0 0 8px 0 + padding 0 + font-size 20px + font-weight normal + + > p + margin 0 + color #555 + + &.denied > h1 + color #e65050 + + &.accepted > h1 + color #54af7c + + &.signin + padding 32px 32px 16px 32px + + > h1 + margin 0 0 22px 0 + padding 0 + font-size 20px + font-weight normal + color #555 + + @media (max-width 600px) + max-width none + box-shadow none + + @media (max-width 500px) + > div + > h1 + font-size 16px + + > footer + > img + display block + width 64px + height 64px + margin 0 auto + +</style> diff --git a/src/server/web/app/base.pug b/src/server/web/app/base.pug new file mode 100644 index 0000000000..60eb1539ec --- /dev/null +++ b/src/server/web/app/base.pug @@ -0,0 +1,38 @@ +doctype html + +!= '\n<!-- Thank you for using Misskey! @syuilo -->\n' + +html + + head + meta(charset='utf-8') + 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 + + style + include ./../../../../built/server/web/assets/init.css + script + include ./../../../../built/server/web/assets/boot.js + + script + include ./../../../../built/server/web/assets/safe.js + + //- FontAwesome style + style #{facss} + + //- highlight.js style + style #{hljscss} + + body + noscript: p + | JavaScriptを有効にしてください + br + | Please turn on your JavaScript + div#ini: p + span . + span . + span . diff --git a/src/server/web/app/boot.js b/src/server/web/app/boot.js new file mode 100644 index 0000000000..0846e4bd55 --- /dev/null +++ b/src/server/web/app/boot.js @@ -0,0 +1,120 @@ +/** + * MISSKEY BOOT LOADER + * (ENTRY POINT) + */ + +/** + * ドメインに基づいて適切なスクリプトを読み込みます。 + * ユーザーの言語およびモバイル端末か否かも考慮します。 + * webpackは介さないためrequireやimportは使えません。 + */ + +'use strict'; + +// Chromeで確認したことなのですが、constやletを用いたとしても +// グローバルなスコープで定数/変数を定義するとwindowのプロパティ +// としてそれがアクセスできるようになる訳ではありませんが、普通に +// コンソールから定数/変数名を入力するとアクセスできてしまいます。 +// ブロック内に入れてスコープを非グローバル化するとそれが防げます +// (Chrome以外のブラウザでは検証していません) +{ + // Get the current url information + const url = new URL(location.href); + + //#region Detect app name + let app = null; + + if (url.pathname == '/docs') app = 'docs'; + if (url.pathname == '/dev') app = 'dev'; + if (url.pathname == '/auth') app = 'auth'; + //#endregion + + // Detect the user language + // Note: The default language is English + let lang = navigator.language.split('-')[0]; + if (!/^(en|ja)$/.test(lang)) lang = 'en'; + if (localStorage.getItem('lang')) lang = localStorage.getItem('lang'); + if (ENV != 'production') lang = 'ja'; + + // Detect the user agent + const ua = navigator.userAgent.toLowerCase(); + const isMobile = /mobile|iphone|ipad|android/.test(ua); + + // Get the <head> element + const head = document.getElementsByTagName('head')[0]; + + // If mobile, insert the viewport meta tag + if (isMobile) { + const meta = document.createElement('meta'); + meta.setAttribute('name', 'viewport'); + meta.setAttribute('content', + 'width=device-width,' + + 'initial-scale=1,' + + 'minimum-scale=1,' + + 'maximum-scale=1,' + + 'user-scalable=no'); + head.appendChild(meta); + } + + // Switch desktop or mobile version + if (app == null) { + app = isMobile ? 'mobile' : 'desktop'; + } + + // Script version + const ver = localStorage.getItem('v') || VERSION; + + // Whether in debug mode + const isDebug = localStorage.getItem('debug') == 'true'; + + // Whether use raw version script + const raw = (localStorage.getItem('useRawScript') == 'true' && isDebug) + || ENV != 'production'; + + // Load an app script + // Note: 'async' make it possible to load the script asyncly. + // 'defer' make it possible to run the script when the dom loaded. + const script = document.createElement('script'); + script.setAttribute('src', `/assets/${app}.${ver}.${lang}.${raw ? 'raw' : 'min'}.js`); + script.setAttribute('async', 'true'); + script.setAttribute('defer', 'true'); + head.appendChild(script); + + // 1秒経ってもスクリプトがロードされない場合はバージョンが古くて + // 404になっているせいかもしれないので、バージョンを確認して古ければ更新する + // + // 読み込まれたスクリプトからこのタイマーを解除できるように、 + // グローバルにタイマーIDを代入しておく + window.mkBootTimer = window.setTimeout(async () => { + // Fetch meta + const res = await fetch(API + '/meta', { + method: 'POST', + cache: 'no-cache' + }); + + // Parse + const meta = await res.json(); + + // Compare versions + if (meta.version != ver) { + alert( + 'Misskeyの新しいバージョンがあります。ページを再度読み込みします。' + + '\n\n' + + 'New version of Misskey available. The page will be reloaded.'); + + // Clear cache (serive worker) + try { + navigator.serviceWorker.controller.postMessage('clear'); + + navigator.serviceWorker.getRegistrations().then(registrations => { + registrations.forEach(registration => registration.unregister()); + }); + } catch (e) { + console.error(e); + } + + // Force reload + location.reload(true); + } + }, 1000); +} diff --git a/src/server/web/app/ch/script.ts b/src/server/web/app/ch/script.ts new file mode 100644 index 0000000000..4c6b6dfd1b --- /dev/null +++ b/src/server/web/app/ch/script.ts @@ -0,0 +1,15 @@ +/** + * Channels + */ + +// Style +import './style.styl'; + +require('./tags'); +import init from '../init'; + +/** + * init + */ +init(() => { +}); diff --git a/src/server/web/app/ch/style.styl b/src/server/web/app/ch/style.styl new file mode 100644 index 0000000000..21ca648cbe --- /dev/null +++ b/src/server/web/app/ch/style.styl @@ -0,0 +1,10 @@ +@import "../app" + +html + padding 8px + background #efefef + +#wait + top auto + bottom 15px + left 15px diff --git a/src/server/web/app/ch/tags/channel.tag b/src/server/web/app/ch/tags/channel.tag new file mode 100644 index 0000000000..dc4b8e1426 --- /dev/null +++ b/src/server/web/app/ch/tags/channel.tag @@ -0,0 +1,403 @@ +<mk-channel> + <mk-header/> + <hr> + <main v-if="!fetching"> + <h1>{ channel.title }</h1> + + <div v-if="$root.$data.os.isSignedIn"> + <p v-if="channel.is_watching">このチャンネルをウォッチしています <a @click="unwatch">ウォッチ解除</a></p> + <p v-if="!channel.is_watching"><a @click="watch">このチャンネルをウォッチする</a></p> + </div> + + <div class="share"> + <mk-twitter-button/> + <mk-line-button/> + </div> + + <div class="body"> + <p v-if="postsFetching">読み込み中<mk-ellipsis/></p> + <div v-if="!postsFetching"> + <p v-if="posts == null || posts.length == 0">まだ投稿がありません</p> + <template v-if="posts != null"> + <mk-channel-post each={ post in posts.slice().reverse() } post={ post } form={ parent.refs.form }/> + </template> + </div> + </div> + <hr> + <mk-channel-form v-if="$root.$data.os.isSignedIn" channel={ channel } ref="form"/> + <div v-if="!$root.$data.os.isSignedIn"> + <p>参加するには<a href={ _URL_ }>ログインまたは新規登録</a>してください</p> + </div> + <hr> + <footer> + <small><a href={ _URL_ }>Misskey</a> ver { _VERSION_ } (葵 aoi)</small> + </footer> + </main> + <style lang="stylus" scoped> + :scope + display block + + > main + > h1 + font-size 1.5em + color #f00 + + > .share + > * + margin-right 4px + + > .body + margin 8px 0 0 0 + + > mk-channel-form + max-width 500px + + </style> + <script lang="typescript"> + import Progress from '../../common/scripts/loading'; + import ChannelStream from '../../common/scripts/streaming/channel-stream'; + + this.mixin('i'); + this.mixin('api'); + + this.id = this.opts.id; + this.fetching = true; + this.postsFetching = true; + this.channel = null; + this.posts = null; + this.connection = new ChannelStream(this.id); + this.unreadCount = 0; + + this.on('mount', () => { + document.documentElement.style.background = '#efefef'; + + Progress.start(); + + let fetched = false; + + // チャンネル概要読み込み + this.$root.$data.os.api('channels/show', { + channel_id: this.id + }).then(channel => { + if (fetched) { + Progress.done(); + } else { + Progress.set(0.5); + fetched = true; + } + + this.update({ + fetching: false, + channel: channel + }); + + document.title = channel.title + ' | Misskey' + }); + + // 投稿読み込み + this.$root.$data.os.api('channels/posts', { + channel_id: this.id + }).then(posts => { + if (fetched) { + Progress.done(); + } else { + Progress.set(0.5); + fetched = true; + } + + this.update({ + postsFetching: false, + posts: posts + }); + }); + + this.connection.on('post', this.onPost); + document.addEventListener('visibilitychange', this.onVisibilitychange, false); + }); + + this.on('unmount', () => { + this.connection.off('post', this.onPost); + this.connection.close(); + document.removeEventListener('visibilitychange', this.onVisibilitychange); + }); + + this.onPost = post => { + this.posts.unshift(post); + this.update(); + + if (document.hidden && this.$root.$data.os.isSignedIn && post.user_id !== this.$root.$data.os.i.id) { + this.unreadCount++; + document.title = `(${this.unreadCount}) ${this.channel.title} | Misskey`; + } + }; + + this.onVisibilitychange = () => { + if (!document.hidden) { + this.unreadCount = 0; + document.title = this.channel.title + ' | Misskey' + } + }; + + this.watch = () => { + this.$root.$data.os.api('channels/watch', { + channel_id: this.id + }).then(() => { + this.channel.is_watching = true; + this.update(); + }, e => { + alert('error'); + }); + }; + + this.unwatch = () => { + this.$root.$data.os.api('channels/unwatch', { + channel_id: this.id + }).then(() => { + this.channel.is_watching = false; + this.update(); + }, e => { + alert('error'); + }); + }; + </script> +</mk-channel> + +<mk-channel-post> + <header> + <a class="index" @click="reply">{ post.index }:</a> + <a class="name" href={ _URL_ + '/@' + acct }><b>{ post.user.name }</b></a> + <mk-time time={ post.created_at }/> + <mk-time time={ post.created_at } mode="detail"/> + <span>ID:<i>{ acct }</i></span> + </header> + <div> + <a v-if="post.reply">>>{ post.reply.index }</a> + { post.text } + <div class="media" v-if="post.media"> + <template 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> + </template> + </div> + </div> + <style lang="stylus" scoped> + :scope + display block + margin 0 + padding 0 + + > header + position -webkit-sticky + position sticky + z-index 1 + top 0 + background rgba(239, 239, 239, 0.9) + + > .index + margin-right 0.25em + color #000 + + > .name + margin-right 0.5em + color #008000 + + > mk-time + margin-right 0.5em + + &:first-of-type + display none + + @media (max-width 600px) + > mk-time + &:first-of-type + display initial + + &:last-of-type + display none + + > div + padding 0 0 1em 2em + + > .media + > a + display inline-block + + > img + max-width 100% + vertical-align bottom + + </style> + <script lang="typescript"> + import getAcct from '../../../../common/user/get-acct'; + + this.post = this.opts.post; + this.form = this.opts.form; + this.acct = getAcct(this.post.user); + + this.reply = () => { + this.form.update({ + reply: this.post + }); + }; + </script> +</mk-channel-post> + +<mk-channel-form> + <p v-if="reply"><b>>>{ reply.index }</b> ({ reply.user.name }): <a @click="clearReply">[x]</a></p> + <textarea ref="text" disabled={ wait } oninput={ update } onkeydown={ onkeydown } onpaste={ onpaste } placeholder="%i18n:ch.tags.mk-channel-form.textarea%"></textarea> + <div class="actions"> + <button @click="selectFile">%fa:upload%%i18n:ch.tags.mk-channel-form.upload%</button> + <button @click="drive">%fa:cloud%%i18n:ch.tags.mk-channel-form.drive%</button> + <button :class="{ wait: wait }" ref="submit" disabled={ wait || (refs.text.value.length == 0) } @click="post"> + <template v-if="!wait">%fa:paper-plane%</template>{ wait ? '%i18n:ch.tags.mk-channel-form.posting%' : '%i18n:ch.tags.mk-channel-form.post%' }<mk-ellipsis v-if="wait"/> + </button> + </div> + <mk-uploader ref="uploader"/> + <ol v-if="files"> + <li each={ files }>{ name }</li> + </ol> + <input ref="file" type="file" accept="image/*" multiple="multiple" onchange={ changeFile }/> + <style lang="stylus" scoped> + :scope + display block + + > textarea + width 100% + max-width 100% + min-width 100% + min-height 5em + + > .actions + display flex + + > button + > [data-fa] + margin-right 0.25em + + &:last-child + margin-left auto + + &.wait + cursor wait + + > input[type='file'] + display none + + </style> + <script lang="typescript"> + this.mixin('api'); + + this.channel = this.opts.channel; + this.files = null; + + this.on('mount', () => { + this.$refs.uploader.on('uploaded', file => { + this.update({ + files: [file] + }); + }); + }); + + this.upload = file => { + this.$refs.uploader.upload(file); + }; + + this.clearReply = () => { + this.update({ + reply: null + }); + }; + + this.clear = () => { + this.clearReply(); + this.update({ + files: null + }); + this.$refs.text.value = ''; + }; + + this.post = () => { + this.update({ + wait: true + }); + + const files = this.files && this.files.length > 0 + ? this.files.map(f => f.id) + : undefined; + + this.$root.$data.os.api('posts/create', { + text: this.$refs.text.value == '' ? undefined : this.$refs.text.value, + media_ids: files, + reply_id: this.reply ? this.reply.id : undefined, + channel_id: this.channel.id + }).then(data => { + this.clear(); + }).catch(err => { + alert('失敗した'); + }).then(() => { + this.update({ + wait: false + }); + }); + }; + + this.changeFile = () => { + Array.from(this.$refs.file.files).forEach(this.upload); + }; + + this.selectFile = () => { + this.$refs.file.click(); + }; + + this.drive = () => { + window['cb'] = files => { + this.update({ + files: files + }); + }; + + window.open(_URL_ + '/selectdrive?multiple=true', + 'drive_window', + 'height=500,width=800'); + }; + + this.onkeydown = e => { + if ((e.which == 10 || e.which == 13) && (e.ctrlKey || e.metaKey)) this.post(); + }; + + this.onpaste = e => { + Array.from(e.clipboardData.items).forEach(item => { + if (item.kind == 'file') { + this.upload(item.getAsFile()); + } + }); + }; + </script> +</mk-channel-form> + +<mk-twitter-button> + <a href="https://twitter.com/share?ref_src=twsrc%5Etfw" class="twitter-share-button" data-show-count="false">Tweet</a> + <script lang="typescript"> + this.on('mount', () => { + const head = document.getElementsByTagName('head')[0]; + const script = document.createElement('script'); + script.setAttribute('src', 'https://platform.twitter.com/widgets.js'); + script.setAttribute('async', 'async'); + head.appendChild(script); + }); + </script> +</mk-twitter-button> + +<mk-line-button> + <div class="line-it-button" data-lang="ja" data-type="share-a" data-url={ _CH_URL_ } style="display: none;"></div> + <script lang="typescript"> + this.on('mount', () => { + const head = document.getElementsByTagName('head')[0]; + const script = document.createElement('script'); + script.setAttribute('src', 'https://d.line-scdn.net/r/web/social-plugin/js/thirdparty/loader.min.js'); + script.setAttribute('async', 'async'); + head.appendChild(script); + }); + </script> +</mk-line-button> diff --git a/src/server/web/app/ch/tags/header.tag b/src/server/web/app/ch/tags/header.tag new file mode 100644 index 0000000000..901123d63b --- /dev/null +++ b/src/server/web/app/ch/tags/header.tag @@ -0,0 +1,20 @@ +<mk-header> + <div> + <a href={ _CH_URL_ }>Index</a> | <a href={ _URL_ }>Misskey</a> + </div> + <div> + <a v-if="!$root.$data.os.isSignedIn" href={ _URL_ }>ログイン(新規登録)</a> + <a v-if="$root.$data.os.isSignedIn" href={ _URL_ + '/@' + I.username }>{ I.username }</a> + </div> + <style lang="stylus" scoped> + :scope + display flex + + > div:last-child + margin-left auto + + </style> + <script lang="typescript"> + this.mixin('i'); + </script> +</mk-header> diff --git a/src/server/web/app/ch/tags/index.tag b/src/server/web/app/ch/tags/index.tag new file mode 100644 index 0000000000..88df2ec45d --- /dev/null +++ b/src/server/web/app/ch/tags/index.tag @@ -0,0 +1,37 @@ +<mk-index> + <mk-header/> + <hr> + <button @click="n">%i18n:ch.tags.mk-index.new%</button> + <hr> + <ul v-if="channels"> + <li each={ channels }><a href={ '/' + this.id }>{ this.title }</a></li> + </ul> + <style lang="stylus" scoped> + :scope + display block + + </style> + <script lang="typescript"> + this.mixin('api'); + + this.on('mount', () => { + this.$root.$data.os.api('channels', { + limit: 100 + }).then(channels => { + this.update({ + channels: channels + }); + }); + }); + + this.n = () => { + const title = window.prompt('%i18n:ch.tags.mk-index.channel-title%'); + + this.$root.$data.os.api('channels/create', { + title: title + }).then(channel => { + location.href = '/' + channel.id; + }); + }; + </script> +</mk-index> diff --git a/src/server/web/app/ch/tags/index.ts b/src/server/web/app/ch/tags/index.ts new file mode 100644 index 0000000000..12ffdaeb84 --- /dev/null +++ b/src/server/web/app/ch/tags/index.ts @@ -0,0 +1,3 @@ +require('./index.tag'); +require('./channel.tag'); +require('./header.tag'); diff --git a/src/server/web/app/common/define-widget.ts b/src/server/web/app/common/define-widget.ts new file mode 100644 index 0000000000..d8d29873a4 --- /dev/null +++ b/src/server/web/app/common/define-widget.ts @@ -0,0 +1,79 @@ +import Vue from 'vue'; + +export default function<T extends object>(data: { + name: string; + props?: () => T; +}) { + return Vue.extend({ + props: { + widget: { + type: Object + }, + isMobile: { + type: Boolean, + default: false + }, + isCustomizeMode: { + type: Boolean, + default: false + } + }, + computed: { + id(): string { + return this.widget.id; + } + }, + data() { + return { + props: data.props ? data.props() : {} as T, + bakedOldProps: null, + preventSave: false + }; + }, + created() { + if (this.props) { + Object.keys(this.props).forEach(prop => { + if (this.widget.data.hasOwnProperty(prop)) { + this.props[prop] = this.widget.data[prop]; + } + }); + } + + this.bakeProps(); + + this.$watch('props', newProps => { + if (this.preventSave) { + this.preventSave = false; + this.bakeProps(); + return; + } + if (this.bakedOldProps == JSON.stringify(newProps)) return; + + this.bakeProps(); + + if (this.isMobile) { + (this as any).api('i/update_mobile_home', { + id: this.id, + data: newProps + }).then(() => { + (this as any).os.i.account.client_settings.mobile_home.find(w => w.id == this.id).data = newProps; + }); + } else { + (this as any).api('i/update_home', { + id: this.id, + data: newProps + }).then(() => { + (this as any).os.i.account.client_settings.home.find(w => w.id == this.id).data = newProps; + }); + } + }, { + deep: true + }); + }, + methods: { + bakeProps() { + this.bakedOldProps = JSON.stringify(this.props); + } + } + }); +} diff --git a/src/server/web/app/common/mios.ts b/src/server/web/app/common/mios.ts new file mode 100644 index 0000000000..2c6c9988e7 --- /dev/null +++ b/src/server/web/app/common/mios.ts @@ -0,0 +1,578 @@ +import Vue from 'vue'; +import { EventEmitter } from 'eventemitter3'; +import * as merge from 'object-assign-deep'; +import * as uuid from 'uuid'; + +import { hostname, apiUrl, swPublickey, version, lang, googleMapsApiKey } from '../config'; +import Progress from './scripts/loading'; +import Connection from './scripts/streaming/stream'; +import { HomeStreamManager } from './scripts/streaming/home'; +import { DriveStreamManager } from './scripts/streaming/drive'; +import { ServerStreamManager } from './scripts/streaming/server'; +import { RequestsStreamManager } from './scripts/streaming/requests'; +import { MessagingIndexStreamManager } from './scripts/streaming/messaging-index'; +import { OthelloStreamManager } from './scripts/streaming/othello'; + +import Err from '../common/views/components/connect-failed.vue'; + +//#region api requests +let spinner = null; +let pending = 0; +//#endregion + +export type API = { + chooseDriveFile: (opts: { + title?: string; + currentFolder?: any; + multiple?: boolean; + }) => Promise<any>; + + chooseDriveFolder: (opts: { + title?: string; + currentFolder?: any; + }) => Promise<any>; + + dialog: (opts: { + title: string; + text: string; + actions?: Array<{ + text: string; + id?: string; + }>; + }) => Promise<string>; + + input: (opts: { + title: string; + placeholder?: string; + default?: string; + }) => Promise<string>; + + post: (opts?: { + reply?: any; + repost?: any; + }) => void; + + notify: (message: string) => void; +}; + +/** + * Misskey Operating System + */ +export default class MiOS extends EventEmitter { + /** + * Misskeyの /meta で取得できるメタ情報 + */ + private meta: { + data: { [x: string]: any }; + chachedAt: Date; + }; + + private isMetaFetching = false; + + public app: Vue; + + public new(vm, props) { + const w = new vm({ + parent: this.app, + propsData: props + }).$mount(); + document.body.appendChild(w.$el); + } + + /** + * 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'; + } + + /** + * Whether enable sounds + */ + public get isEnableSounds() { + return localStorage.getItem('enableSounds') == 'true'; + } + + public apis: API; + + /** + * A connection manager of home stream + */ + public stream: HomeStreamManager; + + /** + * Connection managers + */ + public streams: { + driveStream: DriveStreamManager; + serverStream: ServerStreamManager; + requestsStream: RequestsStreamManager; + messagingIndexStream: MessagingIndexStreamManager; + othelloStream: OthelloStreamManager; + } = { + driveStream: null, + serverStream: null, + requestsStream: null, + messagingIndexStream: null, + othelloStream: null + }; + + /** + * A registration of service worker + */ + private swRegistration: ServiceWorkerRegistration = null; + + /** + * Whether should register ServiceWorker + */ + private shouldRegisterSw: boolean; + + /** + * ウィンドウシステム + */ + public windows = new WindowSystem(); + + /** + * 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 + + if (this.debug) { + (window as any).os = this; + } + } + + private googleMapsIniting = false; + + public getGoogleMaps() { + return new Promise((res, rej) => { + if ((window as any).google && (window as any).google.maps) { + res((window as any).google.maps); + } else { + this.once('init-google-maps', () => { + res((window as any).google.maps); + }); + + //#region load google maps api + if (!this.googleMapsIniting) { + this.googleMapsIniting = true; + (window as any).initGoogleMaps = () => { + this.emit('init-google-maps'); + }; + const head = document.getElementsByTagName('head')[0]; + const script = document.createElement('script'); + script.setAttribute('src', `https://maps.googleapis.com/maps/api/js?key=${googleMapsApiKey}&callback=initGoogleMaps`); + script.setAttribute('async', 'true'); + script.setAttribute('defer', 'true'); + head.appendChild(script); + } + //#endregion + } + }); + } + + 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); + } + + public signout() { + localStorage.removeItem('me'); + document.cookie = `i=; domain=.${hostname}; expires=Thu, 01 Jan 1970 00:00:01 GMT;`; + location.href = '/'; + } + + /** + * Initialize MiOS (boot) + * @param callback A function that call when initialized + */ + public async init(callback) { + //#region Init stream managers + this.streams.serverStream = new ServerStreamManager(this); + this.streams.requestsStream = new RequestsStreamManager(this); + + this.once('signedin', () => { + // Init home stream manager + this.stream = new HomeStreamManager(this, this.i); + + // Init other stream manager + this.streams.driveStream = new DriveStreamManager(this, this.i); + this.streams.messagingIndexStream = new MessagingIndexStreamManager(this, this.i); + this.streams.othelloStream = new OthelloStreamManager(this, this.i); + }); + //#endregion + + // ユーザーをフェッチしてコールバックする + const fetchme = (token, cb) => { + let me = null; + + // Return when not signed in + if (token == null) { + return done(); + } + + // Fetch user + fetch(`${apiUrl}/i`, { + method: 'POST', + body: JSON.stringify({ + i: token + }) + }) + // When success + .then(res => { + // When failed to authenticate user + if (res.status !== 200) { + return this.signout(); + } + + // Parse response + res.json().then(i => { + me = i; + me.account.token = token; + done(); + }); + }) + // When failure + .catch(() => { + // Render the error screen + document.body.innerHTML = '<div id="err"></div>'; + new Vue({ + render: createEl => createEl(Err) + }).$mount('#err'); + + Progress.done(); + }); + + function done() { + if (cb) cb(me); + } + }; + + // フェッチが完了したとき + const fetched = me => { + if (me) { + // デフォルトの設定をマージ + me.account.client_settings = Object.assign({ + fetchOnScroll: true, + showMaps: true, + showPostFormOnTopOfTl: false, + gradientWindowHeader: false + }, me.account.client_settings); + + // ローカルストレージにキャッシュ + localStorage.setItem('me', JSON.stringify(me)); + } + + this.i = me; + + this.emit('signedin'); + + // 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.account.token, freshData => { + merge(cachedMe, freshData); + }); + } 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(swPublickey) + }; + + // 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(); + }); + }); + + // Whether use raw version script + const raw = (localStorage.getItem('useRawScript') == 'true' && this.debug) + || process.env.NODE_ENV != 'production'; + + // The path of service worker script + const sw = `/sw.${version}.${lang}.${raw ? 'raw' : 'min'}.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); + }); + } + + public requests = []; + + /** + * Misskey APIにリクエストします + * @param endpoint エンドポイント名 + * @param data パラメータ + */ + public api(endpoint: string, data: { [x: string]: any } = {}): Promise<{ [x: string]: any }> { + if (++pending === 1) { + spinner = document.createElement('div'); + spinner.setAttribute('id', 'wait'); + document.body.appendChild(spinner); + } + + // Append a credential + if (this.isSignedIn) (data as any).i = this.i.account.token; + + // TODO + //const viaStream = localStorage.getItem('enableExperimental') == 'true'; + + return new Promise((resolve, reject) => { + /*if (viaStream) { + const stream = this.stream.borrow(); + const id = Math.random().toString(); + stream.once(`api-res:${id}`, res => { + resolve(res); + }); + stream.send({ + type: 'api', + id, + endpoint, + data + }); + } else {*/ + const req = { + id: uuid(), + date: new Date(), + name: endpoint, + data, + res: null, + status: null + }; + + if (this.debug) { + this.requests.push(req); + } + + // Send request + fetch(endpoint.indexOf('://') > -1 ? endpoint : `${apiUrl}/${endpoint}`, { + method: 'POST', + body: JSON.stringify(data), + credentials: endpoint === 'signin' ? 'include' : 'omit', + cache: 'no-cache' + }).then(async (res) => { + if (--pending === 0) spinner.parentNode.removeChild(spinner); + + const body = res.status === 204 ? null : await res.json(); + + if (this.debug) { + req.status = res.status; + req.res = body; + } + + if (res.status === 200) { + resolve(body); + } else if (res.status === 204) { + resolve(); + } else { + reject(body.error); + } + }).catch(reject); + /*}*/ + }); + } + + /** + * 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); + } + }); + } + + public connections: Connection[] = []; + + public registerStreamConnection(connection: Connection) { + this.connections.push(connection); + } + + public unregisterStreamConnection(connection: Connection) { + this.connections = this.connections.filter(c => c != connection); + } +} + +class WindowSystem extends EventEmitter { + public windows = new Set(); + + public add(window) { + this.windows.add(window); + this.emit('added', window); + } + + public remove(window) { + this.windows.delete(window); + this.emit('removed', window); + } + + public getAll() { + return this.windows; + } +} + +/** + * 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/server/web/app/common/scripts/check-for-update.ts b/src/server/web/app/common/scripts/check-for-update.ts new file mode 100644 index 0000000000..81c1eb9812 --- /dev/null +++ b/src/server/web/app/common/scripts/check-for-update.ts @@ -0,0 +1,33 @@ +import MiOS from '../mios'; +import { version as current } from '../../config'; + +export default async function(mios: MiOS, force = false, silent = false) { + const meta = await mios.getMeta(force); + const newer = meta.version; + + if (newer != current) { + localStorage.setItem('should-refresh', 'true'); + localStorage.setItem('v', newer); + + // Clear cache (serive worker) + try { + if (navigator.serviceWorker.controller) { + navigator.serviceWorker.controller.postMessage('clear'); + } + + navigator.serviceWorker.getRegistrations().then(registrations => { + registrations.forEach(registration => registration.unregister()); + }); + } catch (e) { + console.error(e); + } + + if (!silent) { + alert('%i18n:common.update-available%'.replace('{newer}', newer).replace('{current}', current)); + } + + return newer; + } else { + return null; + } +} diff --git a/src/server/web/app/common/scripts/compose-notification.ts b/src/server/web/app/common/scripts/compose-notification.ts new file mode 100644 index 0000000000..e1dbd3bc13 --- /dev/null +++ b/src/server/web/app/common/scripts/compose-notification.ts @@ -0,0 +1,67 @@ +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' + }; + + case 'othello_invited': + return { + title: '対局への招待があります', + body: `${data.parent.name}さんから`, + icon: data.parent.avatar_url + '?thumbnail&size=64' + }; + + default: + return null; + } +} diff --git a/src/server/web/app/common/scripts/contains.ts b/src/server/web/app/common/scripts/contains.ts new file mode 100644 index 0000000000..a5071b3f25 --- /dev/null +++ b/src/server/web/app/common/scripts/contains.ts @@ -0,0 +1,8 @@ +export default (parent, child) => { + let node = child.parentNode; + while (node) { + if (node == parent) return true; + node = node.parentNode; + } + return false; +}; diff --git a/src/server/web/app/common/scripts/copy-to-clipboard.ts b/src/server/web/app/common/scripts/copy-to-clipboard.ts new file mode 100644 index 0000000000..3d2741f8d7 --- /dev/null +++ b/src/server/web/app/common/scripts/copy-to-clipboard.ts @@ -0,0 +1,13 @@ +/** + * Clipboardに値をコピー(TODO: 文字列以外も対応) + */ +export default val => { + const form = document.createElement('textarea'); + form.textContent = val; + document.body.appendChild(form); + form.select(); + const result = document.execCommand('copy'); + document.body.removeChild(form); + + return result; +}; diff --git a/src/server/web/app/common/scripts/date-stringify.ts b/src/server/web/app/common/scripts/date-stringify.ts new file mode 100644 index 0000000000..e51de8833d --- /dev/null +++ b/src/server/web/app/common/scripts/date-stringify.ts @@ -0,0 +1,13 @@ +export default date => { + if (typeof date == 'string') date = new Date(date); + return ( + date.getFullYear() + '年' + + (date.getMonth() + 1) + '月' + + date.getDate() + '日' + + ' ' + + date.getHours() + '時' + + date.getMinutes() + '分' + + ' ' + + `(${['日', '月', '火', '水', '木', '金', '土'][date.getDay()]})` + ); +}; diff --git a/src/server/web/app/common/scripts/fuck-ad-block.ts b/src/server/web/app/common/scripts/fuck-ad-block.ts new file mode 100644 index 0000000000..9bcf7deeff --- /dev/null +++ b/src/server/web/app/common/scripts/fuck-ad-block.ts @@ -0,0 +1,21 @@ +require('fuckadblock'); + +declare const fuckAdBlock: any; + +export default (os) => { + function adBlockDetected() { + os.apis.dialog({ + title: '%fa:exclamation-triangle%広告ブロッカーを無効にしてください', + text: '<strong>Misskeyは広告を掲載していません</strong>が、広告をブロックする機能が有効だと一部の機能が利用できなかったり、不具合が発生する場合があります。', + actins: [{ + text: 'OK' + }] + }); + } + + if (fuckAdBlock === undefined) { + adBlockDetected(); + } else { + fuckAdBlock.onDetected(adBlockDetected); + } +}; diff --git a/src/server/web/app/common/scripts/gcd.ts b/src/server/web/app/common/scripts/gcd.ts new file mode 100644 index 0000000000..9a19f9da66 --- /dev/null +++ b/src/server/web/app/common/scripts/gcd.ts @@ -0,0 +1,2 @@ +const gcd = (a, b) => !b ? a : gcd(b, a % b); +export default gcd; diff --git a/src/server/web/app/common/scripts/get-kao.ts b/src/server/web/app/common/scripts/get-kao.ts new file mode 100644 index 0000000000..2168c5be88 --- /dev/null +++ b/src/server/web/app/common/scripts/get-kao.ts @@ -0,0 +1,5 @@ +export default () => [ + '(=^・・^=)', + 'v(‘ω’)v', + '🐡( \'-\' 🐡 )フグパンチ!!!!' +][Math.floor(Math.random() * 3)]; diff --git a/src/server/web/app/common/scripts/get-median.ts b/src/server/web/app/common/scripts/get-median.ts new file mode 100644 index 0000000000..91a415d5b2 --- /dev/null +++ b/src/server/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/server/web/app/common/scripts/loading.ts b/src/server/web/app/common/scripts/loading.ts new file mode 100644 index 0000000000..c48e626648 --- /dev/null +++ b/src/server/web/app/common/scripts/loading.ts @@ -0,0 +1,21 @@ +const NProgress = require('nprogress'); +NProgress.configure({ + trickleSpeed: 500, + showSpinner: false +}); + +const root = document.getElementsByTagName('html')[0]; + +export default { + start: () => { + root.classList.add('progress'); + NProgress.start(); + }, + done: () => { + root.classList.remove('progress'); + NProgress.done(); + }, + set: val => { + NProgress.set(val); + } +}; diff --git a/src/server/web/app/common/scripts/parse-search-query.ts b/src/server/web/app/common/scripts/parse-search-query.ts new file mode 100644 index 0000000000..512791ecb0 --- /dev/null +++ b/src/server/web/app/common/scripts/parse-search-query.ts @@ -0,0 +1,53 @@ +export default function(qs: string) { + const q = { + text: '' + }; + + qs.split(' ').forEach(x => { + if (/^([a-z_]+?):(.+?)$/.test(x)) { + const [key, value] = x.split(':'); + switch (key) { + case 'user': + q['include_user_usernames'] = value.split(','); + break; + case 'exclude_user': + q['exclude_user_usernames'] = value.split(','); + break; + case 'follow': + q['following'] = value == 'null' ? null : value == 'true'; + break; + case 'reply': + q['reply'] = value == 'null' ? null : value == 'true'; + break; + case 'repost': + q['repost'] = value == 'null' ? null : value == 'true'; + break; + case 'media': + q['media'] = value == 'null' ? null : value == 'true'; + break; + case 'poll': + q['poll'] = value == 'null' ? null : value == 'true'; + break; + case 'until': + case 'since': + // YYYY-MM-DD + if (/^[0-9]+\-[0-9]+\-[0-9]+$/) { + const [yyyy, mm, dd] = value.split('-'); + q[`${key}_date`] = (new Date(parseInt(yyyy, 10), parseInt(mm, 10) - 1, parseInt(dd, 10))).getTime(); + } + break; + default: + q[key] = value; + break; + } + } else { + q.text += x + ' '; + } + }); + + if (q.text) { + q.text = q.text.trim(); + } + + return q; +} diff --git a/src/server/web/app/common/scripts/streaming/channel.ts b/src/server/web/app/common/scripts/streaming/channel.ts new file mode 100644 index 0000000000..cab5f4edb4 --- /dev/null +++ b/src/server/web/app/common/scripts/streaming/channel.ts @@ -0,0 +1,13 @@ +import Stream from './stream'; +import MiOS from '../../mios'; + +/** + * Channel stream connection + */ +export default class Connection extends Stream { + constructor(os: MiOS, channelId) { + super(os, 'channel', { + channel: channelId + }); + } +} diff --git a/src/server/web/app/common/scripts/streaming/drive.ts b/src/server/web/app/common/scripts/streaming/drive.ts new file mode 100644 index 0000000000..f11573685e --- /dev/null +++ b/src/server/web/app/common/scripts/streaming/drive.ts @@ -0,0 +1,34 @@ +import Stream from './stream'; +import StreamManager from './stream-manager'; +import MiOS from '../../mios'; + +/** + * Drive stream connection + */ +export class DriveStream extends Stream { + constructor(os: MiOS, me) { + super(os, 'drive', { + i: me.account.token + }); + } +} + +export class DriveStreamManager extends StreamManager<DriveStream> { + private me; + private os: MiOS; + + constructor(os: MiOS, me) { + super(); + + this.me = me; + this.os = os; + } + + public getConnection() { + if (this.connection == null) { + this.connection = new DriveStream(this.os, this.me); + } + + return this.connection; + } +} diff --git a/src/server/web/app/common/scripts/streaming/home.ts b/src/server/web/app/common/scripts/streaming/home.ts new file mode 100644 index 0000000000..ffcf6e5360 --- /dev/null +++ b/src/server/web/app/common/scripts/streaming/home.ts @@ -0,0 +1,57 @@ +import * as merge from 'object-assign-deep'; + +import Stream from './stream'; +import StreamManager from './stream-manager'; +import MiOS from '../../mios'; + +/** + * Home stream connection + */ +export class HomeStream extends Stream { + constructor(os: MiOS, me) { + super(os, '', { + i: me.account.token + }); + + // 最終利用日時を更新するため定期的にaliveメッセージを送信 + setInterval(() => { + this.send({ type: 'alive' }); + me.account.last_used_at = new Date(); + }, 1000 * 60); + + // 自分の情報が更新されたとき + this.on('i_updated', i => { + if (os.debug) { + console.log('I updated:', i); + } + merge(me, i); + }); + + // トークンが再生成されたとき + // このままではAPIが利用できないので強制的にサインアウトさせる + this.on('my_token_regenerated', () => { + alert('%i18n:common.my-token-regenerated%'); + os.signout(); + }); + } +} + +export class HomeStreamManager extends StreamManager<HomeStream> { + private me; + private os: MiOS; + + constructor(os: MiOS, me) { + super(); + + this.me = me; + this.os = os; + } + + public getConnection() { + if (this.connection == null) { + this.connection = new HomeStream(this.os, this.me); + } + + return this.connection; + } +} diff --git a/src/server/web/app/common/scripts/streaming/messaging-index.ts b/src/server/web/app/common/scripts/streaming/messaging-index.ts new file mode 100644 index 0000000000..24f0ce0c9f --- /dev/null +++ b/src/server/web/app/common/scripts/streaming/messaging-index.ts @@ -0,0 +1,34 @@ +import Stream from './stream'; +import StreamManager from './stream-manager'; +import MiOS from '../../mios'; + +/** + * Messaging index stream connection + */ +export class MessagingIndexStream extends Stream { + constructor(os: MiOS, me) { + super(os, 'messaging-index', { + i: me.account.token + }); + } +} + +export class MessagingIndexStreamManager extends StreamManager<MessagingIndexStream> { + private me; + private os: MiOS; + + constructor(os: MiOS, me) { + super(); + + this.me = me; + this.os = os; + } + + public getConnection() { + if (this.connection == null) { + this.connection = new MessagingIndexStream(this.os, this.me); + } + + return this.connection; + } +} diff --git a/src/server/web/app/common/scripts/streaming/messaging.ts b/src/server/web/app/common/scripts/streaming/messaging.ts new file mode 100644 index 0000000000..4c593deb31 --- /dev/null +++ b/src/server/web/app/common/scripts/streaming/messaging.ts @@ -0,0 +1,20 @@ +import Stream from './stream'; +import MiOS from '../../mios'; + +/** + * Messaging stream connection + */ +export class MessagingStream extends Stream { + constructor(os: MiOS, me, otherparty) { + super(os, 'messaging', { + i: me.account.token, + otherparty + }); + + (this as any).on('_connected_', () => { + this.send({ + i: me.account.token + }); + }); + } +} diff --git a/src/server/web/app/common/scripts/streaming/othello-game.ts b/src/server/web/app/common/scripts/streaming/othello-game.ts new file mode 100644 index 0000000000..f34ef35147 --- /dev/null +++ b/src/server/web/app/common/scripts/streaming/othello-game.ts @@ -0,0 +1,11 @@ +import Stream from './stream'; +import MiOS from '../../mios'; + +export class OthelloGameStream extends Stream { + constructor(os: MiOS, me, game) { + super(os, 'othello-game', { + i: me ? me.account.token : null, + game: game.id + }); + } +} diff --git a/src/server/web/app/common/scripts/streaming/othello.ts b/src/server/web/app/common/scripts/streaming/othello.ts new file mode 100644 index 0000000000..8c6f4b9c3c --- /dev/null +++ b/src/server/web/app/common/scripts/streaming/othello.ts @@ -0,0 +1,31 @@ +import StreamManager from './stream-manager'; +import Stream from './stream'; +import MiOS from '../../mios'; + +export class OthelloStream extends Stream { + constructor(os: MiOS, me) { + super(os, 'othello', { + i: me.account.token + }); + } +} + +export class OthelloStreamManager extends StreamManager<OthelloStream> { + private me; + private os: MiOS; + + constructor(os: MiOS, me) { + super(); + + this.me = me; + this.os = os; + } + + public getConnection() { + if (this.connection == null) { + this.connection = new OthelloStream(this.os, this.me); + } + + return this.connection; + } +} diff --git a/src/server/web/app/common/scripts/streaming/requests.ts b/src/server/web/app/common/scripts/streaming/requests.ts new file mode 100644 index 0000000000..5bec30143f --- /dev/null +++ b/src/server/web/app/common/scripts/streaming/requests.ts @@ -0,0 +1,30 @@ +import Stream from './stream'; +import StreamManager from './stream-manager'; +import MiOS from '../../mios'; + +/** + * Requests stream connection + */ +export class RequestsStream extends Stream { + constructor(os: MiOS) { + super(os, 'requests'); + } +} + +export class RequestsStreamManager extends StreamManager<RequestsStream> { + private os: MiOS; + + constructor(os: MiOS) { + super(); + + this.os = os; + } + + public getConnection() { + if (this.connection == null) { + this.connection = new RequestsStream(this.os); + } + + return this.connection; + } +} diff --git a/src/server/web/app/common/scripts/streaming/server.ts b/src/server/web/app/common/scripts/streaming/server.ts new file mode 100644 index 0000000000..3d35ef4d9d --- /dev/null +++ b/src/server/web/app/common/scripts/streaming/server.ts @@ -0,0 +1,30 @@ +import Stream from './stream'; +import StreamManager from './stream-manager'; +import MiOS from '../../mios'; + +/** + * Server stream connection + */ +export class ServerStream extends Stream { + constructor(os: MiOS) { + super(os, 'server'); + } +} + +export class ServerStreamManager extends StreamManager<ServerStream> { + private os: MiOS; + + constructor(os: MiOS) { + super(); + + this.os = os; + } + + public getConnection() { + if (this.connection == null) { + this.connection = new ServerStream(this.os); + } + + return this.connection; + } +} diff --git a/src/server/web/app/common/scripts/streaming/stream-manager.ts b/src/server/web/app/common/scripts/streaming/stream-manager.ts new file mode 100644 index 0000000000..568b8b0372 --- /dev/null +++ b/src/server/web/app/common/scripts/streaming/stream-manager.ts @@ -0,0 +1,108 @@ +import { EventEmitter } from 'eventemitter3'; +import * as uuid from 'uuid'; +import Connection from './stream'; + +/** + * ストリーム接続を管理するクラス + * 複数の場所から同じストリームを利用する際、接続をまとめたりする + */ +export default abstract class StreamManager<T extends Connection> extends EventEmitter { + private _connection: T = null; + + private disposeTimerId: any; + + /** + * コネクションを必要としているユーザー + */ + private users = []; + + protected set connection(connection: T) { + this._connection = connection; + + if (this._connection == null) { + this.emit('disconnected'); + } else { + this.emit('connected', this._connection); + + this._connection.on('_connected_', () => { + this.emit('_connected_'); + }); + + this._connection.on('_disconnected_', () => { + this.emit('_disconnected_'); + }); + + this._connection.user = 'Managed'; + } + } + + protected get connection() { + return this._connection; + } + + /** + * コネクションを持っているか否か + */ + public get hasConnection() { + return this._connection != null; + } + + public get state(): string { + if (!this.hasConnection) return 'no-connection'; + return this._connection.state; + } + + /** + * コネクションを要求します + */ + public abstract getConnection(): T; + + /** + * 現在接続しているコネクションを取得します + */ + public borrow() { + return this._connection; + } + + /** + * コネクションを要求するためのユーザーIDを発行します + */ + public use() { + // タイマー解除 + if (this.disposeTimerId) { + clearTimeout(this.disposeTimerId); + this.disposeTimerId = null; + } + + // ユーザーID生成 + const userId = uuid(); + + this.users.push(userId); + + this._connection.user = `Managed (${ this.users.length })`; + + return userId; + } + + /** + * コネクションを利用し終わってもう必要ないことを通知します + * @param userId use で発行したユーザーID + */ + public dispose(userId) { + this.users = this.users.filter(id => id != userId); + + this._connection.user = `Managed (${ this.users.length })`; + + // 誰もコネクションの利用者がいなくなったら + if (this.users.length == 0) { + // また直ぐに再利用される可能性があるので、一定時間待ち、 + // 新たな利用者が現れなければコネクションを切断する + this.disposeTimerId = setTimeout(() => { + this.disposeTimerId = null; + + this.connection.close(); + this.connection = null; + }, 3000); + } + } +} diff --git a/src/server/web/app/common/scripts/streaming/stream.ts b/src/server/web/app/common/scripts/streaming/stream.ts new file mode 100644 index 0000000000..3912186ad3 --- /dev/null +++ b/src/server/web/app/common/scripts/streaming/stream.ts @@ -0,0 +1,137 @@ +import { EventEmitter } from 'eventemitter3'; +import * as uuid from 'uuid'; +import * as ReconnectingWebsocket from 'reconnecting-websocket'; +import { wsUrl } from '../../../config'; +import MiOS from '../../mios'; + +/** + * Misskey stream connection + */ +export default class Connection extends EventEmitter { + public state: string; + private buffer: any[]; + public socket: ReconnectingWebsocket; + public name: string; + public connectedAt: Date; + public user: string = null; + public in: number = 0; + public out: number = 0; + public inout: Array<{ + type: 'in' | 'out', + at: Date, + data: string + }> = []; + public id: string; + public isSuspended = false; + private os: MiOS; + + constructor(os: MiOS, endpoint, params?) { + super(); + + //#region BIND + this.onOpen = this.onOpen.bind(this); + this.onClose = this.onClose.bind(this); + this.onMessage = this.onMessage.bind(this); + this.send = this.send.bind(this); + this.close = this.close.bind(this); + //#endregion + + this.id = uuid(); + this.os = os; + this.name = endpoint; + this.state = 'initializing'; + this.buffer = []; + + const query = params + ? Object.keys(params) + .map(k => encodeURIComponent(k) + '=' + encodeURIComponent(params[k])) + .join('&') + : null; + + this.socket = new ReconnectingWebsocket(`${wsUrl}/${endpoint}${query ? '?' + query : ''}`); + this.socket.addEventListener('open', this.onOpen); + this.socket.addEventListener('close', this.onClose); + this.socket.addEventListener('message', this.onMessage); + + // Register this connection for debugging + this.os.registerStreamConnection(this); + } + + /** + * Callback of when open connection + */ + private onOpen() { + this.state = 'connected'; + this.emit('_connected_'); + + this.connectedAt = new Date(); + + // バッファーを処理 + const _buffer = [].concat(this.buffer); // Shallow copy + this.buffer = []; // Clear buffer + _buffer.forEach(data => { + this.send(data); // Resend each buffered messages + + if (this.os.debug) { + this.out++; + this.inout.push({ type: 'out', at: new Date(), data }); + } + }); + } + + /** + * Callback of when close connection + */ + private onClose() { + this.state = 'reconnecting'; + this.emit('_disconnected_'); + } + + /** + * Callback of when received a message from connection + */ + private onMessage(message) { + if (this.isSuspended) return; + + if (this.os.debug) { + this.in++; + this.inout.push({ type: 'in', at: new Date(), data: message.data }); + } + + try { + const msg = JSON.parse(message.data); + if (msg.type) this.emit(msg.type, msg.body); + } catch (e) { + // noop + } + } + + /** + * Send a message to connection + */ + public send(data) { + if (this.isSuspended) return; + + // まだ接続が確立されていなかったらバッファリングして次に接続した時に送信する + if (this.state != 'connected') { + this.buffer.push(data); + return; + } + + if (this.os.debug) { + this.out++; + this.inout.push({ type: 'out', at: new Date(), data }); + } + + this.socket.send(JSON.stringify(data)); + } + + /** + * Close this connection + */ + public close() { + this.os.unregisterStreamConnection(this); + this.socket.removeEventListener('open', this.onOpen); + this.socket.removeEventListener('message', this.onMessage); + } +} diff --git a/src/server/web/app/common/views/components/autocomplete.vue b/src/server/web/app/common/views/components/autocomplete.vue new file mode 100644 index 0000000000..8afa291e3c --- /dev/null +++ b/src/server/web/app/common/views/components/autocomplete.vue @@ -0,0 +1,306 @@ +<template> +<div class="mk-autocomplete" @contextmenu.prevent="() => {}"> + <ol class="users" ref="suggests" v-if="users.length > 0"> + <li v-for="user in users" @click="complete(type, user)" @keydown="onKeydown" tabindex="-1"> + <img class="avatar" :src="`${user.avatar_url}?thumbnail&size=32`" alt=""/> + <span class="name">{{ user.name }}</span> + <span class="username">@{{ getAcct(user) }}</span> + </li> + </ol> + <ol class="emojis" ref="suggests" v-if="emojis.length > 0"> + <li v-for="emoji in emojis" @click="complete(type, emoji.emoji)" @keydown="onKeydown" tabindex="-1"> + <span class="emoji">{{ emoji.emoji }}</span> + <span class="name" v-html="emoji.name.replace(q, `<b>${q}</b>`)"></span> + <span class="alias" v-if="emoji.alias">({{ emoji.alias }})</span> + </li> + </ol> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import * as emojilib from 'emojilib'; +import contains from '../../../common/scripts/contains'; +import getAcct from '../../../../../common/user/get-acct'; + +const lib = Object.entries(emojilib.lib).filter((x: any) => { + return x[1].category != 'flags'; +}); +const emjdb = lib.map((x: any) => ({ + emoji: x[1].char, + name: x[0], + alias: null +})); +lib.forEach((x: any) => { + if (x[1].keywords) { + x[1].keywords.forEach(k => { + emjdb.push({ + emoji: x[1].char, + name: k, + alias: x[0] + }); + }); + } +}); +emjdb.sort((a, b) => a.name.length - b.name.length); + +export default Vue.extend({ + props: ['type', 'q', 'textarea', 'complete', 'close', 'x', 'y'], + data() { + return { + fetching: true, + users: [], + emojis: [], + select: -1, + emojilib + } + }, + computed: { + items(): HTMLCollection { + return (this.$refs.suggests as Element).children; + } + }, + updated() { + //#region 位置調整 + const margin = 32; + + if (this.x + this.$el.offsetWidth > window.innerWidth - margin) { + this.$el.style.left = (this.x - this.$el.offsetWidth) + 'px'; + this.$el.style.marginLeft = '-16px'; + } else { + this.$el.style.left = this.x + 'px'; + this.$el.style.marginLeft = '0'; + } + + if (this.y + this.$el.offsetHeight > window.innerHeight - margin) { + this.$el.style.top = (this.y - this.$el.offsetHeight) + 'px'; + this.$el.style.marginTop = '0'; + } else { + this.$el.style.top = this.y + 'px'; + this.$el.style.marginTop = 'calc(1em + 8px)'; + } + //#endregion + }, + mounted() { + this.textarea.addEventListener('keydown', this.onKeydown); + + Array.from(document.querySelectorAll('body *')).forEach(el => { + el.addEventListener('mousedown', this.onMousedown); + }); + + this.$nextTick(() => { + this.exec(); + + this.$watch('q', () => { + this.$nextTick(() => { + this.exec(); + }); + }); + }); + }, + beforeDestroy() { + this.textarea.removeEventListener('keydown', this.onKeydown); + + Array.from(document.querySelectorAll('body *')).forEach(el => { + el.removeEventListener('mousedown', this.onMousedown); + }); + }, + methods: { + getAcct, + exec() { + this.select = -1; + if (this.$refs.suggests) { + Array.from(this.items).forEach(el => { + el.removeAttribute('data-selected'); + }); + } + + if (this.type == 'user') { + const cache = sessionStorage.getItem(this.q); + if (cache) { + const users = JSON.parse(cache); + this.users = users; + this.fetching = false; + } else { + (this as any).api('users/search_by_username', { + query: this.q, + limit: 30 + }).then(users => { + this.users = users; + this.fetching = false; + + // キャッシュ + sessionStorage.setItem(this.q, JSON.stringify(users)); + }); + } + } else if (this.type == 'emoji') { + const matched = []; + emjdb.some(x => { + if (x.name.indexOf(this.q) == 0 && !x.alias && !matched.some(y => y.emoji == x.emoji)) matched.push(x); + return matched.length == 30; + }); + if (matched.length < 30) { + emjdb.some(x => { + if (x.name.indexOf(this.q) == 0 && !matched.some(y => y.emoji == x.emoji)) matched.push(x); + return matched.length == 30; + }); + } + if (matched.length < 30) { + emjdb.some(x => { + if (x.name.indexOf(this.q) > -1 && !matched.some(y => y.emoji == x.emoji)) matched.push(x); + return matched.length == 30; + }); + } + this.emojis = matched; + } + }, + + onMousedown(e) { + if (!contains(this.$el, e.target) && (this.$el != e.target)) this.close(); + }, + + onKeydown(e) { + const cancel = () => { + e.preventDefault(); + e.stopPropagation(); + }; + + switch (e.which) { + case 10: // [ENTER] + case 13: // [ENTER] + if (this.select !== -1) { + cancel(); + (this.items[this.select] as any).click(); + } else { + this.close(); + } + break; + + case 27: // [ESC] + cancel(); + this.close(); + break; + + case 38: // [↑] + if (this.select !== -1) { + cancel(); + this.selectPrev(); + } else { + this.close(); + } + break; + + case 9: // [TAB] + case 40: // [↓] + cancel(); + this.selectNext(); + break; + + default: + e.stopPropagation(); + this.textarea.focus(); + } + }, + + selectNext() { + if (++this.select >= this.items.length) this.select = 0; + this.applySelect(); + }, + + selectPrev() { + if (--this.select < 0) this.select = this.items.length - 1; + this.applySelect(); + }, + + applySelect() { + Array.from(this.items).forEach(el => { + el.removeAttribute('data-selected'); + }); + + this.items[this.select].setAttribute('data-selected', 'true'); + (this.items[this.select] as any).focus(); + } + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +.mk-autocomplete + position fixed + z-index 65535 + margin-top calc(1em + 8px) + overflow hidden + background #fff + border solid 1px rgba(0, 0, 0, 0.1) + border-radius 4px + transition top 0.1s ease, left 0.1s ease + + > ol + display block + margin 0 + padding 4px 0 + max-height 190px + max-width 500px + overflow auto + list-style none + + > li + display block + padding 4px 12px + white-space nowrap + overflow hidden + font-size 0.9em + color rgba(0, 0, 0, 0.8) + cursor default + + &, * + user-select none + + &:hover + &[data-selected='true'] + background $theme-color + + &, * + color #fff !important + + &:active + background darken($theme-color, 10%) + + &, * + color #fff !important + + > .users > li + + .avatar + vertical-align middle + min-width 28px + min-height 28px + max-width 28px + max-height 28px + margin 0 8px 0 0 + border-radius 100% + + .name + margin 0 8px 0 0 + color rgba(0, 0, 0, 0.8) + + .username + color rgba(0, 0, 0, 0.3) + + > .emojis > li + + .emoji + display inline-block + margin 0 4px 0 0 + width 24px + + .name + color rgba(0, 0, 0, 0.8) + + .alias + margin 0 0 0 8px + color rgba(0, 0, 0, 0.3) + +</style> diff --git a/src/server/web/app/common/views/components/connect-failed.troubleshooter.vue b/src/server/web/app/common/views/components/connect-failed.troubleshooter.vue new file mode 100644 index 0000000000..cadbd36ba4 --- /dev/null +++ b/src/server/web/app/common/views/components/connect-failed.troubleshooter.vue @@ -0,0 +1,137 @@ +<template> +<div class="troubleshooter"> + <h1>%fa:wrench%%i18n:common.tags.mk-error.troubleshooter.title%</h1> + <div> + <p :data-wip="network == null"> + <template v-if="network != null"> + <template v-if="network">%fa:check%</template> + <template v-if="!network">%fa:times%</template> + </template> + {{ network == null ? '%i18n:common.tags.mk-error.troubleshooter.checking-network%' : '%i18n:common.tags.mk-error.troubleshooter.network%' }}<mk-ellipsis v-if="network == null"/> + </p> + <p v-if="network == true" :data-wip="internet == null"> + <template v-if="internet != null"> + <template v-if="internet">%fa:check%</template> + <template v-if="!internet">%fa:times%</template> + </template> + {{ internet == null ? '%i18n:common.tags.mk-error.troubleshooter.checking-internet%' : '%i18n:common.tags.mk-error.troubleshooter.internet%' }}<mk-ellipsis v-if="internet == null"/> + </p> + <p v-if="internet == true" :data-wip="server == null"> + <template v-if="server != null"> + <template v-if="server">%fa:check%</template> + <template v-if="!server">%fa:times%</template> + </template> + {{ server == null ? '%i18n:common.tags.mk-error.troubleshooter.checking-server%' : '%i18n:common.tags.mk-error.troubleshooter.server%' }}<mk-ellipsis v-if="server == null"/> + </p> + </div> + <p v-if="!end">%i18n:common.tags.mk-error.troubleshooter.finding%<mk-ellipsis/></p> + <p v-if="network === false"><b>%fa:exclamation-triangle%%i18n:common.tags.mk-error.troubleshooter.no-network%</b><br>%i18n:common.tags.mk-error.troubleshooter.no-network-desc%</p> + <p v-if="internet === false"><b>%fa:exclamation-triangle%%i18n:common.tags.mk-error.troubleshooter.no-internet%</b><br>%i18n:common.tags.mk-error.troubleshooter.no-internet-desc%</p> + <p v-if="server === false"><b>%fa:exclamation-triangle%%i18n:common.tags.mk-error.troubleshooter.no-server%</b><br>%i18n:common.tags.mk-error.troubleshooter.no-server-desc%</p> + <p v-if="server === true" class="success"><b>%fa:info-circle%%i18n:common.tags.mk-error.troubleshooter.success%</b><br>%i18n:common.tags.mk-error.troubleshooter.success-desc%</p> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { apiUrl } from '../../../config'; + +export default Vue.extend({ + data() { + return { + network: navigator.onLine, + end: false, + internet: null, + server: null + }; + }, + mounted() { + if (!this.network) { + this.end = true; + return; + } + + // Check internet connection + fetch('https://google.com?rand=' + Math.random(), { + mode: 'no-cors' + }).then(() => { + this.internet = true; + + // Check misskey server is available + fetch(`${apiUrl}/meta`).then(() => { + this.end = true; + this.server = true; + }) + .catch(() => { + this.end = true; + this.server = false; + }); + }) + .catch(() => { + this.end = true; + this.internet = false; + }); + } +}); +</script> + +<style lang="stylus" scoped> +.troubleshooter + width 100% + max-width 500px + text-align left + background #fff + border-radius 8px + border solid 1px #ddd + + > h1 + margin 0 + padding 0.6em 1.2em + font-size 1em + color #444 + border-bottom solid 1px #eee + + > [data-fa] + margin-right 0.25em + + > div + overflow hidden + padding 0.6em 1.2em + + > p + margin 0.5em 0 + font-size 0.9em + color #444 + + &[data-wip] + color #888 + + > [data-fa] + margin-right 0.25em + + &.times + color #e03524 + + &.check + color #84c32f + + > p + margin 0 + padding 0.7em 1.2em + font-size 1em + color #444 + border-top solid 1px #eee + + > b + > [data-fa] + margin-right 0.25em + + &.success + > b + color #39adad + + &:not(.success) + > b + color #ad4339 + +</style> diff --git a/src/server/web/app/common/views/components/connect-failed.vue b/src/server/web/app/common/views/components/connect-failed.vue new file mode 100644 index 0000000000..185250dbd8 --- /dev/null +++ b/src/server/web/app/common/views/components/connect-failed.vue @@ -0,0 +1,106 @@ +<template> +<div class="mk-connect-failed"> + <img src="data:image/jpeg;base64,%base64:/assets/error.jpg%" alt=""/> + <h1>%i18n:common.tags.mk-error.title%</h1> + <p class="text"> + {{ '%i18n:common.tags.mk-error.description%'.substr(0, '%i18n:common.tags.mk-error.description%'.indexOf('{')) }} + <a @click="reload">{{ '%i18n:common.tags.mk-error.description%'.match(/\{(.+?)\}/)[1] }}</a> + {{ '%i18n:common.tags.mk-error.description%'.substr('%i18n:common.tags.mk-error.description%'.indexOf('}') + 1) }} + </p> + <button v-if="!troubleshooting" @click="troubleshooting = true">%i18n:common.tags.mk-error.troubleshoot%</button> + <x-troubleshooter v-if="troubleshooting"/> + <p class="thanks">%i18n:common.tags.mk-error.thanks%</p> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import XTroubleshooter from './connect-failed.troubleshooter.vue'; + +export default Vue.extend({ + components: { + XTroubleshooter + }, + data() { + return { + troubleshooting: false + }; + }, + mounted() { + document.title = 'Oops!'; + document.documentElement.style.background = '#f8f8f8'; + }, + methods: { + reload() { + location.reload(); + } + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +.mk-connect-failed + width 100% + padding 32px 18px + text-align center + + > img + display block + height 200px + margin 0 auto + pointer-events none + user-select none + + > h1 + display block + margin 1.25em auto 0.65em auto + font-size 1.5em + color #555 + + > .text + display block + margin 0 auto + max-width 600px + font-size 1em + color #666 + + > button + display block + margin 1em auto 0 auto + padding 8px 10px + color $theme-color-foreground + background $theme-color + + &:focus + outline solid 3px rgba($theme-color, 0.3) + + &:hover + background lighten($theme-color, 10%) + + &:active + background darken($theme-color, 10%) + + > .troubleshooter + margin 1em auto 0 auto + + > .thanks + display block + margin 2em auto 0 auto + padding 2em 0 0 0 + max-width 600px + font-size 0.9em + font-style oblique + color #aaa + border-top solid 1px #eee + + @media (max-width 500px) + padding 24px 18px + font-size 80% + + > img + height 150px + +</style> + diff --git a/src/server/web/app/common/views/components/ellipsis.vue b/src/server/web/app/common/views/components/ellipsis.vue new file mode 100644 index 0000000000..07349902de --- /dev/null +++ b/src/server/web/app/common/views/components/ellipsis.vue @@ -0,0 +1,26 @@ +<template> + <span class="mk-ellipsis"> + <span>.</span><span>.</span><span>.</span> + </span> +</template> + +<style lang="stylus" scoped> +.mk-ellipsis + > span + animation ellipsis 1.4s infinite ease-in-out both + + &:nth-child(1) + animation-delay 0s + + &:nth-child(2) + animation-delay 0.16s + + &:nth-child(3) + animation-delay 0.32s + + @keyframes ellipsis + 0%, 80%, 100% + opacity 1 + 40% + opacity 0 +</style> diff --git a/src/server/web/app/common/views/components/file-type-icon.vue b/src/server/web/app/common/views/components/file-type-icon.vue new file mode 100644 index 0000000000..b7e868d1f7 --- /dev/null +++ b/src/server/web/app/common/views/components/file-type-icon.vue @@ -0,0 +1,17 @@ +<template> +<span class="mk-file-type-icon"> + <template v-if="kind == 'image'">%fa:file-image%</template> +</span> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + props: ['type'], + computed: { + kind(): string { + return this.type.split('/')[0]; + } + } +}); +</script> diff --git a/src/server/web/app/common/views/components/forkit.vue b/src/server/web/app/common/views/components/forkit.vue new file mode 100644 index 0000000000..6f334b965a --- /dev/null +++ b/src/server/web/app/common/views/components/forkit.vue @@ -0,0 +1,42 @@ +<template> +<a class="a" href="https://github.com/syuilo/misskey" target="_blank" title="%i18n:common.tags.mk-forkit.open-github-link%" aria-label="%i18n:common.tags.mk-forkit.open-github-link%"> + <svg width="80" height="80" viewBox="0 0 250 250" aria-hidden="aria-hidden"> + <path d="M0,0 L115,115 L130,115 L142,142 L250,250 L250,0 Z"></path> + <path class="octo-arm" d="M128.3,109.0 C113.8,99.7 119.0,89.6 119.0,89.6 C122.0,82.7 120.5,78.6 120.5,78.6 C119.2,72.0 123.4,76.3 123.4,76.3 C127.3,80.9 125.5,87.3 125.5,87.3 C122.9,97.6 130.6,101.9 134.4,103.2" fill="currentColor"></path> + <path d="M115.0,115.0 C114.9,115.1 118.7,116.5 119.8,115.4 L133.7,101.6 C136.9,99.2 139.9,98.4 142.2,98.6 C133.8,88.0 127.5,74.4 143.8,58.0 C148.5,53.4 154.0,51.2 159.7,51.0 C160.3,49.4 163.2,43.6 171.4,40.1 C171.4,40.1 176.1,42.5 178.8,56.2 C183.1,58.6 187.2,61.8 190.9,65.4 C194.5,69.0 197.7,73.2 200.1,77.6 C213.8,80.2 216.3,84.9 216.3,84.9 C212.7,93.1 206.9,96.0 205.4,96.6 C205.1,102.4 203.0,107.8 198.3,112.5 C181.9,128.9 168.3,122.5 157.7,114.1 C157.9,116.9 156.7,120.9 152.7,124.9 L141.0,136.5 C139.8,137.7 141.6,141.9 141.8,141.8 Z" fill="currentColor"></path> + </svg> +</a> +</template> + +<style lang="stylus" scoped> +@import '~const.styl' + +.a + display block + position absolute + top 0 + right 0 + + > svg + display block + //fill #151513 + //color #fff + fill $theme-color + color $theme-color-foreground + + .octo-arm + transform-origin 130px 106px + + &:hover + .octo-arm + animation octocat-wave 560ms ease-in-out + + @keyframes octocat-wave + 0%, 100% + transform rotate(0) + 20%, 60% + transform rotate(-25deg) + 40%, 80% + transform rotate(10deg) + +</style> diff --git a/src/server/web/app/common/views/components/index.ts b/src/server/web/app/common/views/components/index.ts new file mode 100644 index 0000000000..b58ba37ecb --- /dev/null +++ b/src/server/web/app/common/views/components/index.ts @@ -0,0 +1,51 @@ +import Vue from 'vue'; + +import signin from './signin.vue'; +import signup from './signup.vue'; +import forkit from './forkit.vue'; +import nav from './nav.vue'; +import postHtml from './post-html'; +import poll from './poll.vue'; +import pollEditor from './poll-editor.vue'; +import reactionIcon from './reaction-icon.vue'; +import reactionsViewer from './reactions-viewer.vue'; +import time from './time.vue'; +import timer from './timer.vue'; +import mediaList from './media-list.vue'; +import uploader from './uploader.vue'; +import specialMessage from './special-message.vue'; +import streamIndicator from './stream-indicator.vue'; +import ellipsis from './ellipsis.vue'; +import messaging from './messaging.vue'; +import messagingRoom from './messaging-room.vue'; +import urlPreview from './url-preview.vue'; +import twitterSetting from './twitter-setting.vue'; +import fileTypeIcon from './file-type-icon.vue'; +import Switch from './switch.vue'; +import Othello from './othello.vue'; +import welcomeTimeline from './welcome-timeline.vue'; + +Vue.component('mk-signin', signin); +Vue.component('mk-signup', signup); +Vue.component('mk-forkit', forkit); +Vue.component('mk-nav', nav); +Vue.component('mk-post-html', postHtml); +Vue.component('mk-poll', poll); +Vue.component('mk-poll-editor', pollEditor); +Vue.component('mk-reaction-icon', reactionIcon); +Vue.component('mk-reactions-viewer', reactionsViewer); +Vue.component('mk-time', time); +Vue.component('mk-timer', timer); +Vue.component('mk-media-list', mediaList); +Vue.component('mk-uploader', uploader); +Vue.component('mk-special-message', specialMessage); +Vue.component('mk-stream-indicator', streamIndicator); +Vue.component('mk-ellipsis', ellipsis); +Vue.component('mk-messaging', messaging); +Vue.component('mk-messaging-room', messagingRoom); +Vue.component('mk-url-preview', urlPreview); +Vue.component('mk-twitter-setting', twitterSetting); +Vue.component('mk-file-type-icon', fileTypeIcon); +Vue.component('mk-switch', Switch); +Vue.component('mk-othello', Othello); +Vue.component('mk-welcome-timeline', welcomeTimeline); diff --git a/src/server/web/app/common/views/components/media-list.vue b/src/server/web/app/common/views/components/media-list.vue new file mode 100644 index 0000000000..64172ad0b4 --- /dev/null +++ b/src/server/web/app/common/views/components/media-list.vue @@ -0,0 +1,57 @@ +<template> +<div class="mk-media-list" :data-count="mediaList.length"> + <template v-for="media in mediaList"> + <mk-media-video :video="media" :key="media.id" v-if="media.type.startsWith('video')" :inline-playable="mediaList.length === 1"/> + <mk-media-image :image="media" :key="media.id" v-else /> + </template> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +export default Vue.extend({ + props: ['mediaList'], +}); +</script> + +<style lang="stylus" scoped> +.mk-media-list + display grid + grid-gap 4px + height 256px + + @media (max-width 500px) + height 192px + + &[data-count="1"] + grid-template-rows 1fr + &[data-count="2"] + grid-template-columns 1fr 1fr + grid-template-rows 1fr + &[data-count="3"] + grid-template-columns 1fr 0.5fr + grid-template-rows 1fr 1fr + :nth-child(1) + grid-row 1 / 3 + :nth-child(3) + grid-column 2 / 3 + grid-row 2/3 + &[data-count="4"] + grid-template-columns 1fr 1fr + grid-template-rows 1fr 1fr + + :nth-child(1) + grid-column 1 / 2 + grid-row 1 / 2 + :nth-child(2) + grid-column 2 / 3 + grid-row 1 / 2 + :nth-child(3) + grid-column 1 / 2 + grid-row 2 / 3 + :nth-child(4) + grid-column 2 / 3 + grid-row 2 / 3 + +</style> diff --git a/src/server/web/app/common/views/components/messaging-room.form.vue b/src/server/web/app/common/views/components/messaging-room.form.vue new file mode 100644 index 0000000000..01886b19c8 --- /dev/null +++ b/src/server/web/app/common/views/components/messaging-room.form.vue @@ -0,0 +1,305 @@ +<template> +<div class="mk-messaging-form" + @dragover.stop="onDragover" + @drop.stop="onDrop" +> + <textarea + v-model="text" + ref="textarea" + @keypress="onKeypress" + @paste="onPaste" + placeholder="%i18n:common.input-message-here%" + v-autocomplete="'text'" + ></textarea> + <div class="file" @click="file = null" v-if="file">{{ file.name }}</div> + <mk-uploader ref="uploader" @uploaded="onUploaded"/> + <button class="send" @click="send" :disabled="!canSend || sending" title="%i18n:common.send%"> + <template v-if="!sending">%fa:paper-plane%</template><template v-if="sending">%fa:spinner .spin%</template> + </button> + <button class="attach-from-local" @click="chooseFile" title="%i18n:common.tags.mk-messaging-form.attach-from-local%"> + %fa:upload% + </button> + <button class="attach-from-drive" @click="chooseFileFromDrive" title="%i18n:common.tags.mk-messaging-form.attach-from-drive%"> + %fa:R folder-open% + </button> + <input ref="file" type="file" @change="onChangeFile"/> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import * as autosize from 'autosize'; + +export default Vue.extend({ + props: ['user'], + data() { + return { + text: null, + file: null, + sending: false + }; + }, + computed: { + draftId(): string { + return this.user.id; + }, + canSend(): boolean { + return (this.text != null && this.text != '') || this.file != null; + }, + room(): any { + return this.$parent; + } + }, + watch: { + text() { + this.saveDraft(); + }, + file() { + this.saveDraft(); + + if (this.room.isBottom()) { + this.room.scrollToBottom(); + } + } + }, + mounted() { + autosize(this.$refs.textarea); + + // 書きかけの投稿を復元 + const draft = JSON.parse(localStorage.getItem('message_drafts') || '{}')[this.draftId]; + if (draft) { + this.text = draft.data.text; + this.file = draft.data.file; + } + }, + methods: { + onPaste(e) { + const data = e.clipboardData; + const items = data.items; + + if (items.length == 1) { + if (items[0].kind == 'file') { + this.upload(items[0].getAsFile()); + } + } else { + if (items[0].kind == 'file') { + alert('メッセージに添付できるのはひとつのファイルのみです'); + } + } + }, + + onDragover(e) { + const isFile = e.dataTransfer.items[0].kind == 'file'; + const isDriveFile = e.dataTransfer.types[0] == 'mk_drive_file'; + if (isFile || isDriveFile) { + e.preventDefault(); + e.dataTransfer.dropEffect = e.dataTransfer.effectAllowed == 'all' ? 'copy' : 'move'; + } + }, + + onDrop(e): void { + // ファイルだったら + if (e.dataTransfer.files.length == 1) { + e.preventDefault(); + this.upload(e.dataTransfer.files[0]); + return; + } else if (e.dataTransfer.files.length > 1) { + e.preventDefault(); + alert('メッセージに添付できるのはひとつのファイルのみです'); + return; + } + + //#region ドライブのファイル + const driveFile = e.dataTransfer.getData('mk_drive_file'); + if (driveFile != null && driveFile != '') { + this.file = JSON.parse(driveFile); + e.preventDefault(); + } + //#endregion + }, + + onKeypress(e) { + if ((e.which == 10 || e.which == 13) && e.ctrlKey) { + this.send(); + } + }, + + chooseFile() { + (this.$refs.file as any).click(); + }, + + chooseFileFromDrive() { + (this as any).apis.chooseDriveFile({ + multiple: false + }).then(file => { + this.file = file; + }); + }, + + onChangeFile() { + this.upload((this.$refs.file as any).files[0]); + }, + + upload(file) { + (this.$refs.uploader as any).upload(file); + }, + + onUploaded(file) { + this.file = file; + }, + + send() { + this.sending = true; + (this as any).api('messaging/messages/create', { + user_id: this.user.id, + text: this.text ? this.text : undefined, + file_id: this.file ? this.file.id : undefined + }).then(message => { + this.clear(); + }).catch(err => { + console.error(err); + }).then(() => { + this.sending = false; + }); + }, + + clear() { + this.text = ''; + this.file = null; + this.deleteDraft(); + }, + + saveDraft() { + const data = JSON.parse(localStorage.getItem('message_drafts') || '{}'); + + data[this.draftId] = { + updated_at: new Date(), + data: { + text: this.text, + file: this.file + } + } + + localStorage.setItem('message_drafts', JSON.stringify(data)); + }, + + deleteDraft() { + const data = JSON.parse(localStorage.getItem('message_drafts') || '{}'); + + delete data[this.draftId]; + + localStorage.setItem('message_drafts', JSON.stringify(data)); + }, + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +.mk-messaging-form + > textarea + cursor auto + display block + width 100% + min-width 100% + max-width 100% + height 64px + margin 0 + padding 8px + resize none + font-size 1em + color #000 + outline none + border none + border-top solid 1px #eee + border-radius 0 + box-shadow none + background transparent + + > .file + padding 8px + color #444 + background #eee + cursor pointer + + > .send + position absolute + bottom 0 + right 0 + margin 0 + padding 10px 14px + font-size 1em + color #aaa + transition color 0.1s ease + + &:hover + color $theme-color + + &:active + color darken($theme-color, 10%) + transition color 0s ease + + .files + display block + margin 0 + padding 0 8px + list-style none + + &:after + content '' + display block + clear both + + > li + display block + float left + margin 4px + padding 0 + width 64px + height 64px + background-color #eee + background-repeat no-repeat + background-position center center + background-size cover + cursor move + + &:hover + > .remove + display block + + > .remove + display none + position absolute + right -6px + top -6px + margin 0 + padding 0 + background transparent + outline none + border none + border-radius 0 + box-shadow none + cursor pointer + + .attach-from-local + .attach-from-drive + margin 0 + padding 10px 14px + font-size 1em + font-weight normal + text-decoration none + color #aaa + transition color 0.1s ease + + &:hover + color $theme-color + + &:active + color darken($theme-color, 10%) + transition color 0s ease + + input[type=file] + display none + +</style> diff --git a/src/server/web/app/common/views/components/messaging-room.message.vue b/src/server/web/app/common/views/components/messaging-room.message.vue new file mode 100644 index 0000000000..5f2eb1ba86 --- /dev/null +++ b/src/server/web/app/common/views/components/messaging-room.message.vue @@ -0,0 +1,263 @@ +<template> +<div class="message" :data-is-me="isMe"> + <router-link class="avatar-anchor" :to="`/@${acct}`" :title="acct" target="_blank"> + <img class="avatar" :src="`${message.user.avatar_url}?thumbnail&size=80`" alt=""/> + </router-link> + <div class="content"> + <div class="balloon" :data-no-text="message.text == null"> + <p class="read" v-if="isMe && message.is_read">%i18n:common.tags.mk-messaging-message.is-read%</p> + <button class="delete-button" v-if="isMe" title="%i18n:common.delete%"> + <img src="/assets/desktop/messaging/delete.png" alt="Delete"/> + </button> + <div class="content" v-if="!message.is_deleted"> + <mk-post-html class="text" v-if="message.ast" :ast="message.ast" :i="os.i"/> + <div class="file" v-if="message.file"> + <a :href="message.file.url" target="_blank" :title="message.file.name"> + <img v-if="message.file.type.split('/')[0] == 'image'" :src="message.file.url" :alt="message.file.name"/> + <p v-else>{{ message.file.name }}</p> + </a> + </div> + </div> + <div class="content" v-if="message.is_deleted"> + <p class="is-deleted">%i18n:common.tags.mk-messaging-message.deleted%</p> + </div> + </div> + <div></div> + <mk-url-preview v-for="url in urls" :url="url" :key="url"/> + <footer> + <mk-time :time="message.created_at"/> + <template v-if="message.is_edited">%fa:pencil-alt%</template> + </footer> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import getAcct from '../../../../../common/user/get-acct'; + +export default Vue.extend({ + props: ['message'], + computed: { + acct() { + return getAcct(this.message.user); + }, + isMe(): boolean { + return this.message.user_id == (this as any).os.i.id; + }, + urls(): string[] { + if (this.message.ast) { + return this.message.ast + .filter(t => (t.type == 'url' || t.type == 'link') && !t.silent) + .map(t => t.url); + } else { + return null; + } + } + } +}); +</script> + +<style lang="stylus" scoped> +.message + $me-balloon-color = #23A7B6 + + padding 10px 12px 10px 12px + background-color transparent + + > .avatar-anchor + display block + position absolute + top 10px + + > .avatar + display block + min-width 54px + min-height 54px + max-width 54px + max-height 54px + margin 0 + border-radius 8px + transition all 0.1s ease + + > .content + + > .balloon + display block + padding 0 + max-width calc(100% - 16px) + min-height 38px + border-radius 16px + + &:before + content "" + pointer-events none + display block + position absolute + top 12px + + & + * + clear both + + &:hover + > .delete-button + display block + + > .delete-button + display none + position absolute + z-index 1 + top -4px + right -4px + margin 0 + padding 0 + cursor pointer + outline none + border none + border-radius 0 + box-shadow none + background transparent + + > img + vertical-align bottom + width 16px + height 16px + cursor pointer + + > .read + user-select none + display block + position absolute + z-index 1 + bottom -4px + left -12px + margin 0 + color rgba(0, 0, 0, 0.5) + font-size 11px + + > .content + + > .is-deleted + display block + margin 0 + padding 0 + overflow hidden + overflow-wrap break-word + font-size 1em + color rgba(0, 0, 0, 0.5) + + > .text + display block + margin 0 + padding 8px 16px + overflow hidden + overflow-wrap break-word + font-size 1em + color rgba(0, 0, 0, 0.8) + + & + .file + > a + border-radius 0 0 16px 16px + + > .file + > a + display block + max-width 100% + max-height 512px + border-radius 16px + overflow hidden + text-decoration none + + &:hover + text-decoration none + + > p + background #ccc + + > * + display block + margin 0 + width 100% + height 100% + + > p + padding 30px + text-align center + color #555 + background #ddd + + > .mk-url-preview + margin 8px 0 + + > footer + display block + margin 2px 0 0 0 + font-size 10px + color rgba(0, 0, 0, 0.4) + + > [data-fa] + margin-left 4px + + &:not([data-is-me]) + > .avatar-anchor + left 12px + + > .content + padding-left 66px + + > .balloon + float left + background #eee + + &[data-no-text] + background transparent + + &:not([data-no-text]):before + left -14px + border-top solid 8px transparent + border-right solid 8px #eee + border-bottom solid 8px transparent + border-left solid 8px transparent + + > footer + text-align left + + &[data-is-me] + > .avatar-anchor + right 12px + + > .content + padding-right 66px + + > .balloon + float right + background $me-balloon-color + + &[data-no-text] + background transparent + + &:not([data-no-text]):before + right -14px + left auto + border-top solid 8px transparent + border-right solid 8px transparent + border-bottom solid 8px transparent + border-left solid 8px $me-balloon-color + + > .content + + > p.is-deleted + color rgba(255, 255, 255, 0.5) + + > .text >>> + &, * + color #fff !important + + > footer + text-align right + + &[data-is-deleted] + > .baloon + opacity 0.5 + +</style> diff --git a/src/server/web/app/common/views/components/messaging-room.vue b/src/server/web/app/common/views/components/messaging-room.vue new file mode 100644 index 0000000000..6ff808b617 --- /dev/null +++ b/src/server/web/app/common/views/components/messaging-room.vue @@ -0,0 +1,377 @@ +<template> +<div class="mk-messaging-room" + @dragover.prevent.stop="onDragover" + @drop.prevent.stop="onDrop" +> + <div class="stream"> + <p class="init" v-if="init">%fa:spinner .spin%%i18n:common.loading%</p> + <p class="empty" v-if="!init && messages.length == 0">%fa:info-circle%%i18n:common.tags.mk-messaging-room.empty%</p> + <p class="no-history" v-if="!init && messages.length > 0 && !existMoreMessages">%fa:flag%%i18n:common.tags.mk-messaging-room.no-history%</p> + <button class="more" :class="{ fetching: fetchingMoreMessages }" v-if="existMoreMessages" @click="fetchMoreMessages" :disabled="fetchingMoreMessages"> + <template v-if="fetchingMoreMessages">%fa:spinner .pulse .fw%</template>{{ fetchingMoreMessages ? '%i18n:common.loading%' : '%i18n:common.tags.mk-messaging-room.more%' }} + </button> + <template v-for="(message, i) in _messages"> + <x-message :message="message" :key="message.id"/> + <p class="date" v-if="i != messages.length - 1 && message._date != _messages[i + 1]._date"> + <span>{{ _messages[i + 1]._datetext }}</span> + </p> + </template> + </div> + <footer> + <div ref="notifications" class="notifications"></div> + <x-form :user="user" ref="form"/> + </footer> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { MessagingStream } from '../../scripts/streaming/messaging'; +import XMessage from './messaging-room.message.vue'; +import XForm from './messaging-room.form.vue'; +import { url } from '../../../config'; + +export default Vue.extend({ + components: { + XMessage, + XForm + }, + + props: ['user', 'isNaked'], + + data() { + return { + init: true, + fetchingMoreMessages: false, + messages: [], + existMoreMessages: false, + connection: null + }; + }, + + computed: { + _messages(): any[] { + return (this.messages as any).map(message => { + const date = new Date(message.created_at).getDate(); + const month = new Date(message.created_at).getMonth() + 1; + message._date = date; + message._datetext = `${month}月 ${date}日`; + return message; + }); + }, + + form(): any { + return this.$refs.form; + } + }, + + mounted() { + this.connection = new MessagingStream((this as any).os, (this as any).os.i, this.user.id); + + this.connection.on('message', this.onMessage); + this.connection.on('read', this.onRead); + + document.addEventListener('visibilitychange', this.onVisibilitychange); + + this.fetchMessages().then(() => { + this.init = false; + this.scrollToBottom(); + }); + }, + + beforeDestroy() { + this.connection.off('message', this.onMessage); + this.connection.off('read', this.onRead); + this.connection.close(); + + document.removeEventListener('visibilitychange', this.onVisibilitychange); + }, + + methods: { + onDragover(e) { + const isFile = e.dataTransfer.items[0].kind == 'file'; + const isDriveFile = e.dataTransfer.types[0] == 'mk_drive_file'; + + if (isFile || isDriveFile) { + e.dataTransfer.dropEffect = e.dataTransfer.effectAllowed == 'all' ? 'copy' : 'move'; + } else { + e.dataTransfer.dropEffect = 'none'; + } + }, + + onDrop(e): void { + // ファイルだったら + if (e.dataTransfer.files.length == 1) { + this.form.upload(e.dataTransfer.files[0]); + return; + } else if (e.dataTransfer.files.length > 1) { + alert('メッセージに添付できるのはひとつのファイルのみです'); + return; + } + + //#region ドライブのファイル + const driveFile = e.dataTransfer.getData('mk_drive_file'); + if (driveFile != null && driveFile != '') { + const file = JSON.parse(driveFile); + this.form.file = file; + } + //#endregion + }, + + fetchMessages() { + return new Promise((resolve, reject) => { + const max = this.existMoreMessages ? 20 : 10; + + (this as any).api('messaging/messages', { + user_id: this.user.id, + limit: max + 1, + until_id: this.existMoreMessages ? this.messages[0].id : undefined + }).then(messages => { + if (messages.length == max + 1) { + this.existMoreMessages = true; + messages.pop(); + } else { + this.existMoreMessages = false; + } + + this.messages.unshift.apply(this.messages, messages.reverse()); + resolve(); + }); + }); + }, + + fetchMoreMessages() { + this.fetchingMoreMessages = true; + this.fetchMessages().then(() => { + this.fetchingMoreMessages = false; + }); + }, + + onMessage(message) { + // サウンドを再生する + if ((this as any).os.isEnableSounds) { + const sound = new Audio(`${url}/assets/message.mp3`); + sound.volume = localStorage.getItem('soundVolume') ? parseInt(localStorage.getItem('soundVolume'), 10) / 100 : 1; + sound.play(); + } + + const isBottom = this.isBottom(); + + this.messages.push(message); + if (message.user_id != (this as any).os.i.id && !document.hidden) { + this.connection.send({ + type: 'read', + id: message.id + }); + } + + if (isBottom) { + // Scroll to bottom + this.$nextTick(() => { + this.scrollToBottom(); + }); + } else if (message.user_id != (this as any).os.i.id) { + // Notify + this.notify('%i18n:common.tags.mk-messaging-room.new-message%'); + } + }, + + onRead(ids) { + if (!Array.isArray(ids)) ids = [ids]; + ids.forEach(id => { + if (this.messages.some(x => x.id == id)) { + const exist = this.messages.map(x => x.id).indexOf(id); + this.messages[exist].is_read = true; + } + }); + }, + + isBottom() { + const asobi = 64; + const current = this.isNaked + ? window.scrollY + window.innerHeight + : this.$el.scrollTop + this.$el.offsetHeight; + const max = this.isNaked + ? document.body.offsetHeight + : this.$el.scrollHeight; + return current > (max - asobi); + }, + + scrollToBottom() { + if (this.isNaked) { + window.scroll(0, document.body.offsetHeight); + } else { + this.$el.scrollTop = this.$el.scrollHeight; + } + }, + + notify(message) { + const n = document.createElement('p') as any; + n.innerHTML = '%fa:arrow-circle-down%' + message; + n.onclick = () => { + this.scrollToBottom(); + n.parentNode.removeChild(n); + }; + (this.$refs.notifications as any).appendChild(n); + + setTimeout(() => { + n.style.opacity = 0; + setTimeout(() => n.parentNode.removeChild(n), 1000); + }, 4000); + }, + + onVisibilitychange() { + if (document.hidden) return; + this.messages.forEach(message => { + if (message.user_id !== (this as any).os.i.id && !message.is_read) { + this.connection.send({ + type: 'read', + id: message.id + }); + } + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +.mk-messaging-room + display flex + flex 1 + flex-direction column + height 100% + + > .stream + width 100% + max-width 600px + margin 0 auto + flex 1 + + > .init + width 100% + margin 0 + padding 16px 8px 8px 8px + text-align center + font-size 0.8em + color rgba(0, 0, 0, 0.4) + + [data-fa] + margin-right 4px + + > .empty + width 100% + margin 0 + padding 16px 8px 8px 8px + text-align center + font-size 0.8em + color rgba(0, 0, 0, 0.4) + + [data-fa] + margin-right 4px + + > .no-history + display block + margin 0 + padding 16px + text-align center + font-size 0.8em + color rgba(0, 0, 0, 0.4) + + [data-fa] + margin-right 4px + + > .more + display block + margin 16px auto + padding 0 12px + line-height 24px + color #fff + background rgba(0, 0, 0, 0.3) + border-radius 12px + + &:hover + background rgba(0, 0, 0, 0.4) + + &:active + background rgba(0, 0, 0, 0.5) + + &.fetching + cursor wait + + > [data-fa] + margin-right 4px + + > .message + // something + + > .date + display block + margin 8px 0 + text-align center + + &:before + content '' + display block + position absolute + height 1px + width 90% + top 16px + left 0 + right 0 + margin 0 auto + background rgba(0, 0, 0, 0.1) + + > span + display inline-block + margin 0 + padding 0 16px + //font-weight bold + line-height 32px + color rgba(0, 0, 0, 0.3) + background #fff + + > footer + position -webkit-sticky + position sticky + z-index 2 + bottom 0 + width 100% + max-width 600px + margin 0 auto + padding 0 + background rgba(255, 255, 255, 0.95) + background-clip content-box + + > .notifications + position absolute + top -48px + width 100% + padding 8px 0 + text-align center + + &:empty + display none + + > p + display inline-block + margin 0 + padding 0 12px 0 28px + cursor pointer + line-height 32px + font-size 12px + color $theme-color-foreground + background $theme-color + border-radius 16px + transition opacity 1s ease + + > [data-fa] + position absolute + top 0 + left 10px + line-height 32px + font-size 16px + +</style> diff --git a/src/server/web/app/common/views/components/messaging.vue b/src/server/web/app/common/views/components/messaging.vue new file mode 100644 index 0000000000..88574b94d1 --- /dev/null +++ b/src/server/web/app/common/views/components/messaging.vue @@ -0,0 +1,463 @@ +<template> +<div class="mk-messaging" :data-compact="compact"> + <div class="search" v-if="!compact" :style="{ top: headerTop + 'px' }"> + <div class="form"> + <label for="search-input">%fa:search%</label> + <input v-model="q" type="search" @input="search" @keydown="onSearchKeydown" placeholder="%i18n:common.tags.mk-messaging.search-user%"/> + </div> + <div class="result"> + <ol class="users" v-if="result.length > 0" ref="searchResult"> + <li v-for="(user, i) in result" + @keydown.enter="navigate(user)" + @keydown="onSearchResultKeydown(i)" + @click="navigate(user)" + tabindex="-1" + > + <img class="avatar" :src="`${user.avatar_url}?thumbnail&size=32`" alt=""/> + <span class="name">{{ user.name }}</span> + <span class="username">@{{ getAcct(user) }}</span> + </li> + </ol> + </div> + </div> + <div class="history" v-if="messages.length > 0"> + <template> + <a v-for="message in messages" + class="user" + :href="`/i/messaging/${getAcct(isMe(message) ? message.recipient : message.user)}`" + :data-is-me="isMe(message)" + :data-is-read="message.is_read" + @click.prevent="navigate(isMe(message) ? message.recipient : message.user)" + :key="message.id" + > + <div> + <img class="avatar" :src="`${isMe(message) ? message.recipient.avatar_url : message.user.avatar_url}?thumbnail&size=64`" alt=""/> + <header> + <span class="name">{{ isMe(message) ? message.recipient.name : message.user.name }}</span> + <span class="username">@{{ getAcct(isMe(message) ? message.recipient : message.user) }}</span> + <mk-time :time="message.created_at"/> + </header> + <div class="body"> + <p class="text"><span class="me" v-if="isMe(message)">%i18n:common.tags.mk-messaging.you%:</span>{{ message.text }}</p> + </div> + </div> + </a> + </template> + </div> + <p class="no-history" v-if="!fetching && messages.length == 0">%i18n:common.tags.mk-messaging.no-history%</p> + <p class="fetching" v-if="fetching">%fa:spinner .pulse .fw%%i18n:common.loading%<mk-ellipsis/></p> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import getAcct from '../../../../../common/user/get-acct'; + +export default Vue.extend({ + props: { + compact: { + type: Boolean, + default: false + }, + headerTop: { + type: Number, + default: 0 + } + }, + data() { + return { + fetching: true, + moreFetching: false, + messages: [], + q: null, + result: [], + connection: null, + connectionId: null + }; + }, + mounted() { + this.connection = (this as any).os.streams.messagingIndexStream.getConnection(); + this.connectionId = (this as any).os.streams.messagingIndexStream.use(); + + this.connection.on('message', this.onMessage); + this.connection.on('read', this.onRead); + + (this as any).api('messaging/history').then(messages => { + this.messages = messages; + this.fetching = false; + }); + }, + beforeDestroy() { + this.connection.off('message', this.onMessage); + this.connection.off('read', this.onRead); + (this as any).os.streams.messagingIndexStream.dispose(this.connectionId); + }, + methods: { + getAcct, + isMe(message) { + return message.user_id == (this as any).os.i.id; + }, + onMessage(message) { + this.messages = this.messages.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.messages.unshift(message); + }, + onRead(ids) { + ids.forEach(id => { + const found = this.messages.find(m => m.id == id); + if (found) found.is_read = true; + }); + }, + search() { + if (this.q == '') { + this.result = []; + return; + } + (this as any).api('users/search', { + query: this.q, + max: 5 + }).then(users => { + this.result = users; + }); + }, + navigate(user) { + this.$emit('navigate', user); + }, + onSearchKeydown(e) { + switch (e.which) { + case 9: // [TAB] + case 40: // [↓] + e.preventDefault(); + e.stopPropagation(); + (this.$refs.searchResult as any).childNodes[0].focus(); + break; + } + }, + onSearchResultKeydown(i, e) { + const list = this.$refs.searchResult as any; + + const cancel = () => { + e.preventDefault(); + e.stopPropagation(); + }; + + switch (true) { + case e.which == 27: // [ESC] + cancel(); + (this.$refs.search as any).focus(); + break; + + case e.which == 9 && e.shiftKey: // [TAB] + [Shift] + case e.which == 38: // [↑] + cancel(); + (list.childNodes[i].previousElementSibling || list.childNodes[this.result.length - 1]).focus(); + break; + + case e.which == 9: // [TAB] + case e.which == 40: // [↓] + cancel(); + (list.childNodes[i].nextElementSibling || list.childNodes[0]).focus(); + break; + } + } + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +.mk-messaging + + &[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 + position sticky + top 0 + left 0 + z-index 1 + width 100% + background #fff + box-shadow 0 0px 2px rgba(0, 0, 0, 0.2) + + > .form + padding 8px + background #f7f7f7 + + > label + display block + position absolute + top 0 + left 8px + z-index 1 + height 100% + width 38px + pointer-events none + + > [data-fa] + display block + position absolute + top 0 + right 0 + bottom 0 + left 0 + width 1em + line-height 56px + margin auto + color #555 + + > input + margin 0 + padding 0 0 0 32px + width 100% + font-size 1em + line-height 38px + color #000 + outline none + border solid 1px #eee + border-radius 5px + box-shadow none + transition color 0.5s ease, border 0.5s ease + + &:hover + border solid 1px #ddd + transition border 0.2s ease + + &:focus + color darken($theme-color, 20%) + border solid 1px $theme-color + transition color 0, border 0 + + > .result + display block + top 0 + left 0 + z-index 2 + width 100% + margin 0 + padding 0 + background #fff + + > .users + margin 0 + padding 0 + list-style none + + > li + display inline-block + z-index 1 + width 100% + padding 8px 32px + vertical-align top + white-space nowrap + overflow hidden + color rgba(0, 0, 0, 0.8) + text-decoration none + transition none + cursor pointer + + &:hover + &:focus + color #fff + background $theme-color + + .name + color #fff + + .username + color #fff + + &:active + color #fff + background darken($theme-color, 10%) + + .name + color #fff + + .username + color #fff + + .avatar + vertical-align middle + min-width 32px + min-height 32px + max-width 32px + max-height 32px + margin 0 8px 0 0 + border-radius 6px + + .name + margin 0 8px 0 0 + /*font-weight bold*/ + font-weight normal + color rgba(0, 0, 0, 0.8) + + .username + font-weight normal + color rgba(0, 0, 0, 0.3) + + > .history + + > a + display block + text-decoration none + background #fff + border-bottom solid 1px #eee + + * + pointer-events none + user-select none + + &:hover + background #fafafa + + > .avatar + filter saturate(200%) + + &:active + background #eee + + &[data-is-read] + &[data-is-me] + opacity 0.8 + + &:not([data-is-me]):not([data-is-read]) + > div + background-image url("/assets/unread.svg") + background-repeat no-repeat + background-position 0 center + + &:after + content "" + display block + clear both + + > div + max-width 500px + margin 0 auto + padding 20px 30px + + &:after + content "" + display block + clear both + + > header + display flex + align-items center + margin-bottom 2px + white-space nowrap + overflow hidden + + > .name + margin 0 + padding 0 + overflow hidden + text-overflow ellipsis + font-size 1em + color rgba(0, 0, 0, 0.9) + font-weight bold + transition all 0.1s ease + + > .username + margin 0 8px + color rgba(0, 0, 0, 0.5) + + > .mk-time + margin 0 0 0 auto + color rgba(0, 0, 0, 0.5) + font-size 80% + + > .avatar + float left + width 54px + height 54px + margin 0 16px 0 0 + border-radius 8px + transition all 0.1s ease + + > .body + + > .text + display block + margin 0 0 0 0 + padding 0 + overflow hidden + overflow-wrap break-word + font-size 1.1em + color rgba(0, 0, 0, 0.8) + + .me + color rgba(0, 0, 0, 0.4) + + > .image + display block + max-width 100% + max-height 512px + + > .no-history + margin 0 + padding 2em 1em + text-align center + color #999 + font-weight 500 + + > .fetching + margin 0 + padding 16px + text-align center + color #aaa + + > [data-fa] + margin-right 4px + + // TODO: element base media query + @media (max-width 400px) + > .search + > .result + > .users + > li + padding 8px 16px + + > .history + > a + &:not([data-is-me]):not([data-is-read]) + > div + background-image none + border-left solid 4px #3aa2dc + + > div + padding 16px + font-size 14px + + > .avatar + margin 0 12px 0 0 + +</style> diff --git a/src/server/web/app/common/views/components/nav.vue b/src/server/web/app/common/views/components/nav.vue new file mode 100644 index 0000000000..8ce75d3529 --- /dev/null +++ b/src/server/web/app/common/views/components/nav.vue @@ -0,0 +1,41 @@ +<template> +<span class="mk-nav"> + <a :href="aboutUrl">%i18n:common.tags.mk-nav-links.about%</a> + <i>・</i> + <a :href="statsUrl">%i18n:common.tags.mk-nav-links.stats%</a> + <i>・</i> + <a :href="statusUrl">%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="devUrl">%i18n:common.tags.mk-nav-links.develop%</a> + <i>・</i> + <a href="https://twitter.com/misskey_xyz" target="_blank">Follow us on %fa:B twitter%</a> +</span> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { docsUrl, statsUrl, statusUrl, devUrl, lang } from '../../../config'; + +export default Vue.extend({ + data() { + return { + aboutUrl: `${docsUrl}/${lang}/about`, + statsUrl, + statusUrl, + devUrl + } + } +}); +</script> + +<style lang="stylus" scoped> +.mk-nav + a + color inherit +</style> diff --git a/src/server/web/app/common/views/components/othello.game.vue b/src/server/web/app/common/views/components/othello.game.vue new file mode 100644 index 0000000000..414d819a55 --- /dev/null +++ b/src/server/web/app/common/views/components/othello.game.vue @@ -0,0 +1,324 @@ +<template> +<div class="root"> + <header><b>{{ blackUser.name }}</b>(黒) vs <b>{{ whiteUser.name }}</b>(白)</header> + + <div style="overflow: hidden"> + <p class="turn" v-if="!iAmPlayer && !game.is_ended">{{ turnUser.name }}のターンです<mk-ellipsis/></p> + <p class="turn" v-if="logPos != logs.length">{{ turnUser.name }}のターン</p> + <p class="turn1" v-if="iAmPlayer && !game.is_ended && !isMyTurn">相手のターンです<mk-ellipsis/></p> + <p class="turn2" v-if="iAmPlayer && !game.is_ended && isMyTurn" v-animate-css="{ classes: 'tada', iteration: 'infinite' }">あなたのターンです</p> + <p class="result" v-if="game.is_ended && logPos == logs.length"> + <template v-if="game.winner"><b>{{ game.winner.name }}</b>の勝ち{{ game.settings.is_llotheo ? ' (ロセオ)' : '' }}</template> + <template v-else>引き分け</template> + </p> + </div> + + <div class="board" :style="{ 'grid-template-rows': `repeat(${ game.settings.map.length }, 1fr)`, 'grid-template-columns': `repeat(${ game.settings.map[0].length }, 1fr)` }"> + <div v-for="(stone, i) in o.board" + :class="{ empty: stone == null, none: o.map[i] == 'null', isEnded: game.is_ended, myTurn: !game.is_ended && isMyTurn, can: turnUser ? o.canPut(turnUser.id == blackUser.id, i) : null, prev: o.prevPos == i }" + @click="set(i)" + :title="'[' + (o.transformPosToXy(i)[0] + 1) + ', ' + (o.transformPosToXy(i)[1] + 1) + '] (' + i + ')'" + > + <img v-if="stone === true" :src="`${blackUser.avatar_url}?thumbnail&size=128`" alt=""> + <img v-if="stone === false" :src="`${whiteUser.avatar_url}?thumbnail&size=128`" alt=""> + </div> + </div> + + <p class="status"><b>{{ logPos }}ターン目</b> 黒:{{ o.blackCount }} 白:{{ o.whiteCount }} 合計:{{ o.blackCount + o.whiteCount }}</p> + + <div class="player" v-if="game.is_ended"> + <el-button-group> + <el-button type="primary" @click="logPos = 0" :disabled="logPos == 0">%fa:angle-double-left%</el-button> + <el-button type="primary" @click="logPos--" :disabled="logPos == 0">%fa:angle-left%</el-button> + </el-button-group> + <span>{{ logPos }} / {{ logs.length }}</span> + <el-button-group> + <el-button type="primary" @click="logPos++" :disabled="logPos == logs.length">%fa:angle-right%</el-button> + <el-button type="primary" @click="logPos = logs.length" :disabled="logPos == logs.length">%fa:angle-double-right%</el-button> + </el-button-group> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import * as CRC32 from 'crc-32'; +import Othello, { Color } from '../../../../../common/othello/core'; +import { url } from '../../../config'; + +export default Vue.extend({ + props: ['initGame', 'connection'], + + data() { + return { + game: null, + o: null as Othello, + logs: [], + logPos: 0, + pollingClock: null + }; + }, + + computed: { + iAmPlayer(): boolean { + if (!(this as any).os.isSignedIn) return false; + return this.game.user1_id == (this as any).os.i.id || this.game.user2_id == (this as any).os.i.id; + }, + myColor(): Color { + if (!this.iAmPlayer) return null; + if (this.game.user1_id == (this as any).os.i.id && this.game.black == 1) return true; + if (this.game.user2_id == (this as any).os.i.id && this.game.black == 2) return true; + return false; + }, + opColor(): Color { + if (!this.iAmPlayer) return null; + return this.myColor === true ? false : true; + }, + blackUser(): any { + return this.game.black == 1 ? this.game.user1 : this.game.user2; + }, + whiteUser(): any { + return this.game.black == 1 ? this.game.user2 : this.game.user1; + }, + turnUser(): any { + if (this.o.turn === true) { + return this.game.black == 1 ? this.game.user1 : this.game.user2; + } else if (this.o.turn === false) { + return this.game.black == 1 ? this.game.user2 : this.game.user1; + } else { + return null; + } + }, + isMyTurn(): boolean { + if (this.turnUser == null) return null; + return this.turnUser.id == (this as any).os.i.id; + } + }, + + watch: { + logPos(v) { + if (!this.game.is_ended) return; + this.o = new Othello(this.game.settings.map, { + isLlotheo: this.game.settings.is_llotheo, + canPutEverywhere: this.game.settings.can_put_everywhere, + loopedBoard: this.game.settings.looped_board + }); + this.logs.forEach((log, i) => { + if (i < v) { + this.o.put(log.color, log.pos); + } + }); + this.$forceUpdate(); + } + }, + + created() { + this.game = this.initGame; + + this.o = new Othello(this.game.settings.map, { + isLlotheo: this.game.settings.is_llotheo, + canPutEverywhere: this.game.settings.can_put_everywhere, + loopedBoard: this.game.settings.looped_board + }); + + this.game.logs.forEach(log => { + this.o.put(log.color, log.pos); + }); + + this.logs = this.game.logs; + this.logPos = this.logs.length; + + // 通信を取りこぼしてもいいように定期的にポーリングさせる + if (this.game.is_started && !this.game.is_ended) { + this.pollingClock = setInterval(() => { + const crc32 = CRC32.str(this.logs.map(x => x.pos.toString()).join('')); + this.connection.send({ + type: 'check', + crc32 + }); + }, 3000); + } + }, + + mounted() { + this.connection.on('set', this.onSet); + this.connection.on('rescue', this.onRescue); + }, + + beforeDestroy() { + this.connection.off('set', this.onSet); + this.connection.off('rescue', this.onRescue); + + clearInterval(this.pollingClock); + }, + + methods: { + set(pos) { + if (this.game.is_ended) return; + if (!this.iAmPlayer) return; + if (!this.isMyTurn) return; + if (!this.o.canPut(this.myColor, pos)) return; + + this.o.put(this.myColor, pos); + + // サウンドを再生する + if ((this as any).os.isEnableSounds) { + const sound = new Audio(`${url}/assets/othello-put-me.mp3`); + sound.volume = localStorage.getItem('soundVolume') ? parseInt(localStorage.getItem('soundVolume'), 10) / 100 : 1; + sound.play(); + } + + this.connection.send({ + type: 'set', + pos + }); + + this.checkEnd(); + + this.$forceUpdate(); + }, + + onSet(x) { + this.logs.push(x); + this.logPos++; + this.o.put(x.color, x.pos); + this.checkEnd(); + this.$forceUpdate(); + + // サウンドを再生する + if ((this as any).os.isEnableSounds && x.color != this.myColor) { + const sound = new Audio(`${url}/assets/othello-put-you.mp3`); + sound.volume = localStorage.getItem('soundVolume') ? parseInt(localStorage.getItem('soundVolume'), 10) / 100 : 1; + sound.play(); + } + }, + + checkEnd() { + this.game.is_ended = this.o.isEnded; + if (this.game.is_ended) { + if (this.o.winner === true) { + this.game.winner_id = this.game.black == 1 ? this.game.user1_id : this.game.user2_id; + this.game.winner = this.game.black == 1 ? this.game.user1 : this.game.user2; + } else if (this.o.winner === false) { + this.game.winner_id = this.game.black == 1 ? this.game.user2_id : this.game.user1_id; + this.game.winner = this.game.black == 1 ? this.game.user2 : this.game.user1; + } else { + this.game.winner_id = null; + this.game.winner = null; + } + } + }, + + // 正しいゲーム情報が送られてきたとき + onRescue(game) { + this.game = game; + + this.o = new Othello(this.game.settings.map, { + isLlotheo: this.game.settings.is_llotheo, + canPutEverywhere: this.game.settings.can_put_everywhere, + loopedBoard: this.game.settings.looped_board + }); + + this.game.logs.forEach(log => { + this.o.put(log.color, log.pos, true); + }); + + this.logs = this.game.logs; + this.logPos = this.logs.length; + + this.checkEnd(); + this.$forceUpdate(); + } + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +.root + text-align center + + > header + padding 8px + border-bottom dashed 1px #c4cdd4 + + > .board + display grid + grid-gap 4px + width 350px + height 350px + margin 0 auto + + > div + background transparent + border-radius 6px + overflow hidden + + * + pointer-events none + user-select none + + &.empty + border solid 2px #eee + + &.empty.can + background #eee + + &.empty.myTurn + border-color #ddd + + &.can + background #eee + cursor pointer + + &:hover + border-color darken($theme-color, 10%) + background $theme-color + + &:active + background darken($theme-color, 10%) + + &.prev + box-shadow 0 0 0 4px rgba($theme-color, 0.7) + + &.isEnded + border-color #ddd + + &.none + border-color transparent !important + + > img + display block + width 100% + height 100% + + > .graph + display grid + grid-template-columns repeat(61, 1fr) + width 300px + height 38px + margin 0 auto 16px auto + + > div + &:not(:empty) + background #ccc + + > div:first-child + background #333 + + > div:last-child + background #ccc + + > .status + margin 0 + padding 16px 0 + + > .player + padding-bottom 32px + + > span + display inline-block + margin 0 8px + min-width 70px +</style> diff --git a/src/server/web/app/common/views/components/othello.gameroom.vue b/src/server/web/app/common/views/components/othello.gameroom.vue new file mode 100644 index 0000000000..38a25f6686 --- /dev/null +++ b/src/server/web/app/common/views/components/othello.gameroom.vue @@ -0,0 +1,42 @@ +<template> +<div> + <x-room v-if="!g.is_started" :game="g" :connection="connection"/> + <x-game v-else :init-game="g" :connection="connection"/> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import XGame from './othello.game.vue'; +import XRoom from './othello.room.vue'; +import { OthelloGameStream } from '../../scripts/streaming/othello-game'; + +export default Vue.extend({ + components: { + XGame, + XRoom + }, + props: ['game'], + data() { + return { + connection: null, + g: null + }; + }, + created() { + this.g = this.game; + this.connection = new OthelloGameStream((this as any).os, (this as any).os.i, this.game); + this.connection.on('started', this.onStarted); + }, + beforeDestroy() { + this.connection.off('started', this.onStarted); + this.connection.close(); + }, + methods: { + onStarted(game) { + Object.assign(this.g, game); + this.$forceUpdate(); + } + } +}); +</script> diff --git a/src/server/web/app/common/views/components/othello.room.vue b/src/server/web/app/common/views/components/othello.room.vue new file mode 100644 index 0000000000..3965414836 --- /dev/null +++ b/src/server/web/app/common/views/components/othello.room.vue @@ -0,0 +1,297 @@ +<template> +<div class="root"> + <header><b>{{ game.user1.name }}</b> vs <b>{{ game.user2.name }}</b></header> + + <div> + <p>ゲームの設定</p> + + <el-card class="map"> + <div slot="header"> + <el-select :class="$style.mapSelect" v-model="mapName" placeholder="マップを選択" @change="onMapChange"> + <el-option label="ランダム" :value="null"/> + <el-option-group v-for="c in mapCategories" :key="c" :label="c"> + <el-option v-for="m in maps" v-if="m.category == c" :key="m.name" :label="m.name" :value="m.name"> + <span style="float: left">{{ m.name }}</span> + <span style="float: right; color: #8492a6; font-size: 13px" v-if="m.author">(by <i>{{ m.author }}</i>)</span> + </el-option> + </el-option-group> + </el-select> + </div> + <div :class="$style.board" v-if="game.settings.map != null" :style="{ 'grid-template-rows': `repeat(${ game.settings.map.length }, 1fr)`, 'grid-template-columns': `repeat(${ game.settings.map[0].length }, 1fr)` }"> + <div v-for="(x, i) in game.settings.map.join('')" + :data-none="x == ' '" + @click="onPixelClick(i, x)" + > + <template v-if="x == 'b'">%fa:circle%</template> + <template v-if="x == 'w'">%fa:circle R%</template> + </div> + </div> + </el-card> + + <el-card class="bw"> + <div slot="header"> + <span>先手/後手</span> + </div> + <el-radio v-model="game.settings.bw" label="random" @change="updateSettings">ランダム</el-radio> + <el-radio v-model="game.settings.bw" :label="1" @change="updateSettings">{{ game.user1.name }}が黒</el-radio> + <el-radio v-model="game.settings.bw" :label="2" @change="updateSettings">{{ game.user2.name }}が黒</el-radio> + </el-card> + + <el-card class="rules"> + <div slot="header"> + <span>ルール</span> + </div> + <mk-switch v-model="game.settings.is_llotheo" @change="updateSettings" text="石の少ない方が勝ち(ロセオ)"/> + <mk-switch v-model="game.settings.looped_board" @change="updateSettings" text="ループマップ"/> + <mk-switch v-model="game.settings.can_put_everywhere" @change="updateSettings" text="どこでも置けるモード"/> + </el-card> + + <el-card class="bot-form" v-if="form"> + <div slot="header"> + <span>Botの設定</span> + </div> + <el-alert v-for="message in messages" + :title="message.text" + :type="message.type" + :key="message.id" + /> + <template v-for="item in form"> + <mk-switch v-if="item.type == 'button'" v-model="item.value" :key="item.id" :text="item.label" @change="onChangeForm($event, item)">{{ item.desc || '' }}</mk-switch> + + <el-card v-if="item.type == 'radio'" :key="item.id"> + <div slot="header"> + <span>{{ item.label }}</span> + </div> + <el-radio v-for="(r, i) in item.items" :key="item.id + ':' + i" v-model="item.value" :label="r.value" @change="onChangeForm($event, item)">{{ r.label }}</el-radio> + </el-card> + + <el-card v-if="item.type == 'textbox'" :key="item.id"> + <div slot="header"> + <span>{{ item.label }}</span> + </div> + <el-input v-model="item.value" @change="onChangeForm($event, item)"/> + </el-card> + </template> + </el-card> + </div> + + <footer> + <p class="status"> + <template v-if="isAccepted && isOpAccepted">ゲームは数秒後に開始されます<mk-ellipsis/></template> + <template v-if="isAccepted && !isOpAccepted">相手の準備が完了するのを待っています<mk-ellipsis/></template> + <template v-if="!isAccepted && isOpAccepted">あなたの準備が完了するのを待っています</template> + <template v-if="!isAccepted && !isOpAccepted">準備中<mk-ellipsis/></template> + </p> + + <div class="actions"> + <el-button @click="exit">キャンセル</el-button> + <el-button type="primary" @click="accept" v-if="!isAccepted">準備完了</el-button> + <el-button type="primary" @click="cancel" v-if="isAccepted">準備続行</el-button> + </div> + </footer> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import * as maps from '../../../../../common/othello/maps'; + +export default Vue.extend({ + props: ['game', 'connection'], + + data() { + return { + o: null, + isLlotheo: false, + mapName: maps.eighteight.name, + maps: maps, + form: null, + messages: [] + }; + }, + + computed: { + mapCategories(): string[] { + const categories = Object.entries(maps).map(x => x[1].category); + return categories.filter((item, pos) => categories.indexOf(item) == pos); + }, + isAccepted(): boolean { + if (this.game.user1_id == (this as any).os.i.id && this.game.user1_accepted) return true; + if (this.game.user2_id == (this as any).os.i.id && this.game.user2_accepted) return true; + return false; + }, + isOpAccepted(): boolean { + if (this.game.user1_id != (this as any).os.i.id && this.game.user1_accepted) return true; + if (this.game.user2_id != (this as any).os.i.id && this.game.user2_accepted) return true; + return false; + } + }, + + created() { + this.connection.on('change-accepts', this.onChangeAccepts); + this.connection.on('update-settings', this.onUpdateSettings); + this.connection.on('init-form', this.onInitForm); + this.connection.on('message', this.onMessage); + + if (this.game.user1_id != (this as any).os.i.id && this.game.settings.form1) this.form = this.game.settings.form1; + if (this.game.user2_id != (this as any).os.i.id && this.game.settings.form2) this.form = this.game.settings.form2; + }, + + beforeDestroy() { + this.connection.off('change-accepts', this.onChangeAccepts); + this.connection.off('update-settings', this.onUpdateSettings); + this.connection.off('init-form', this.onInitForm); + this.connection.off('message', this.onMessage); + }, + + methods: { + exit() { + + }, + + accept() { + this.connection.send({ + type: 'accept' + }); + }, + + cancel() { + this.connection.send({ + type: 'cancel-accept' + }); + }, + + onChangeAccepts(accepts) { + this.game.user1_accepted = accepts.user1; + this.game.user2_accepted = accepts.user2; + this.$forceUpdate(); + }, + + updateSettings() { + this.connection.send({ + type: 'update-settings', + settings: this.game.settings + }); + }, + + onUpdateSettings(settings) { + this.game.settings = settings; + if (this.game.settings.map == null) { + this.mapName = null; + } else { + const foundMap = Object.entries(maps).find(x => x[1].data.join('') == this.game.settings.map.join('')); + this.mapName = foundMap ? foundMap[1].name : '-Custom-'; + } + }, + + onInitForm(x) { + if (x.user_id == (this as any).os.i.id) return; + this.form = x.form; + }, + + onMessage(x) { + if (x.user_id == (this as any).os.i.id) return; + this.messages.unshift(x.message); + }, + + onChangeForm(v, item) { + this.connection.send({ + type: 'update-form', + id: item.id, + value: v + }); + }, + + onMapChange(v) { + if (v == null) { + this.game.settings.map = null; + } else { + this.game.settings.map = Object.entries(maps).find(x => x[1].name == v)[1].data; + } + this.$forceUpdate(); + this.updateSettings(); + }, + + onPixelClick(pos, pixel) { + const x = pos % this.game.settings.map[0].length; + const y = Math.floor(pos / this.game.settings.map[0].length); + const newPixel = + pixel == ' ' ? '-' : + pixel == '-' ? 'b' : + pixel == 'b' ? 'w' : + ' '; + const line = this.game.settings.map[y].split(''); + line[x] = newPixel; + this.$set(this.game.settings.map, y, line.join('')); + this.$forceUpdate(); + this.updateSettings(); + } + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +.root + text-align center + background #f9f9f9 + + > header + padding 8px + border-bottom dashed 1px #c4cdd4 + + > div + padding 0 16px + + > .map + > .bw + > .rules + > .bot-form + max-width 400px + margin 0 auto 16px auto + + > footer + position sticky + bottom 0 + padding 16px + background rgba(255, 255, 255, 0.9) + border-top solid 1px #c4cdd4 + + > .status + margin 0 0 16px 0 +</style> + +<style lang="stylus" module> +.mapSelect + width 100% + +.board + display grid + grid-gap 4px + width 300px + height 300px + margin 0 auto + + > div + background transparent + border solid 2px #ddd + border-radius 6px + overflow hidden + cursor pointer + + * + pointer-events none + user-select none + width 100% + height 100% + + &[data-none] + border-color transparent + +</style> + +<style lang="stylus"> +.el-alert__content + position initial !important +</style> diff --git a/src/server/web/app/common/views/components/othello.vue b/src/server/web/app/common/views/components/othello.vue new file mode 100644 index 0000000000..d650322341 --- /dev/null +++ b/src/server/web/app/common/views/components/othello.vue @@ -0,0 +1,311 @@ +<template> +<div class="mk-othello"> + <div v-if="game"> + <x-gameroom :game="game"/> + </div> + <div class="matching" v-else-if="matching"> + <h1><b>{{ matching.name }}</b>を待っています<mk-ellipsis/></h1> + <div class="cancel"> + <el-button round @click="cancel">キャンセル</el-button> + </div> + </div> + <div class="index" v-else> + <h1>Misskey %fa:circle%thell%fa:circle R%</h1> + <p>他のMisskeyユーザーとオセロで対戦しよう</p> + <div class="play"> + <el-button round>フリーマッチ(準備中)</el-button> + <el-button type="primary" round @click="match">指名</el-button> + <details> + <summary>遊び方</summary> + <div> + <p>オセロは、相手と交互に石をボードに置いてゆき、相手の石を挟んでひっくり返しながら、最終的に残った石が多い方が勝ちというボードゲームです。</p> + <dl> + <dt><b>フリーマッチ</b></dt> + <dd>ランダムなユーザーと対戦するモードです。</dd> + <dt><b>指名</b></dt> + <dd>指定したユーザーと対戦するモードです。</dd> + </dl> + </div> + </details> + </div> + <section v-if="invitations.length > 0"> + <h2>対局の招待があります!:</h2> + <div class="invitation" v-for="i in invitations" tabindex="-1" @click="accept(i)"> + <img :src="`${i.parent.avatar_url}?thumbnail&size=32`" alt=""> + <span class="name"><b>{{ i.parent.name }}</b></span> + <span class="username">@{{ i.parent.username }}</span> + <mk-time :time="i.created_at"/> + </div> + </section> + <section v-if="myGames.length > 0"> + <h2>自分の対局</h2> + <a class="game" v-for="g in myGames" tabindex="-1" @click.prevent="go(g)" :href="`/othello/${g.id}`"> + <img :src="`${g.user1.avatar_url}?thumbnail&size=32`" alt=""> + <img :src="`${g.user2.avatar_url}?thumbnail&size=32`" alt=""> + <span><b>{{ g.user1.name }}</b> vs <b>{{ g.user2.name }}</b></span> + <span class="state">{{ g.is_ended ? '終了' : '進行中' }}</span> + </a> + </section> + <section v-if="games.length > 0"> + <h2>みんなの対局</h2> + <a class="game" v-for="g in games" tabindex="-1" @click.prevent="go(g)" :href="`/othello/${g.id}`"> + <img :src="`${g.user1.avatar_url}?thumbnail&size=32`" alt=""> + <img :src="`${g.user2.avatar_url}?thumbnail&size=32`" alt=""> + <span><b>{{ g.user1.name }}</b> vs <b>{{ g.user2.name }}</b></span> + <span class="state">{{ g.is_ended ? '終了' : '進行中' }}</span> + </a> + </section> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import XGameroom from './othello.gameroom.vue'; + +export default Vue.extend({ + components: { + XGameroom + }, + props: ['initGame'], + data() { + return { + game: null, + games: [], + gamesFetching: true, + gamesMoreFetching: false, + myGames: [], + matching: null, + invitations: [], + connection: null, + connectionId: null, + pingClock: null + }; + }, + watch: { + game(g) { + this.$emit('gamed', g); + } + }, + created() { + if (this.initGame) { + this.game = this.initGame; + } + }, + mounted() { + this.connection = (this as any).os.streams.othelloStream.getConnection(); + this.connectionId = (this as any).os.streams.othelloStream.use(); + + this.connection.on('matched', this.onMatched); + this.connection.on('invited', this.onInvited); + + (this as any).api('othello/games', { + my: true + }).then(games => { + this.myGames = games; + }); + + (this as any).api('othello/games').then(games => { + this.games = games; + this.gamesFetching = false; + }); + + (this as any).api('othello/invitations').then(invitations => { + this.invitations = this.invitations.concat(invitations); + }); + + this.pingClock = setInterval(() => { + if (this.matching) { + this.connection.send({ + type: 'ping', + id: this.matching.id + }); + } + }, 3000); + }, + beforeDestroy() { + this.connection.off('matched', this.onMatched); + this.connection.off('invited', this.onInvited); + (this as any).os.streams.othelloStream.dispose(this.connectionId); + + clearInterval(this.pingClock); + }, + methods: { + go(game) { + (this as any).api('othello/games/show', { + game_id: game.id + }).then(game => { + this.matching = null; + this.game = game; + }); + }, + match() { + (this as any).apis.input({ + title: 'ユーザー名を入力してください' + }).then(username => { + (this as any).api('users/show', { + username + }).then(user => { + (this as any).api('othello/match', { + user_id: user.id + }).then(res => { + if (res == null) { + this.matching = user; + } else { + this.game = res; + } + }); + }); + }); + }, + cancel() { + this.matching = null; + (this as any).api('othello/match/cancel'); + }, + accept(invitation) { + (this as any).api('othello/match', { + user_id: invitation.parent.id + }).then(game => { + if (game) { + this.matching = null; + this.game = game; + } + }); + }, + onMatched(game) { + this.matching = null; + this.game = game; + }, + onInvited(invite) { + this.invitations.unshift(invite); + } + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +.mk-othello + color #677f84 + background #fff + + > .matching + > h1 + margin 0 + padding 24px + font-size 20px + text-align center + font-weight normal + + > .cancel + margin 0 auto + padding 24px 0 0 0 + max-width 200px + text-align center + border-top dashed 1px #c4cdd4 + + > .index + > h1 + margin 0 + padding 24px + font-size 24px + text-align center + font-weight normal + color #fff + background linear-gradient(to bottom, #8bca3e, #d6cf31) + + & + p + margin 0 + padding 12px + margin-bottom 12px + text-align center + font-size 14px + border-bottom solid 1px #d3d9dc + + > .play + margin 0 auto + padding 0 16px + max-width 500px + text-align center + + > details + margin 8px 0 + + > div + padding 16px + font-size 14px + text-align left + background #f5f5f5 + border-radius 8px + + > section + margin 0 auto + padding 0 16px 16px 16px + max-width 500px + border-top solid 1px #d3d9dc + + > h2 + margin 0 + padding 16px 0 8px 0 + font-size 16px + font-weight bold + + .invitation + margin 8px 0 + padding 8px + border solid 1px #e1e5e8 + border-radius 6px + cursor pointer + + * + pointer-events none + user-select none + + &:focus + border-color $theme-color + + &:hover + background #f5f5f5 + + &:active + background #eee + + > img + vertical-align bottom + border-radius 100% + + > span + margin 0 8px + line-height 32px + + .game + display block + margin 8px 0 + padding 8px + color #677f84 + border solid 1px #e1e5e8 + border-radius 6px + cursor pointer + + * + pointer-events none + user-select none + + &:focus + border-color $theme-color + + &:hover + background #f5f5f5 + + &:active + background #eee + + > img + vertical-align bottom + border-radius 100% + + > span + margin 0 8px + line-height 32px +</style> diff --git a/src/server/web/app/common/views/components/poll-editor.vue b/src/server/web/app/common/views/components/poll-editor.vue new file mode 100644 index 0000000000..47d901d7b1 --- /dev/null +++ b/src/server/web/app/common/views/components/poll-editor.vue @@ -0,0 +1,142 @@ +<template> +<div class="mk-poll-editor"> + <p class="caution" v-if="choices.length < 2"> + %fa:exclamation-triangle%%i18n:common.tags.mk-poll-editor.no-only-one-choice% + </p> + <ul ref="choices"> + <li v-for="(choice, i) in choices"> + <input :value="choice" @input="onInput(i, $event)" :placeholder="'%i18n:common.tags.mk-poll-editor.choice-n%'.replace('{}', i + 1)"> + <button @click="remove(i)" title="%i18n:common.tags.mk-poll-editor.remove%"> + %fa:times% + </button> + </li> + </ul> + <button class="add" v-if="choices.length < 10" @click="add">%i18n:common.tags.mk-poll-editor.add%</button> + <button class="destroy" @click="destroy" title="%i18n:common.tags.mk-poll-editor.destroy%"> + %fa:times% + </button> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + data() { + return { + choices: ['', ''] + }; + }, + watch: { + choices() { + this.$emit('updated'); + } + }, + methods: { + onInput(i, e) { + Vue.set(this.choices, i, e.target.value); + }, + + add() { + this.choices.push(''); + this.$nextTick(() => { + (this.$refs.choices as any).childNodes[this.choices.length - 1].childNodes[0].focus(); + }); + }, + + remove(i) { + this.choices = this.choices.filter((_, _i) => _i != i); + }, + + destroy() { + this.$emit('destroyed'); + }, + + get() { + return { + choices: this.choices.filter(choice => choice != '') + } + }, + + set(data) { + if (data.choices.length == 0) return; + this.choices = data.choices; + if (data.choices.length == 1) this.choices = this.choices.concat(''); + } + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +.mk-poll-editor + padding 8px + + > .caution + margin 0 0 8px 0 + font-size 0.8em + color #f00 + + > [data-fa] + margin-right 4px + + > ul + display block + margin 0 + padding 0 + list-style none + + > li + display block + margin 8px 0 + padding 0 + width 100% + + &:first-child + margin-top 0 + + &:last-child + margin-bottom 0 + + > input + padding 6px 8px + width 300px + font-size 14px + border solid 1px rgba($theme-color, 0.1) + border-radius 4px + + &:hover + border-color rgba($theme-color, 0.2) + + &:focus + border-color rgba($theme-color, 0.5) + + > button + padding 4px 8px + color rgba($theme-color, 0.4) + + &:hover + color rgba($theme-color, 0.6) + + &:active + color darken($theme-color, 30%) + + > .add + margin 8px 0 0 0 + vertical-align top + color $theme-color + + > .destroy + position absolute + top 0 + right 0 + padding 4px 8px + color rgba($theme-color, 0.4) + + &:hover + color rgba($theme-color, 0.6) + + &:active + color darken($theme-color, 30%) + +</style> diff --git a/src/server/web/app/common/views/components/poll.vue b/src/server/web/app/common/views/components/poll.vue new file mode 100644 index 0000000000..8156c8bc58 --- /dev/null +++ b/src/server/web/app/common/views/components/poll.vue @@ -0,0 +1,124 @@ +<template> +<div class="mk-poll" :data-is-voted="isVoted"> + <ul> + <li v-for="choice in poll.choices" :key="choice.id" @click="vote(choice.id)" :class="{ voted: choice.voted }" :title="!isVoted ? '%i18n:common.tags.mk-poll.vote-to%'.replace('{}', choice.text) : ''"> + <div class="backdrop" :style="{ 'width': (showResult ? (choice.votes / total * 100) : 0) + '%' }"></div> + <span> + <template v-if="choice.is_voted">%fa:check%</template> + <span>{{ choice.text }}</span> + <span class="votes" v-if="showResult">({{ '%i18n:common.tags.mk-poll.vote-count%'.replace('{}', choice.votes) }})</span> + </span> + </li> + </ul> + <p v-if="total > 0"> + <span>{{ '%i18n:common.tags.mk-poll.total-users%'.replace('{}', total) }}</span> + <span>・</span> + <a v-if="!isVoted" @click="toggleShowResult">{{ showResult ? '%i18n:common.tags.mk-poll.vote%' : '%i18n:common.tags.mk-poll.show-result%' }}</a> + <span v-if="isVoted">%i18n:common.tags.mk-poll.voted%</span> + </p> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + props: ['post'], + data() { + return { + showResult: false + }; + }, + computed: { + poll(): any { + return this.post.poll; + }, + total(): number { + return this.poll.choices.reduce((a, b) => a + b.votes, 0); + }, + isVoted(): boolean { + return this.poll.choices.some(c => c.is_voted); + } + }, + created() { + this.showResult = this.isVoted; + }, + methods: { + toggleShowResult() { + this.showResult = !this.showResult; + }, + vote(id) { + if (this.poll.choices.some(c => c.is_voted)) return; + (this as any).api('posts/polls/vote', { + post_id: this.post.id, + choice: id + }).then(() => { + this.poll.choices.forEach(c => { + if (c.id == id) { + c.votes++; + Vue.set(c, 'is_voted', true); + } + }); + this.showResult = true; + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +.mk-poll + + > ul + display block + margin 0 + padding 0 + list-style none + + > li + display block + margin 4px 0 + padding 4px 8px + width 100% + border solid 1px #eee + border-radius 4px + overflow hidden + cursor pointer + + &:hover + background rgba(0, 0, 0, 0.05) + + &:active + background rgba(0, 0, 0, 0.1) + + > .backdrop + position absolute + top 0 + left 0 + height 100% + background $theme-color + transition width 1s ease + + > span + > [data-fa] + margin-right 4px + + > .votes + margin-left 4px + + > p + a + color inherit + + &[data-is-voted] + > ul > li + cursor default + + &:hover + background transparent + + &:active + background transparent + +</style> diff --git a/src/server/web/app/common/views/components/post-html.ts b/src/server/web/app/common/views/components/post-html.ts new file mode 100644 index 0000000000..98da86617d --- /dev/null +++ b/src/server/web/app/common/views/components/post-html.ts @@ -0,0 +1,137 @@ +import Vue from 'vue'; +import * as emojilib from 'emojilib'; +import getAcct from '../../../../../common/user/get-acct'; +import { url } from '../../../config'; +import MkUrl from './url.vue'; + +const flatten = list => list.reduce( + (a, b) => a.concat(Array.isArray(b) ? flatten(b) : b), [] +); + +export default Vue.component('mk-post-html', { + props: { + ast: { + type: Array, + required: true + }, + shouldBreak: { + type: Boolean, + default: true + }, + i: { + type: Object, + default: null + } + }, + render(createElement) { + const els = flatten((this as any).ast.map(token => { + switch (token.type) { + case 'text': + const text = token.content.replace(/(\r\n|\n|\r)/g, '\n'); + + if ((this as any).shouldBreak) { + const x = text.split('\n') + .map(t => t == '' ? [createElement('br')] : [createElement('span', t), createElement('br')]); + x[x.length - 1].pop(); + return x; + } else { + return createElement('span', text.replace(/\n/g, ' ')); + } + + case 'bold': + return createElement('strong', token.bold); + + case 'url': + return createElement(MkUrl, { + props: { + url: token.content, + target: '_blank' + } + }); + + case 'link': + return createElement('a', { + attrs: { + class: 'link', + href: token.url, + target: '_blank', + title: token.url + } + }, token.title); + + case 'mention': + return (createElement as any)('a', { + attrs: { + href: `${url}/@${getAcct(token)}`, + target: '_blank', + dataIsMe: (this as any).i && getAcct((this as any).i) == getAcct(token) + }, + directives: [{ + name: 'user-preview', + value: token.content + }] + }, token.content); + + case 'hashtag': + return createElement('a', { + attrs: { + href: `${url}/search?q=${token.content}`, + target: '_blank' + } + }, token.content); + + case 'code': + return createElement('pre', [ + createElement('code', { + domProps: { + innerHTML: token.html + } + }) + ]); + + case 'inline-code': + return createElement('code', token.html); + + case 'quote': + const text2 = token.quote.replace(/(\r\n|\n|\r)/g, '\n'); + + if ((this as any).shouldBreak) { + const x = text2.split('\n') + .map(t => [createElement('span', t), createElement('br')]); + x[x.length - 1].pop(); + return createElement('div', { + attrs: { + class: 'quote' + } + }, x); + } else { + return createElement('span', { + attrs: { + class: 'quote' + } + }, text2.replace(/\n/g, ' ')); + } + + case 'emoji': + const emoji = emojilib.lib[token.emoji]; + return createElement('span', emoji ? emoji.char : token.content); + + default: + console.log('unknown ast type:', token.type); + } + })); + + const _els = []; + els.forEach((el, i) => { + if (el.tag == 'br') { + if (els[i - 1].tag != 'div') { + _els.push(el); + } + } else { + _els.push(el); + } + }); + + return createElement('span', _els); + } +}); diff --git a/src/server/web/app/common/views/components/post-menu.vue b/src/server/web/app/common/views/components/post-menu.vue new file mode 100644 index 0000000000..a53680e55a --- /dev/null +++ b/src/server/web/app/common/views/components/post-menu.vue @@ -0,0 +1,141 @@ +<template> +<div class="mk-post-menu"> + <div class="backdrop" ref="backdrop" @click="close"></div> + <div class="popover" :class="{ compact }" ref="popover"> + <button v-if="post.user_id == os.i.id" @click="pin">%i18n:common.tags.mk-post-menu.pin%</button> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import * as anime from 'animejs'; + +export default Vue.extend({ + props: ['post', 'source', 'compact'], + mounted() { + this.$nextTick(() => { + const popover = this.$refs.popover as any; + + const rect = this.source.getBoundingClientRect(); + const width = popover.offsetWidth; + const height = popover.offsetHeight; + + if (this.compact) { + const x = rect.left + window.pageXOffset + (this.source.offsetWidth / 2); + const y = rect.top + window.pageYOffset + (this.source.offsetHeight / 2); + popover.style.left = (x - (width / 2)) + 'px'; + popover.style.top = (y - (height / 2)) + 'px'; + } else { + const x = rect.left + window.pageXOffset + (this.source.offsetWidth / 2); + const y = rect.top + window.pageYOffset + this.source.offsetHeight; + popover.style.left = (x - (width / 2)) + 'px'; + popover.style.top = y + 'px'; + } + + anime({ + targets: this.$refs.backdrop, + opacity: 1, + duration: 100, + easing: 'linear' + }); + + anime({ + targets: this.$refs.popover, + opacity: 1, + scale: [0.5, 1], + duration: 500 + }); + }); + }, + methods: { + pin() { + (this as any).api('i/pin', { + post_id: this.post.id + }).then(() => { + this.$destroy(); + }); + }, + + close() { + (this.$refs.backdrop as any).style.pointerEvents = 'none'; + anime({ + targets: this.$refs.backdrop, + opacity: 0, + duration: 200, + easing: 'linear' + }); + + (this.$refs.popover as any).style.pointerEvents = 'none'; + anime({ + targets: this.$refs.popover, + opacity: 0, + scale: 0.5, + duration: 200, + easing: 'easeInBack', + complete: () => this.$destroy() + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +$border-color = rgba(27, 31, 35, 0.15) + +.mk-post-menu + position initial + + > .backdrop + position fixed + top 0 + left 0 + z-index 10000 + width 100% + height 100% + background rgba(0, 0, 0, 0.1) + opacity 0 + + > .popover + position absolute + z-index 10001 + background #fff + border 1px solid $border-color + border-radius 4px + box-shadow 0 3px 12px rgba(27, 31, 35, 0.15) + transform scale(0.5) + opacity 0 + + $balloon-size = 16px + + &:not(.compact) + margin-top $balloon-size + transform-origin center -($balloon-size) + + &:before + content "" + display block + position absolute + top -($balloon-size * 2) + left s('calc(50% - %s)', $balloon-size) + border-top solid $balloon-size transparent + border-left solid $balloon-size transparent + border-right solid $balloon-size transparent + border-bottom solid $balloon-size $border-color + + &:after + content "" + display block + position absolute + top -($balloon-size * 2) + 1.5px + left s('calc(50% - %s)', $balloon-size) + border-top solid $balloon-size transparent + border-left solid $balloon-size transparent + border-right solid $balloon-size transparent + border-bottom solid $balloon-size #fff + + > button + display block + padding 16px + +</style> diff --git a/src/server/web/app/common/views/components/reaction-icon.vue b/src/server/web/app/common/views/components/reaction-icon.vue new file mode 100644 index 0000000000..7d24f4f9e9 --- /dev/null +++ b/src/server/web/app/common/views/components/reaction-icon.vue @@ -0,0 +1,28 @@ +<template> +<span class="mk-reaction-icon"> + <img v-if="reaction == 'like'" src="/assets/reactions/like.png" alt="%i18n:common.reactions.like%"> + <img v-if="reaction == 'love'" src="/assets/reactions/love.png" alt="%i18n:common.reactions.love%"> + <img v-if="reaction == 'laugh'" src="/assets/reactions/laugh.png" alt="%i18n:common.reactions.laugh%"> + <img v-if="reaction == 'hmm'" src="/assets/reactions/hmm.png" alt="%i18n:common.reactions.hmm%"> + <img v-if="reaction == 'surprise'" src="/assets/reactions/surprise.png" alt="%i18n:common.reactions.surprise%"> + <img v-if="reaction == 'congrats'" src="/assets/reactions/congrats.png" alt="%i18n:common.reactions.congrats%"> + <img v-if="reaction == 'angry'" src="/assets/reactions/angry.png" alt="%i18n:common.reactions.angry%"> + <img v-if="reaction == 'confused'" src="/assets/reactions/confused.png" alt="%i18n:common.reactions.confused%"> + <img v-if="reaction == 'pudding'" src="/assets/reactions/pudding.png" alt="%i18n:common.reactions.pudding%"> +</span> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + props: ['reaction'] +}); +</script> + +<style lang="stylus" scoped> +.mk-reaction-icon + img + vertical-align middle + width 1em + height 1em +</style> diff --git a/src/server/web/app/common/views/components/reaction-picker.vue b/src/server/web/app/common/views/components/reaction-picker.vue new file mode 100644 index 0000000000..df8100f2fc --- /dev/null +++ b/src/server/web/app/common/views/components/reaction-picker.vue @@ -0,0 +1,191 @@ +<template> +<div class="mk-reaction-picker"> + <div class="backdrop" ref="backdrop" @click="close"></div> + <div class="popover" :class="{ compact }" ref="popover"> + <p v-if="!compact">{{ title }}</p> + <div> + <button @click="react('like')" @mouseover="onMouseover" @mouseout="onMouseout" tabindex="1" title="%i18n:common.reactions.like%"><mk-reaction-icon reaction='like'/></button> + <button @click="react('love')" @mouseover="onMouseover" @mouseout="onMouseout" tabindex="2" title="%i18n:common.reactions.love%"><mk-reaction-icon reaction='love'/></button> + <button @click="react('laugh')" @mouseover="onMouseover" @mouseout="onMouseout" tabindex="3" title="%i18n:common.reactions.laugh%"><mk-reaction-icon reaction='laugh'/></button> + <button @click="react('hmm')" @mouseover="onMouseover" @mouseout="onMouseout" tabindex="4" title="%i18n:common.reactions.hmm%"><mk-reaction-icon reaction='hmm'/></button> + <button @click="react('surprise')" @mouseover="onMouseover" @mouseout="onMouseout" tabindex="5" title="%i18n:common.reactions.surprise%"><mk-reaction-icon reaction='surprise'/></button> + <button @click="react('congrats')" @mouseover="onMouseover" @mouseout="onMouseout" tabindex="6" title="%i18n:common.reactions.congrats%"><mk-reaction-icon reaction='congrats'/></button> + <button @click="react('angry')" @mouseover="onMouseover" @mouseout="onMouseout" tabindex="4" title="%i18n:common.reactions.angry%"><mk-reaction-icon reaction='angry'/></button> + <button @click="react('confused')" @mouseover="onMouseover" @mouseout="onMouseout" tabindex="5" title="%i18n:common.reactions.confused%"><mk-reaction-icon reaction='confused'/></button> + <button @click="react('pudding')" @mouseover="onMouseover" @mouseout="onMouseout" tabindex="6" title="%i18n:common.reactions.pudding%"><mk-reaction-icon reaction='pudding'/></button> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import * as anime from 'animejs'; + +const placeholder = '%i18n:common.tags.mk-reaction-picker.choose-reaction%'; + +export default Vue.extend({ + props: ['post', 'source', 'compact', 'cb'], + data() { + return { + title: placeholder + }; + }, + mounted() { + this.$nextTick(() => { + const popover = this.$refs.popover as any; + + const rect = this.source.getBoundingClientRect(); + const width = popover.offsetWidth; + const height = popover.offsetHeight; + + if (this.compact) { + const x = rect.left + window.pageXOffset + (this.source.offsetWidth / 2); + const y = rect.top + window.pageYOffset + (this.source.offsetHeight / 2); + popover.style.left = (x - (width / 2)) + 'px'; + popover.style.top = (y - (height / 2)) + 'px'; + } else { + const x = rect.left + window.pageXOffset + (this.source.offsetWidth / 2); + const y = rect.top + window.pageYOffset + this.source.offsetHeight; + popover.style.left = (x - (width / 2)) + 'px'; + popover.style.top = y + 'px'; + } + + anime({ + targets: this.$refs.backdrop, + opacity: 1, + duration: 100, + easing: 'linear' + }); + + anime({ + targets: this.$refs.popover, + opacity: 1, + scale: [0.5, 1], + duration: 500 + }); + }); + }, + methods: { + react(reaction) { + (this as any).api('posts/reactions/create', { + post_id: this.post.id, + reaction: reaction + }).then(() => { + if (this.cb) this.cb(); + this.$destroy(); + }); + }, + onMouseover(e) { + this.title = e.target.title; + }, + onMouseout(e) { + this.title = placeholder; + }, + close() { + (this.$refs.backdrop as any).style.pointerEvents = 'none'; + anime({ + targets: this.$refs.backdrop, + opacity: 0, + duration: 200, + easing: 'linear' + }); + + (this.$refs.popover as any).style.pointerEvents = 'none'; + anime({ + targets: this.$refs.popover, + opacity: 0, + scale: 0.5, + duration: 200, + easing: 'easeInBack', + complete: () => this.$destroy() + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +$border-color = rgba(27, 31, 35, 0.15) + +.mk-reaction-picker + position initial + + > .backdrop + position fixed + top 0 + left 0 + z-index 10000 + width 100% + height 100% + background rgba(0, 0, 0, 0.1) + opacity 0 + + > .popover + position absolute + z-index 10001 + background #fff + border 1px solid $border-color + border-radius 4px + box-shadow 0 3px 12px rgba(27, 31, 35, 0.15) + transform scale(0.5) + opacity 0 + + $balloon-size = 16px + + &:not(.compact) + margin-top $balloon-size + transform-origin center -($balloon-size) + + &:before + content "" + display block + position absolute + top -($balloon-size * 2) + left s('calc(50% - %s)', $balloon-size) + border-top solid $balloon-size transparent + border-left solid $balloon-size transparent + border-right solid $balloon-size transparent + border-bottom solid $balloon-size $border-color + + &:after + content "" + display block + position absolute + top -($balloon-size * 2) + 1.5px + left s('calc(50% - %s)', $balloon-size) + border-top solid $balloon-size transparent + border-left solid $balloon-size transparent + border-right solid $balloon-size transparent + border-bottom solid $balloon-size #fff + + > p + display block + margin 0 + padding 8px 10px + font-size 14px + color #586069 + border-bottom solid 1px #e1e4e8 + + > div + padding 4px + width 240px + text-align center + + > button + padding 0 + width 40px + height 40px + font-size 24px + border-radius 2px + + &:hover + background #eee + + &:active + background $theme-color + box-shadow inset 0 0.15em 0.3em rgba(27, 31, 35, 0.15) + +</style> diff --git a/src/server/web/app/common/views/components/reactions-viewer.vue b/src/server/web/app/common/views/components/reactions-viewer.vue new file mode 100644 index 0000000000..f6a27d9139 --- /dev/null +++ b/src/server/web/app/common/views/components/reactions-viewer.vue @@ -0,0 +1,49 @@ +<template> +<div class="mk-reactions-viewer"> + <template v-if="reactions"> + <span v-if="reactions.like"><mk-reaction-icon reaction='like'/><span>{{ reactions.like }}</span></span> + <span v-if="reactions.love"><mk-reaction-icon reaction='love'/><span>{{ reactions.love }}</span></span> + <span v-if="reactions.laugh"><mk-reaction-icon reaction='laugh'/><span>{{ reactions.laugh }}</span></span> + <span v-if="reactions.hmm"><mk-reaction-icon reaction='hmm'/><span>{{ reactions.hmm }}</span></span> + <span v-if="reactions.surprise"><mk-reaction-icon reaction='surprise'/><span>{{ reactions.surprise }}</span></span> + <span v-if="reactions.congrats"><mk-reaction-icon reaction='congrats'/><span>{{ reactions.congrats }}</span></span> + <span v-if="reactions.angry"><mk-reaction-icon reaction='angry'/><span>{{ reactions.angry }}</span></span> + <span v-if="reactions.confused"><mk-reaction-icon reaction='confused'/><span>{{ reactions.confused }}</span></span> + <span v-if="reactions.pudding"><mk-reaction-icon reaction='pudding'/><span>{{ reactions.pudding }}</span></span> + </template> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + props: ['post'], + computed: { + reactions(): number { + return this.post.reaction_counts; + } + } +}); +</script> + +<style lang="stylus" scoped> +.mk-reactions-viewer + border-top dashed 1px #eee + border-bottom dashed 1px #eee + margin 4px 0 + + &:empty + display none + + > span + margin-right 8px + + > .mk-reaction-icon + font-size 1.4em + + > span + margin-left 4px + font-size 1.2em + color #444 + +</style> diff --git a/src/server/web/app/common/views/components/signin.vue b/src/server/web/app/common/views/components/signin.vue new file mode 100644 index 0000000000..2434684085 --- /dev/null +++ b/src/server/web/app/common/views/components/signin.vue @@ -0,0 +1,142 @@ +<template> +<form class="mk-signin" :class="{ signing }" @submit.prevent="onSubmit"> + <label class="user-name"> + <input v-model="username" type="text" pattern="^[a-zA-Z0-9-]+$" placeholder="%i18n:common.tags.mk-signin.username%" autofocus required @change="onUsernameChange"/>%fa:at% + </label> + <label class="password"> + <input v-model="password" type="password" placeholder="%i18n:common.tags.mk-signin.password%" required/>%fa:lock% + </label> + <label class="token" v-if="user && user.account.two_factor_enabled"> + <input v-model="token" type="number" placeholder="%i18n:common.tags.mk-signin.token%" required/>%fa:lock% + </label> + <button type="submit" :disabled="signing">{{ signing ? '%i18n:common.tags.mk-signin.signing-in%' : '%i18n:common.tags.mk-signin.signin%' }}</button> + もしくは <a :href="`${apiUrl}/signin/twitter`">Twitterでログイン</a> +</form> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { apiUrl } from '../../../config'; + +export default Vue.extend({ + data() { + return { + signing: false, + user: null, + username: '', + password: '', + token: '', + apiUrl, + }; + }, + methods: { + onUsernameChange() { + (this as any).api('users/show', { + username: this.username + }).then(user => { + this.user = user; + }); + }, + onSubmit() { + this.signing = true; + + (this as any).api('signin', { + username: this.username, + password: this.password, + token: this.user && this.user.account.two_factor_enabled ? this.token : undefined + }).then(() => { + location.reload(); + }).catch(() => { + alert('something happened'); + this.signing = false; + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +.mk-signin + &.signing + &, * + cursor wait !important + + label + display block + margin 12px 0 + + [data-fa] + display block + pointer-events none + position absolute + bottom 0 + top 0 + left 0 + z-index 1 + margin auto + padding 0 16px + height 1em + color #898786 + + input[type=text] + input[type=password] + input[type=number] + user-select text + display inline-block + cursor auto + padding 0 0 0 38px + margin 0 + width 100% + line-height 44px + font-size 1em + color rgba(0, 0, 0, 0.7) + background #fff + outline none + border solid 1px #eee + border-radius 4px + + &:hover + background rgba(255, 255, 255, 0.7) + border-color #ddd + + & + i + color #797776 + + &:focus + background #fff + border-color #ccc + + & + i + color #797776 + + [type=submit] + cursor pointer + padding 16px + margin -6px 0 0 0 + width 100% + font-size 1.2em + color rgba(0, 0, 0, 0.5) + outline none + border none + border-radius 0 + background transparent + transition all .5s ease + + &:hover + color $theme-color + transition all .2s ease + + &:focus + color $theme-color + transition all .2s ease + + &:active + color darken($theme-color, 30%) + transition all .2s ease + + &:disabled + opacity 0.7 + +</style> diff --git a/src/server/web/app/common/views/components/signup.vue b/src/server/web/app/common/views/components/signup.vue new file mode 100644 index 0000000000..c2e78aa8a3 --- /dev/null +++ b/src/server/web/app/common/views/components/signup.vue @@ -0,0 +1,287 @@ +<template> +<form class="mk-signup" @submit.prevent="onSubmit" autocomplete="off"> + <label class="username"> + <p class="caption">%fa:at%%i18n:common.tags.mk-signup.username%</p> + <input v-model="username" type="text" pattern="^[a-zA-Z0-9-]{3,20}$" placeholder="a~z、A~Z、0~9、-" autocomplete="off" required @input="onChangeUsername"/> + <p class="profile-page-url-preview" v-if="shouldShowProfileUrl">{{ `${url}/@${username}` }}</p> + <p class="info" v-if="usernameState == 'wait'" style="color:#999">%fa:spinner .pulse .fw%%i18n:common.tags.mk-signup.checking%</p> + <p class="info" v-if="usernameState == 'ok'" style="color:#3CB7B5">%fa:check .fw%%i18n:common.tags.mk-signup.available%</p> + <p class="info" v-if="usernameState == 'unavailable'" style="color:#FF1161">%fa:exclamation-triangle .fw%%i18n:common.tags.mk-signup.unavailable%</p> + <p class="info" v-if="usernameState == 'error'" style="color:#FF1161">%fa:exclamation-triangle .fw%%i18n:common.tags.mk-signup.error%</p> + <p class="info" v-if="usernameState == 'invalid-format'" style="color:#FF1161">%fa:exclamation-triangle .fw%%i18n:common.tags.mk-signup.invalid-format%</p> + <p class="info" v-if="usernameState == 'min-range'" style="color:#FF1161">%fa:exclamation-triangle .fw%%i18n:common.tags.mk-signup.too-short%</p> + <p class="info" v-if="usernameState == 'max-range'" style="color:#FF1161">%fa:exclamation-triangle .fw%%i18n:common.tags.mk-signup.too-long%</p> + </label> + <label class="password"> + <p class="caption">%fa:lock%%i18n:common.tags.mk-signup.password%</p> + <input v-model="password" type="password" placeholder="%i18n:common.tags.mk-signup.password-placeholder%" autocomplete="off" required @input="onChangePassword"/> + <div class="meter" v-show="passwordStrength != ''" :data-strength="passwordStrength"> + <div class="value" ref="passwordMetar"></div> + </div> + <p class="info" v-if="passwordStrength == 'low'" style="color:#FF1161">%fa:exclamation-triangle .fw%%i18n:common.tags.mk-signup.weak-password%</p> + <p class="info" v-if="passwordStrength == 'medium'" style="color:#3CB7B5">%fa:check .fw%%i18n:common.tags.mk-signup.normal-password%</p> + <p class="info" v-if="passwordStrength == 'high'" style="color:#3CB7B5">%fa:check .fw%%i18n:common.tags.mk-signup.strong-password%</p> + </label> + <label class="retype-password"> + <p class="caption">%fa:lock%%i18n:common.tags.mk-signup.password%(%i18n:common.tags.mk-signup.retype%)</p> + <input v-model="retypedPassword" type="password" placeholder="%i18n:common.tags.mk-signup.retype-placeholder%" autocomplete="off" required @input="onChangePasswordRetype"/> + <p class="info" v-if="passwordRetypeState == 'match'" style="color:#3CB7B5">%fa:check .fw%%i18n:common.tags.mk-signup.password-matched%</p> + <p class="info" v-if="passwordRetypeState == 'not-match'" style="color:#FF1161">%fa:exclamation-triangle .fw%%i18n:common.tags.mk-signup.password-not-matched%</p> + </label> + <label class="recaptcha"> + <p class="caption"><template v-if="recaptchaed">%fa:toggle-on%</template><template v-if="!recaptchaed">%fa:toggle-off%</template>%i18n:common.tags.mk-signup.recaptcha%</p> + <div class="g-recaptcha" data-callback="onRecaptchaed" data-expired-callback="onRecaptchaExpired" :data-sitekey="recaptchaSitekey"></div> + </label> + <label class="agree-tou"> + <input name="agree-tou" type="checkbox" autocomplete="off" required/> + <p><a :href="touUrl" target="_blank">利用規約</a>に同意する</p> + </label> + <button type="submit">%i18n:common.tags.mk-signup.create%</button> +</form> +</template> + +<script lang="ts"> +import Vue from 'vue'; +const getPasswordStrength = require('syuilo-password-strength'); +import { url, docsUrl, lang, recaptchaSitekey } from '../../../config'; + +export default Vue.extend({ + data() { + return { + username: '', + password: '', + retypedPassword: '', + url, + touUrl: `${docsUrl}/${lang}/tou`, + recaptchaSitekey, + recaptchaed: false, + usernameState: null, + passwordStrength: '', + passwordRetypeState: null + } + }, + computed: { + shouldShowProfileUrl(): boolean { + return (this.username != '' && + this.usernameState != 'invalid-format' && + this.usernameState != 'min-range' && + this.usernameState != 'max-range'); + } + }, + methods: { + onChangeUsername() { + if (this.username == '') { + this.usernameState = null; + return; + } + + const err = + !this.username.match(/^[a-zA-Z0-9\-]+$/) ? 'invalid-format' : + this.username.length < 3 ? 'min-range' : + this.username.length > 20 ? 'max-range' : + null; + + if (err) { + this.usernameState = err; + return; + } + + this.usernameState = 'wait'; + + (this as any).api('username/available', { + username: this.username + }).then(result => { + this.usernameState = result.available ? 'ok' : 'unavailable'; + }).catch(err => { + this.usernameState = 'error'; + }); + }, + onChangePassword() { + if (this.password == '') { + this.passwordStrength = ''; + return; + } + + const strength = getPasswordStrength(this.password); + this.passwordStrength = strength > 0.7 ? 'high' : strength > 0.3 ? 'medium' : 'low'; + (this.$refs.passwordMetar as any).style.width = `${strength * 100}%`; + }, + onChangePasswordRetype() { + if (this.retypedPassword == '') { + this.passwordRetypeState = null; + return; + } + + this.passwordRetypeState = this.password == this.retypedPassword ? 'match' : 'not-match'; + }, + onSubmit() { + (this as any).api('signup', { + username: this.username, + password: this.password, + 'g-recaptcha-response': (window as any).grecaptcha.getResponse() + }).then(() => { + (this as any).api('signin', { + username: this.username, + password: this.password + }).then(() => { + location.href = '/'; + }); + }).catch(() => { + alert('%i18n:common.tags.mk-signup.some-error%'); + + (window as any).grecaptcha.reset(); + this.recaptchaed = false; + }); + } + }, + created() { + (window as any).onRecaptchaed = () => { + this.recaptchaed = true; + }; + + (window as any).onRecaptchaExpired = () => { + this.recaptchaed = false; + }; + }, + mounted() { + const head = document.getElementsByTagName('head')[0]; + const script = document.createElement('script'); + script.setAttribute('src', 'https://www.google.com/recaptcha/api.js'); + head.appendChild(script); + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +.mk-signup + min-width 302px + + label + display block + margin 0 0 16px 0 + + > .caption + margin 0 0 4px 0 + color #828888 + font-size 0.95em + + > [data-fa] + margin-right 0.25em + color #96adac + + > .info + display block + margin 4px 0 + font-size 0.8em + + > [data-fa] + margin-right 0.3em + + &.username + .profile-page-url-preview + display block + margin 4px 8px 0 4px + font-size 0.8em + color #888 + + &:empty + display none + + &:not(:empty) + .info + margin-top 0 + + &.password + .meter + display block + margin-top 8px + width 100% + height 8px + + &[data-strength=''] + display none + + &[data-strength='low'] + > .value + background #d73612 + + &[data-strength='medium'] + > .value + background #d7ca12 + + &[data-strength='high'] + > .value + background #61bb22 + + > .value + display block + width 0% + height 100% + background transparent + border-radius 4px + transition all 0.1s ease + + [type=text], [type=password] + user-select text + display inline-block + cursor auto + padding 0 12px + margin 0 + width 100% + line-height 44px + font-size 1em + color #333 !important + background #fff !important + outline none + border solid 1px rgba(0, 0, 0, 0.1) + border-radius 4px + box-shadow 0 0 0 114514px #fff inset + transition all .3s ease + + &:hover + border-color rgba(0, 0, 0, 0.2) + transition all .1s ease + + &:focus + color $theme-color !important + border-color $theme-color + box-shadow 0 0 0 1024px #fff inset, 0 0 0 4px rgba($theme-color, 10%) + transition all 0s ease + + &:disabled + opacity 0.5 + + .agree-tou + padding 4px + border-radius 4px + + &:hover + background #f4f4f4 + + &:active + background #eee + + &, * + cursor pointer + + p + display inline + color #555 + + button + margin 0 + padding 16px + width 100% + font-size 1em + color #fff + background $theme-color + border-radius 3px + + &:hover + background lighten($theme-color, 5%) + + &:active + background darken($theme-color, 5%) + +</style> diff --git a/src/server/web/app/common/views/components/special-message.vue b/src/server/web/app/common/views/components/special-message.vue new file mode 100644 index 0000000000..2fd4d6515e --- /dev/null +++ b/src/server/web/app/common/views/components/special-message.vue @@ -0,0 +1,42 @@ +<template> +<div class="mk-special-message"> + <p v-if="m == 1 && d == 1">%i18n:common.tags.mk-special-message.new-year%</p> + <p v-if="m == 12 && d == 25">%i18n:common.tags.mk-special-message.christmas%</p> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + data() { + return { + now: new Date() + }; + }, + computed: { + d(): number { + return this.now.getDate(); + }, + m(): number { + return this.now.getMonth() + 1; + } + } +}); +</script> + +<style lang="stylus" scoped> +.mk-special-message + &:empty + display none + + > p + margin 0 + padding 4px + text-align center + font-size 14px + font-weight bold + text-transform uppercase + color #fff + background #ff1036 + +</style> diff --git a/src/server/web/app/common/views/components/stream-indicator.vue b/src/server/web/app/common/views/components/stream-indicator.vue new file mode 100644 index 0000000000..1f18fa76ed --- /dev/null +++ b/src/server/web/app/common/views/components/stream-indicator.vue @@ -0,0 +1,86 @@ +<template> +<div class="mk-stream-indicator"> + <p v-if=" stream.state == 'initializing' "> + %fa:spinner .pulse% + <span>%i18n:common.tags.mk-stream-indicator.connecting%<mk-ellipsis/></span> + </p> + <p v-if=" stream.state == 'reconnecting' "> + %fa:spinner .pulse% + <span>%i18n:common.tags.mk-stream-indicator.reconnecting%<mk-ellipsis/></span> + </p> + <p v-if=" stream.state == 'connected' "> + %fa:check% + <span>%i18n:common.tags.mk-stream-indicator.connected%</span> + </p> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import * as anime from 'animejs'; + +export default Vue.extend({ + computed: { + stream() { + return (this as any).os.stream; + } + }, + created() { + (this as any).os.stream.on('_connected_', this.onConnected); + (this as any).os.stream.on('_disconnected_', this.onDisconnected); + + this.$nextTick(() => { + if (this.stream.state == 'connected') { + this.$el.style.opacity = '0'; + } + }); + }, + beforeDestroy() { + (this as any).os.stream.off('_connected_', this.onConnected); + (this as any).os.stream.off('_disconnected_', this.onDisconnected); + }, + methods: { + onConnected() { + setTimeout(() => { + anime({ + targets: this.$el, + opacity: 0, + easing: 'linear', + duration: 200 + }); + }, 1000); + }, + onDisconnected() { + anime({ + targets: this.$el, + opacity: 1, + easing: 'linear', + duration: 100 + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +.mk-stream-indicator + pointer-events none + position fixed + z-index 16384 + bottom 8px + right 8px + margin 0 + padding 6px 12px + font-size 0.9em + color #fff + background rgba(0, 0, 0, 0.8) + border-radius 4px + + > p + display block + margin 0 + + > [data-fa] + margin-right 0.25em + +</style> diff --git a/src/server/web/app/common/views/components/switch.vue b/src/server/web/app/common/views/components/switch.vue new file mode 100644 index 0000000000..19a4adc3de --- /dev/null +++ b/src/server/web/app/common/views/components/switch.vue @@ -0,0 +1,190 @@ +<template> +<div + class="mk-switch" + :class="{ disabled, checked }" + role="switch" + :aria-checked="checked" + :aria-disabled="disabled" + @click="switchValue" + @mouseover="mouseenter" +> + <input + type="checkbox" + @change="handleChange" + ref="input" + :disabled="disabled" + @keydown.enter="switchValue" + > + <span class="button"> + <span :style="{ transform }"></span> + </span> + <span class="label"> + <span :aria-hidden="!checked">{{ text }}</span> + <p :aria-hidden="!checked"> + <slot></slot> + </p> + </span> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + props: { + value: { + type: Boolean, + default: false + }, + disabled: { + type: Boolean, + default: false + }, + text: String + },/* + created() { + if (!~[true, false].indexOf(this.value)) { + this.$emit('input', false); + } + },*/ + computed: { + checked(): boolean { + return this.value; + }, + transform(): string { + return this.checked ? 'translate3d(20px, 0, 0)' : ''; + } + }, + watch: { + value() { + (this.$el).style.transition = 'all 0.3s'; + (this.$refs.input as any).checked = this.checked; + } + }, + mounted() { + (this.$refs.input as any).checked = this.checked; + }, + methods: { + mouseenter() { + (this.$el).style.transition = 'all 0s'; + }, + handleChange() { + (this.$el).style.transition = 'all 0.3s'; + this.$emit('input', !this.checked); + this.$emit('change', !this.checked); + this.$nextTick(() => { + // set input's checked property + // in case parent refuses to change component's value + (this.$refs.input as any).checked = this.checked; + }); + }, + switchValue() { + !this.disabled && this.handleChange(); + } + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +.mk-switch + display flex + margin 12px 0 + cursor pointer + transition all 0.3s + + > * + user-select none + + &.disabled + opacity 0.6 + cursor not-allowed + + &.checked + > .button + background-color $theme-color + border-color $theme-color + + > .label + > span + color $theme-color + + &:hover + > .label + > span + color darken($theme-color, 10%) + + > .button + background darken($theme-color, 10%) + border-color darken($theme-color, 10%) + + &:hover + > .label + > span + color #2e3338 + + > .button + background #ced2da + border-color #ced2da + + > input + position absolute + width 0 + height 0 + opacity 0 + margin 0 + + &:focus + .button + &:after + content "" + pointer-events none + position absolute + top -5px + right -5px + bottom -5px + left -5px + border 2px solid rgba($theme-color, 0.3) + border-radius 14px + + > .button + display inline-block + margin 0 + width 40px + min-width 40px + height 20px + min-height 20px + background #dcdfe6 + border 1px solid #dcdfe6 + outline none + border-radius 10px + transition inherit + + > * + position absolute + top 1px + left 1px + border-radius 100% + transition transform 0.3s + width 16px + height 16px + background-color #fff + + > .label + margin-left 8px + display block + font-size 15px + cursor pointer + transition inherit + + > span + display block + line-height 20px + color #4a535a + transition inherit + + > p + margin 0 + //font-size 90% + color #9daab3 + +</style> diff --git a/src/server/web/app/common/views/components/time.vue b/src/server/web/app/common/views/components/time.vue new file mode 100644 index 0000000000..6e0d2b0dcb --- /dev/null +++ b/src/server/web/app/common/views/components/time.vue @@ -0,0 +1,76 @@ +<template> +<time class="mk-time"> + <span v-if=" mode == 'relative' ">{{ relative }}</span> + <span v-if=" mode == 'absolute' ">{{ absolute }}</span> + <span v-if=" mode == 'detail' ">{{ absolute }} ({{ relative }})</span> +</time> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +export default Vue.extend({ + props: { + time: { + type: [Date, String], + required: true + }, + mode: { + type: String, + default: 'relative' + } + }, + data() { + return { + tickId: null, + now: new Date() + }; + }, + computed: { + _time(): Date { + return typeof this.time == 'string' ? new Date(this.time) : this.time; + }, + absolute(): string { + const time = this._time; + return ( + time.getFullYear() + '年' + + (time.getMonth() + 1) + '月' + + time.getDate() + '日' + + ' ' + + time.getHours() + '時' + + time.getMinutes() + '分'); + }, + relative(): string { + const time = this._time; + const ago = (this.now.getTime() - time.getTime()) / 1000/*ms*/; + return ( + ago >= 31536000 ? '%i18n:common.time.years_ago%' .replace('{}', (~~(ago / 31536000)).toString()) : + ago >= 2592000 ? '%i18n:common.time.months_ago%' .replace('{}', (~~(ago / 2592000)).toString()) : + ago >= 604800 ? '%i18n:common.time.weeks_ago%' .replace('{}', (~~(ago / 604800)).toString()) : + ago >= 86400 ? '%i18n:common.time.days_ago%' .replace('{}', (~~(ago / 86400)).toString()) : + ago >= 3600 ? '%i18n:common.time.hours_ago%' .replace('{}', (~~(ago / 3600)).toString()) : + ago >= 60 ? '%i18n:common.time.minutes_ago%'.replace('{}', (~~(ago / 60)).toString()) : + ago >= 10 ? '%i18n:common.time.seconds_ago%'.replace('{}', (~~(ago % 60)).toString()) : + ago >= 0 ? '%i18n:common.time.just_now%' : + ago < 0 ? '%i18n:common.time.future%' : + '%i18n:common.time.unknown%'); + } + }, + created() { + if (this.mode == 'relative' || this.mode == 'detail') { + this.tick(); + this.tickId = setInterval(this.tick, 1000); + } + }, + destroyed() { + if (this.mode === 'relative' || this.mode === 'detail') { + clearInterval(this.tickId); + } + }, + methods: { + tick() { + this.now = new Date(); + } + } +}); +</script> diff --git a/src/server/web/app/common/views/components/timer.vue b/src/server/web/app/common/views/components/timer.vue new file mode 100644 index 0000000000..a3c4f01b77 --- /dev/null +++ b/src/server/web/app/common/views/components/timer.vue @@ -0,0 +1,49 @@ +<template> +<time class="mk-time"> + {{ hh }}:{{ mm }}:{{ ss }} +</time> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +export default Vue.extend({ + props: { + time: { + type: [Date, String], + required: true + } + }, + data() { + return { + tickId: null, + hh: null, + mm: null, + ss: null + }; + }, + computed: { + _time(): Date { + return typeof this.time == 'string' ? new Date(this.time) : this.time; + } + }, + created() { + this.tick(); + this.tickId = setInterval(this.tick, 1000); + }, + destroyed() { + clearInterval(this.tickId); + }, + methods: { + tick() { + const now = new Date().getTime(); + const start = this._time.getTime(); + const ago = Math.floor((now - start) / 1000); + + this.hh = Math.floor(ago / (60 * 60)).toString().padStart(2, '0'); + this.mm = Math.floor(ago / 60).toString().padStart(2, '0'); + this.ss = (ago % 60).toString().padStart(2, '0'); + } + } +}); +</script> diff --git a/src/server/web/app/common/views/components/twitter-setting.vue b/src/server/web/app/common/views/components/twitter-setting.vue new file mode 100644 index 0000000000..15968d20a6 --- /dev/null +++ b/src/server/web/app/common/views/components/twitter-setting.vue @@ -0,0 +1,66 @@ +<template> +<div class="mk-twitter-setting"> + <p>%i18n:common.tags.mk-twitter-setting.description%<a :href="`${docsUrl}/link-to-twitter`" target="_blank">%i18n:common.tags.mk-twitter-setting.detail%</a></p> + <p class="account" v-if="os.i.account.twitter" :title="`Twitter ID: ${os.i.account.twitter.user_id}`">%i18n:common.tags.mk-twitter-setting.connected-to%: <a :href="`https://twitter.com/${os.i.account.twitter.screen_name}`" target="_blank">@{{ os.i.account.twitter.screen_name }}</a></p> + <p> + <a :href="`${apiUrl}/connect/twitter`" target="_blank" @click.prevent="connect">{{ os.i.account.twitter ? '%i18n:common.tags.mk-twitter-setting.reconnect%' : '%i18n:common.tags.mk-twitter-setting.connect%' }}</a> + <span v-if="os.i.account.twitter"> or </span> + <a :href="`${apiUrl}/disconnect/twitter`" target="_blank" v-if="os.i.account.twitter" @click.prevent="disconnect">%i18n:common.tags.mk-twitter-setting.disconnect%</a> + </p> + <p class="id" v-if="os.i.account.twitter">Twitter ID: {{ os.i.account.twitter.user_id }}</p> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { apiUrl, docsUrl } from '../../../config'; + +export default Vue.extend({ + data() { + return { + form: null, + apiUrl, + docsUrl + }; + }, + mounted() { + this.$watch('os.i', () => { + if ((this as any).os.i.account.twitter) { + if (this.form) this.form.close(); + } + }, { + deep: true + }); + }, + methods: { + connect() { + this.form = window.open(apiUrl + '/connect/twitter', + 'twitter_connect_window', + 'height=570, width=520'); + }, + + disconnect() { + window.open(apiUrl + '/disconnect/twitter', + 'twitter_disconnect_window', + 'height=570, width=520'); + } + } +}); +</script> + +<style lang="stylus" scoped> +.mk-twitter-setting + color #4a535a + + .account + border solid 1px #e1e8ed + border-radius 4px + padding 16px + + a + font-weight bold + color inherit + + .id + color #8899a6 +</style> diff --git a/src/server/web/app/common/views/components/uploader.vue b/src/server/web/app/common/views/components/uploader.vue new file mode 100644 index 0000000000..73006b16e9 --- /dev/null +++ b/src/server/web/app/common/views/components/uploader.vue @@ -0,0 +1,212 @@ +<template> +<div class="mk-uploader"> + <ol v-if="uploads.length > 0"> + <li v-for="ctx in uploads" :key="ctx.id"> + <div class="img" :style="{ backgroundImage: `url(${ ctx.img })` }"></div> + <p class="name">%fa:spinner .pulse%{{ ctx.name }}</p> + <p class="status"> + <span class="initing" v-if="ctx.progress == undefined">%i18n:common.tags.mk-uploader.waiting%<mk-ellipsis/></span> + <span class="kb" v-if="ctx.progress != undefined">{{ String(Math.floor(ctx.progress.value / 1024)).replace(/(\d)(?=(\d\d\d)+(?!\d))/g, '$1,') }}<i>KB</i> / {{ String(Math.floor(ctx.progress.max / 1024)).replace(/(\d)(?=(\d\d\d)+(?!\d))/g, '$1,') }}<i>KB</i></span> + <span class="percentage" v-if="ctx.progress != undefined">{{ Math.floor((ctx.progress.value / ctx.progress.max) * 100) }}</span> + </p> + <progress v-if="ctx.progress != undefined && ctx.progress.value != ctx.progress.max" :value="ctx.progress.value" :max="ctx.progress.max"></progress> + <div class="progress initing" v-if="ctx.progress == undefined"></div> + <div class="progress waiting" v-if="ctx.progress != undefined && ctx.progress.value == ctx.progress.max"></div> + </li> + </ol> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { apiUrl } from '../../../config'; + +export default Vue.extend({ + data() { + return { + uploads: [] + }; + }, + methods: { + upload(file, folder) { + if (folder && typeof folder == 'object') folder = folder.id; + + const id = Math.random(); + + const ctx = { + id: id, + name: file.name || 'untitled', + progress: undefined, + img: undefined + }; + + this.uploads.push(ctx); + this.$emit('change', this.uploads); + + const reader = new FileReader(); + reader.onload = (e: any) => { + ctx.img = e.target.result; + }; + reader.readAsDataURL(file); + + const data = new FormData(); + data.append('i', (this as any).os.i.account.token); + data.append('file', file); + + if (folder) data.append('folder_id', folder); + + const xhr = new XMLHttpRequest(); + xhr.open('POST', apiUrl + '/drive/files/create', true); + xhr.onload = (e: any) => { + const driveFile = JSON.parse(e.target.response); + + this.$emit('uploaded', driveFile); + + this.uploads = this.uploads.filter(x => x.id != id); + this.$emit('change', this.uploads); + }; + + xhr.upload.onprogress = e => { + if (e.lengthComputable) { + if (ctx.progress == undefined) ctx.progress = {}; + ctx.progress.max = e.total; + ctx.progress.value = e.loaded; + } + }; + + xhr.send(data); + } + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +.mk-uploader + overflow auto + + &:empty + display none + + > ol + display block + margin 0 + padding 0 + list-style none + + > li + display block + margin 8px 0 0 0 + padding 0 + height 36px + box-shadow 0 -1px 0 rgba($theme-color, 0.1) + border-top solid 8px transparent + + &:first-child + margin 0 + box-shadow none + border-top none + + > .img + display block + position absolute + top 0 + left 0 + width 36px + height 36px + background-size cover + background-position center center + + > .name + display block + position absolute + top 0 + left 44px + margin 0 + padding 0 + max-width 256px + font-size 0.8em + color rgba($theme-color, 0.7) + white-space nowrap + text-overflow ellipsis + overflow hidden + + > [data-fa] + margin-right 4px + + > .status + display block + position absolute + top 0 + right 0 + margin 0 + padding 0 + font-size 0.8em + + > .initing + color rgba($theme-color, 0.5) + + > .kb + color rgba($theme-color, 0.5) + + > .percentage + display inline-block + width 48px + text-align right + + color rgba($theme-color, 0.7) + + &:after + content '%' + + > progress + display block + position absolute + bottom 0 + right 0 + margin 0 + width calc(100% - 44px) + height 8px + background transparent + border none + border-radius 4px + overflow hidden + + &::-webkit-progress-value + background $theme-color + + &::-webkit-progress-bar + background rgba($theme-color, 0.1) + + > .progress + display block + position absolute + bottom 0 + right 0 + margin 0 + width calc(100% - 44px) + height 8px + border none + border-radius 4px + background linear-gradient( + 45deg, + lighten($theme-color, 30%) 25%, + $theme-color 25%, + $theme-color 50%, + lighten($theme-color, 30%) 50%, + lighten($theme-color, 30%) 75%, + $theme-color 75%, + $theme-color + ) + background-size 32px 32px + animation bg 1.5s linear infinite + + &.initing + opacity 0.3 + + @keyframes bg + from {background-position: 0 0;} + to {background-position: -64px 32px;} + +</style> diff --git a/src/server/web/app/common/views/components/url-preview.vue b/src/server/web/app/common/views/components/url-preview.vue new file mode 100644 index 0000000000..e91e510550 --- /dev/null +++ b/src/server/web/app/common/views/components/url-preview.vue @@ -0,0 +1,142 @@ +<template> +<iframe v-if="youtubeId" type="text/html" height="250" + :src="`https://www.youtube.com/embed/${youtubeId}?origin=${misskeyUrl}`" + frameborder="0"/> +<div v-else> + <a class="mk-url-preview" :href="url" target="_blank" :title="url" v-if="!fetching"> + <div class="thumbnail" v-if="thumbnail" :style="`background-image: url(${thumbnail})`"></div> + <article> + <header> + <h1>{{ title }}</h1> + </header> + <p>{{ description }}</p> + <footer> + <img class="icon" v-if="icon" :src="icon"/> + <p>{{ sitename }}</p> + </footer> + </article> + </a> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { url as misskeyUrl } from '../../../config'; + +export default Vue.extend({ + props: ['url'], + data() { + return { + fetching: true, + title: null, + description: null, + thumbnail: null, + icon: null, + sitename: null, + youtubeId: null, + misskeyUrl + }; + }, + created() { + const url = new URL(this.url); + + if (url.hostname == 'www.youtube.com') { + this.youtubeId = url.searchParams.get('v'); + } else if (url.hostname == 'youtu.be') { + this.youtubeId = url.pathname; + } else { + fetch('/api:url?url=' + this.url).then(res => { + res.json().then(info => { + this.title = info.title; + this.description = info.description; + this.thumbnail = info.thumbnail; + this.icon = info.icon; + this.sitename = info.sitename; + + this.fetching = false; + }); + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +iframe + width 100% + +.mk-url-preview + display block + font-size 16px + border solid 1px #eee + border-radius 4px + overflow hidden + + &:hover + text-decoration none + border-color #ddd + + > article > header > h1 + text-decoration underline + + > .thumbnail + position absolute + width 100px + height 100% + background-position center + background-size cover + + & + article + left 100px + width calc(100% - 100px) + + > article + padding 16px + + > header + margin-bottom 8px + + > h1 + margin 0 + font-size 1em + color #555 + + > p + margin 0 + color #777 + font-size 0.8em + + > footer + margin-top 8px + height 16px + + > img + display inline-block + width 16px + height 16px + margin-right 4px + vertical-align top + + > p + display inline-block + margin 0 + color #666 + font-size 0.8em + line-height 16px + vertical-align top + + @media (max-width 500px) + font-size 8px + border none + + > .thumbnail + width 70px + + & + article + left 70px + width calc(100% - 70px) + + > article + padding 8px + +</style> diff --git a/src/server/web/app/common/views/components/url.vue b/src/server/web/app/common/views/components/url.vue new file mode 100644 index 0000000000..14d4fc82f3 --- /dev/null +++ b/src/server/web/app/common/views/components/url.vue @@ -0,0 +1,66 @@ +<template> +<a class="mk-url" :href="url" :target="target"> + <span class="schema">{{ schema }}//</span> + <span class="hostname">{{ hostname }}</span> + <span class="port" v-if="port != ''">:{{ port }}</span> + <span class="pathname" v-if="pathname != ''">{{ pathname }}</span> + <span class="query">{{ query }}</span> + <span class="hash">{{ hash }}</span> + %fa:external-link-square-alt% +</a> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +export default Vue.extend({ + props: ['url', 'target'], + data() { + return { + schema: null, + hostname: null, + port: null, + pathname: null, + query: null, + hash: null + }; + }, + created() { + const url = new URL(this.url); + + this.schema = url.protocol; + this.hostname = url.hostname; + this.port = url.port; + this.pathname = url.pathname; + this.query = url.search; + this.hash = url.hash; + } +}); +</script> + +<style lang="stylus" scoped> +.mk-url + word-break break-all + + > [data-fa] + padding-left 2px + font-size .9em + font-weight 400 + font-style normal + + > .schema + opacity 0.5 + + > .hostname + font-weight bold + + > .pathname + opacity 0.8 + + > .query + opacity 0.5 + + > .hash + font-style italic + +</style> diff --git a/src/server/web/app/common/views/components/welcome-timeline.vue b/src/server/web/app/common/views/components/welcome-timeline.vue new file mode 100644 index 0000000000..7586e9264e --- /dev/null +++ b/src/server/web/app/common/views/components/welcome-timeline.vue @@ -0,0 +1,118 @@ +<template> +<div class="mk-welcome-timeline"> + <div v-for="post in posts"> + <router-link class="avatar-anchor" :to="`/@${getAcct(post.user)}`" v-user-preview="post.user.id"> + <img class="avatar" :src="`${post.user.avatar_url}?thumbnail&size=96`" alt="avatar"/> + </router-link> + <div class="body"> + <header> + <router-link class="name" :to="`/@${getAcct(post.user)}`" v-user-preview="post.user.id">{{ post.user.name }}</router-link> + <span class="username">@{{ getAcct(post.user) }}</span> + <div class="info"> + <router-link class="created-at" :to="`/@${getAcct(post.user)}/${post.id}`"> + <mk-time :time="post.created_at"/> + </router-link> + </div> + </header> + <div class="text"> + <mk-post-html :ast="post.ast"/> + </div> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import getAcct from '../../../../../common/user/get-acct'; + +export default Vue.extend({ + data() { + return { + fetching: true, + posts: [] + }; + }, + mounted() { + this.fetch(); + }, + methods: { + getAcct, + fetch(cb?) { + this.fetching = true; + (this as any).api('posts', { + reply: false, + repost: false, + media: false, + poll: false, + bot: false + }).then(posts => { + this.posts = posts; + this.fetching = false; + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +.mk-welcome-timeline + background #fff + + > div + padding 16px + overflow-wrap break-word + font-size .9em + color #4C4C4C + border-bottom 1px solid rgba(0, 0, 0, 0.05) + + &:after + content "" + display block + clear both + + > .avatar-anchor + display block + float left + position -webkit-sticky + position sticky + top 16px + + > img + display block + width 42px + height 42px + border-radius 6px + + > .body + float right + width calc(100% - 42px) + padding-left 12px + + > header + display flex + align-items center + margin-bottom 4px + white-space nowrap + + > .name + display block + margin 0 .5em 0 0 + padding 0 + overflow hidden + font-weight bold + text-overflow ellipsis + color #627079 + + > .username + margin 0 .5em 0 0 + color #ccc + + > .info + margin-left auto + font-size 0.9em + + > .created-at + color #c0c0c0 + +</style> diff --git a/src/server/web/app/common/views/directives/autocomplete.ts b/src/server/web/app/common/views/directives/autocomplete.ts new file mode 100644 index 0000000000..3440c4212a --- /dev/null +++ b/src/server/web/app/common/views/directives/autocomplete.ts @@ -0,0 +1,194 @@ +import * as getCaretCoordinates from 'textarea-caret'; +import MkAutocomplete from '../components/autocomplete.vue'; + +export default { + bind(el, binding, vn) { + const self = el._autoCompleteDirective_ = {} as any; + self.x = new Autocomplete(el, vn.context, binding.value); + self.x.attach(); + }, + + unbind(el, binding, vn) { + const self = el._autoCompleteDirective_; + self.x.detach(); + } +}; + +/** + * オートコンプリートを管理するクラス。 + */ +class Autocomplete { + private suggestion: any; + private textarea: any; + private vm: any; + private model: any; + private currentType: string; + + private get text(): string { + return this.vm[this.model]; + } + + private set text(text: string) { + this.vm[this.model] = text; + } + + /** + * 対象のテキストエリアを与えてインスタンスを初期化します。 + */ + constructor(textarea, vm, model) { + //#region BIND + this.onInput = this.onInput.bind(this); + this.complete = this.complete.bind(this); + this.close = this.close.bind(this); + //#endregion + + this.suggestion = null; + this.textarea = textarea; + this.vm = vm; + this.model = model; + } + + /** + * このインスタンスにあるテキストエリアの入力のキャプチャを開始します。 + */ + public attach() { + this.textarea.addEventListener('input', this.onInput); + } + + /** + * このインスタンスにあるテキストエリアの入力のキャプチャを解除します。 + */ + public detach() { + this.textarea.removeEventListener('input', this.onInput); + this.close(); + } + + /** + * テキスト入力時 + */ + private onInput() { + const caret = this.textarea.selectionStart; + const text = this.text.substr(0, caret); + + const mentionIndex = text.lastIndexOf('@'); + const emojiIndex = text.lastIndexOf(':'); + + let opened = false; + + if (mentionIndex != -1 && mentionIndex > emojiIndex) { + const username = text.substr(mentionIndex + 1); + if (username != '' && username.match(/^[a-zA-Z0-9-]+$/)) { + this.open('user', username); + opened = true; + } + } + + if (emojiIndex != -1 && emojiIndex > mentionIndex) { + const emoji = text.substr(emojiIndex + 1); + if (emoji != '' && emoji.match(/^[\+\-a-z0-9_]+$/)) { + this.open('emoji', emoji); + opened = true; + } + } + + if (!opened) { + this.close(); + } + } + + /** + * サジェストを提示します。 + */ + private open(type, q) { + if (type != this.currentType) { + this.close(); + } + this.currentType = type; + + //#region サジェストを表示すべき位置を計算 + const caretPosition = getCaretCoordinates(this.textarea, this.textarea.selectionStart); + + const rect = this.textarea.getBoundingClientRect(); + + const x = rect.left + caretPosition.left - this.textarea.scrollLeft; + const y = rect.top + caretPosition.top - this.textarea.scrollTop; + //#endregion + + if (this.suggestion) { + this.suggestion.x = x; + this.suggestion.y = y; + this.suggestion.q = q; + } else { + // サジェスト要素作成 + this.suggestion = new MkAutocomplete({ + propsData: { + textarea: this.textarea, + complete: this.complete, + close: this.close, + type: type, + q: q, + x, + y + } + }).$mount(); + + // 要素追加 + document.body.appendChild(this.suggestion.$el); + } + } + + /** + * サジェストを閉じます。 + */ + private close() { + if (this.suggestion == null) return; + + this.suggestion.$destroy(); + this.suggestion = null; + + this.textarea.focus(); + } + + /** + * オートコンプリートする + */ + private complete(type, value) { + this.close(); + + const caret = this.textarea.selectionStart; + + if (type == 'user') { + const source = this.text; + + const before = source.substr(0, caret); + const trimmedBefore = before.substring(0, before.lastIndexOf('@')); + const after = source.substr(caret); + + // 挿入 + this.text = trimmedBefore + '@' + value.username + ' ' + after; + + // キャレットを戻す + this.vm.$nextTick(() => { + this.textarea.focus(); + const pos = trimmedBefore.length + (value.username.length + 2); + this.textarea.setSelectionRange(pos, pos); + }); + } else if (type == 'emoji') { + const source = this.text; + + const before = source.substr(0, caret); + const trimmedBefore = before.substring(0, before.lastIndexOf(':')); + const after = source.substr(caret); + + // 挿入 + this.text = trimmedBefore + value + after; + + // キャレットを戻す + this.vm.$nextTick(() => { + this.textarea.focus(); + const pos = trimmedBefore.length + 1; + this.textarea.setSelectionRange(pos, pos); + }); + } + } +} diff --git a/src/server/web/app/common/views/directives/index.ts b/src/server/web/app/common/views/directives/index.ts new file mode 100644 index 0000000000..268f07a950 --- /dev/null +++ b/src/server/web/app/common/views/directives/index.ts @@ -0,0 +1,5 @@ +import Vue from 'vue'; + +import autocomplete from './autocomplete'; + +Vue.directive('autocomplete', autocomplete); diff --git a/src/server/web/app/common/views/filters/bytes.ts b/src/server/web/app/common/views/filters/bytes.ts new file mode 100644 index 0000000000..3afb11e9ae --- /dev/null +++ b/src/server/web/app/common/views/filters/bytes.ts @@ -0,0 +1,8 @@ +import Vue from 'vue'; + +Vue.filter('bytes', (v, digits = 0) => { + const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']; + if (v == 0) return '0Byte'; + const i = Math.floor(Math.log(v) / Math.log(1024)); + return (v / Math.pow(1024, i)).toFixed(digits).replace(/\.0+$/, '') + sizes[i]; +}); diff --git a/src/server/web/app/common/views/filters/index.ts b/src/server/web/app/common/views/filters/index.ts new file mode 100644 index 0000000000..3a1d1ac235 --- /dev/null +++ b/src/server/web/app/common/views/filters/index.ts @@ -0,0 +1,2 @@ +require('./bytes'); +require('./number'); diff --git a/src/server/web/app/common/views/filters/number.ts b/src/server/web/app/common/views/filters/number.ts new file mode 100644 index 0000000000..d9f48229dd --- /dev/null +++ b/src/server/web/app/common/views/filters/number.ts @@ -0,0 +1,5 @@ +import Vue from 'vue'; + +Vue.filter('number', (n) => { + return n.toLocaleString(); +}); diff --git a/src/server/web/app/common/views/widgets/access-log.vue b/src/server/web/app/common/views/widgets/access-log.vue new file mode 100644 index 0000000000..f7bb17d833 --- /dev/null +++ b/src/server/web/app/common/views/widgets/access-log.vue @@ -0,0 +1,90 @@ +<template> +<div class="mkw-access-log"> + <mk-widget-container :show-header="props.design == 0"> + <template slot="header">%fa:server%%i18n:desktop.tags.mk-access-log-home-widget.title%</template> + + <div :class="$style.logs" ref="log"> + <p v-for="req in requests"> + <span :class="$style.ip" :style="`color:${ req.fg }; background:${ req.bg }`">{{ req.ip }}</span> + <b>{{ req.method }}</b> + <span>{{ req.path }}</span> + </p> + </div> + </mk-widget-container> +</div> +</template> + +<script lang="ts"> +import define from '../../../common/define-widget'; +import * as seedrandom from 'seedrandom'; + +export default define({ + name: 'broadcast', + props: () => ({ + design: 0 + }) +}).extend({ + data() { + return { + requests: [], + connection: null, + connectionId: null + }; + }, + mounted() { + this.connection = (this as any).os.streams.requestsStream.getConnection(); + this.connectionId = (this as any).os.streams.requestsStream.use(); + this.connection.on('request', this.onRequest); + }, + beforeDestroy() { + this.connection.off('request', this.onRequest); + (this as any).os.streams.requestsStream.dispose(this.connectionId); + }, + methods: { + 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.$refs.log as any).scrollTop = (this.$refs.log as any).scrollHeight; + }, + func() { + if (this.props.design == 1) { + this.props.design = 0; + } else { + this.props.design++; + } + } + } +}); +</script> + +<style lang="stylus" module> +.logs + 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) + + > b + margin-right 4px + +.ip + margin-right 4px + padding 0 4px + +</style> diff --git a/src/server/web/app/common/views/widgets/broadcast.vue b/src/server/web/app/common/views/widgets/broadcast.vue new file mode 100644 index 0000000000..bf41a5fc67 --- /dev/null +++ b/src/server/web/app/common/views/widgets/broadcast.vue @@ -0,0 +1,161 @@ +<template> +<div class="mkw-broadcast" + :data-found="broadcasts.length != 0" + :data-melt="props.design == 1" + :data-mobile="isMobile" +> + <div class="icon"> + <svg height="32" version="1.1" viewBox="0 0 32 32" width="32"> + <path class="tower" d="M16.04,11.24c1.79,0,3.239-1.45,3.239-3.24S17.83,4.76,16.04,4.76c-1.79,0-3.24,1.45-3.24,3.24 C12.78,9.78,14.24,11.24,16.04,11.24z M16.04,13.84c-0.82,0-1.66-0.2-2.4-0.6L7.34,29.98h2.98l1.72-2h8l1.681,2H24.7L18.42,13.24 C17.66,13.64,16.859,13.84,16.04,13.84z M16.02,14.8l2.02,7.2h-4L16.02,14.8z M12.04,25.98l2-2h4l2,2H12.04z"></path> + <path class="wave a" d="M4.66,1.04c-0.508-0.508-1.332-0.508-1.84,0c-1.86,1.92-2.8,4.44-2.8,6.94c0,2.52,0.94,5.04,2.8,6.96 c0.5,0.52,1.32,0.52,1.82,0s0.5-1.36,0-1.88C3.28,11.66,2.6,9.82,2.6,7.98S3.28,4.3,4.64,2.9C5.157,2.391,5.166,1.56,4.66,1.04z"></path> + <path class="wave b" d="M9.58,12.22c0.5-0.5,0.5-1.34,0-1.84C8.94,9.72,8.62,8.86,8.62,8s0.32-1.72,0.96-2.38c0.5-0.52,0.5-1.34,0-1.84 C9.346,3.534,9.02,3.396,8.68,3.4c-0.32,0-0.66,0.12-0.9,0.38C6.64,4.94,6.08,6.48,6.08,8s0.58,3.06,1.7,4.22 C8.28,12.72,9.1,12.72,9.58,12.22z"></path> + <path class="wave c" d="M22.42,3.78c-0.5,0.5-0.5,1.34,0,1.84c0.641,0.66,0.96,1.52,0.96,2.38s-0.319,1.72-0.96,2.38c-0.5,0.52-0.5,1.34,0,1.84 c0.487,0.497,1.285,0.505,1.781,0.018c0.007-0.006,0.013-0.012,0.02-0.018c1.139-1.16,1.699-2.7,1.699-4.22s-0.561-3.06-1.699-4.22 c-0.494-0.497-1.297-0.5-1.794-0.007C22.424,3.775,22.422,3.778,22.42,3.78z"></path> + <path class="wave d" d="M29.18,1.06c-0.479-0.502-1.273-0.522-1.775-0.044c-0.016,0.015-0.029,0.029-0.045,0.044c-0.5,0.52-0.5,1.36,0,1.88 c1.361,1.4,2.041,3.24,2.041,5.08s-0.68,3.66-2.041,5.08c-0.5,0.52-0.5,1.36,0,1.88c0.509,0.508,1.332,0.508,1.841,0 c1.86-1.92,2.8-4.44,2.8-6.96C31.99,5.424,30.98,2.931,29.18,1.06z"></path> + </svg> + </div> + <p class="fetching" v-if="fetching">%i18n:desktop.tags.mk-broadcast-home-widget.fetching%<mk-ellipsis/></p> + <h1 v-if="!fetching">{{ broadcasts.length == 0 ? '%i18n:desktop.tags.mk-broadcast-home-widget.no-broadcasts%' : broadcasts[i].title }}</h1> + <p v-if="!fetching"> + <span v-if="broadcasts.length != 0" v-html="broadcasts[i].text"></span> + <template v-if="broadcasts.length == 0">%i18n:desktop.tags.mk-broadcast-home-widget.have-a-nice-day%</template> + </p> + <a v-if="broadcasts.length > 1" @click="next">%i18n:desktop.tags.mk-broadcast-home-widget.next% >></a> +</div> +</template> + +<script lang="ts"> +import define from '../../../common/define-widget'; +import { lang } from '../../../config'; + +export default define({ + name: 'broadcast', + props: () => ({ + design: 0 + }) +}).extend({ + data() { + return { + i: 0, + fetching: true, + broadcasts: [] + }; + }, + mounted() { + (this as any).os.getMeta().then(meta => { + let broadcasts = []; + if (meta.broadcasts) { + meta.broadcasts.forEach(broadcast => { + if (broadcast[lang]) { + broadcasts.push(broadcast[lang]); + } + }); + } + this.broadcasts = broadcasts; + this.fetching = false; + }); + }, + methods: { + next() { + if (this.i == this.broadcasts.length - 1) { + this.i = 0; + } else { + this.i++; + } + }, + func() { + if (this.props.design == 1) { + this.props.design = 0; + } else { + this.props.design++; + } + } + } +}); +</script> + +<style lang="stylus" scoped> +.mkw-broadcast + padding 10px + border solid 1px #4078c0 + border-radius 6px + + &[data-melt] + border none + + &[data-found] + padding-left 50px + + > .icon + display block + + &:after + content "" + display block + clear both + + > .icon + display none + float left + margin-left -40px + + > svg + fill currentColor + color #4078c0 + + > .wave + opacity 1 + + &.a + animation wave 20s ease-in-out 2.1s infinite + &.b + animation wave 20s ease-in-out 2s infinite + &.c + animation wave 20s ease-in-out 2s infinite + &.d + animation wave 20s ease-in-out 2.1s infinite + + @keyframes wave + 0% + opacity 1 + 1.5% + opacity 0 + 3.5% + opacity 0 + 5% + opacity 1 + 6.5% + opacity 0 + 8.5% + opacity 0 + 10% + opacity 1 + + > h1 + margin 0 + font-size 0.95em + font-weight normal + color #4078c0 + + > p + display block + z-index 1 + margin 0 + font-size 0.7em + color #555 + + &.fetching + text-align center + + a + color #555 + text-decoration underline + + > a + display block + font-size 0.7em + + &[data-mobile] + > p + color #fff + +</style> diff --git a/src/server/web/app/common/views/widgets/calendar.vue b/src/server/web/app/common/views/widgets/calendar.vue new file mode 100644 index 0000000000..03f69a7597 --- /dev/null +++ b/src/server/web/app/common/views/widgets/calendar.vue @@ -0,0 +1,201 @@ +<template> +<div class="mkw-calendar" + :data-melt="props.design == 1" + :data-special="special" + :data-mobile="isMobile" +> + <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> + <p class="week-day">{{ weekDay }}曜日</p> + </div> + <div class="info"> + <div> + <p>今日:<b>{{ dayP.toFixed(1) }}%</b></p> + <div class="meter"> + <div class="val" :style="{ width: `${dayP}%` }"></div> + </div> + </div> + <div> + <p>今月:<b>{{ monthP.toFixed(1) }}%</b></p> + <div class="meter"> + <div class="val" :style="{ width: `${monthP}%` }"></div> + </div> + </div> + <div> + <p>今年:<b>{{ yearP.toFixed(1) }}%</b></p> + <div class="meter"> + <div class="val" :style="{ width: `${yearP}%` }"></div> + </div> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import define from '../../../common/define-widget'; +export default define({ + name: 'calendar', + props: () => ({ + design: 0 + }) +}).extend({ + data() { + return { + now: new Date(), + year: null, + month: null, + day: null, + weekDay: null, + yearP: null, + dayP: null, + monthP: null, + isHoliday: null, + special: null, + clock: null + }; + }, + created() { + this.tick(); + this.clock = setInterval(this.tick, 1000); + }, + beforeDestroy() { + clearInterval(this.clock); + }, + methods: { + func() { + if (this.isMobile) return; + if (this.props.design == 2) { + this.props.design = 0; + } else { + this.props.design++; + } + }, + tick() { + const now = new Date(); + const nd = now.getDate(); + const nm = now.getMonth(); + const ny = now.getFullYear(); + + this.year = ny; + this.month = nm + 1; + this.day = nd; + this.weekDay = ['日', '月', '火', '水', '木', '金', '土'][now.getDay()]; + + const dayNumer = now.getTime() - new Date(ny, nm, nd).getTime(); + const dayDenom = 1000/*ms*/ * 60/*s*/ * 60/*m*/ * 24/*h*/; + const monthNumer = now.getTime() - new Date(ny, nm, 1).getTime(); + const monthDenom = new Date(ny, nm + 1, 1).getTime() - new Date(ny, nm, 1).getTime(); + const yearNumer = now.getTime() - new Date(ny, 0, 1).getTime(); + const yearDenom = new Date(ny + 1, 0, 1).getTime() - new Date(ny, 0, 1).getTime(); + + this.dayP = dayNumer / dayDenom * 100; + this.monthP = monthNumer / monthDenom * 100; + this.yearP = yearNumer / yearDenom * 100; + + this.isHoliday = now.getDay() == 0 || now.getDay() == 6; + + this.special = + nm == 0 && nd == 1 ? 'on-new-years-day' : + false; + } + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +.mkw-calendar + 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 + + &[data-melt] + background transparent + border none + + &[data-mobile] + border none + border-radius 8px + box-shadow 0 0 0 1px rgba(0, 0, 0, 0.2) + + &:after + content "" + display block + clear both + + > .calendar + float left + width 60% + text-align center + + &[data-is-holiday] + > .day + color #ef95a0 + + > p + margin 0 + line-height 18px + font-size 14px + + > span + margin 0 4px + + > .day + margin 10px 0 + line-height 32px + font-size 28px + + > .info + display block + float left + width 40% + padding 0 16px 0 0 + + > div + margin-bottom 8px + + &:last-child + margin-bottom 4px + + > p + margin 0 0 2px 0 + font-size 12px + line-height 18px + color #888 + + > b + margin-left 2px + + > .meter + width 100% + overflow hidden + background #eee + border-radius 8px + + > .val + height 4px + background $theme-color + + &:nth-child(1) + > .meter > .val + background #f7796c + + &:nth-child(2) + > .meter > .val + background #a1de41 + + &:nth-child(3) + > .meter > .val + background #41ddde + +</style> diff --git a/src/server/web/app/common/views/widgets/donation.vue b/src/server/web/app/common/views/widgets/donation.vue new file mode 100644 index 0000000000..e218df06e1 --- /dev/null +++ b/src/server/web/app/common/views/widgets/donation.vue @@ -0,0 +1,58 @@ +<template> +<div class="mkw-donation" :data-mobile="isMobile"> + <article> + <h1>%fa:heart%%i18n:desktop.tags.mk-donation-home-widget.title%</h1> + <p> + {{ '%i18n:desktop.tags.mk-donation-home-widget.text%'.substr(0, '%i18n:desktop.tags.mk-donation-home-widget.text%'.indexOf('{')) }} + <a href="https://syuilo.com">@syuilo</a> + {{ '%i18n:desktop.tags.mk-donation-home-widget.text%'.substr('%i18n:desktop.tags.mk-donation-home-widget.text%'.indexOf('}') + 1) }} + </p> + </article> +</div> +</template> + +<script lang="ts"> +import define from '../../../common/define-widget'; +export default define({ + name: 'donation' +}); +</script> + +<style lang="stylus" scoped> +.mkw-donation + background #fff + border solid 1px #ead8bb + border-radius 6px + + > article + padding 20px + + > h1 + margin 0 0 5px 0 + font-size 1em + color #888 + + > [data-fa] + margin-right 0.25em + + > p + display block + z-index 1 + margin 0 + font-size 0.8em + color #999 + + &[data-mobile] + border none + background #ead8bb + border-radius 8px + box-shadow 0 0 0 1px rgba(0, 0, 0, 0.2) + + > article + > h1 + color #7b8871 + + > p + color #777d71 + +</style> diff --git a/src/server/web/app/common/views/widgets/index.ts b/src/server/web/app/common/views/widgets/index.ts new file mode 100644 index 0000000000..e41030e85a --- /dev/null +++ b/src/server/web/app/common/views/widgets/index.ts @@ -0,0 +1,25 @@ +import Vue from 'vue'; + +import wAccessLog from './access-log.vue'; +import wVersion from './version.vue'; +import wRss from './rss.vue'; +import wServer from './server.vue'; +import wBroadcast from './broadcast.vue'; +import wCalendar from './calendar.vue'; +import wPhotoStream from './photo-stream.vue'; +import wSlideshow from './slideshow.vue'; +import wTips from './tips.vue'; +import wDonation from './donation.vue'; +import wNav from './nav.vue'; + +Vue.component('mkw-nav', wNav); +Vue.component('mkw-calendar', wCalendar); +Vue.component('mkw-photo-stream', wPhotoStream); +Vue.component('mkw-slideshow', wSlideshow); +Vue.component('mkw-tips', wTips); +Vue.component('mkw-donation', wDonation); +Vue.component('mkw-broadcast', wBroadcast); +Vue.component('mkw-server', wServer); +Vue.component('mkw-rss', wRss); +Vue.component('mkw-version', wVersion); +Vue.component('mkw-access-log', wAccessLog); diff --git a/src/server/web/app/common/views/widgets/nav.vue b/src/server/web/app/common/views/widgets/nav.vue new file mode 100644 index 0000000000..7bd5a7832f --- /dev/null +++ b/src/server/web/app/common/views/widgets/nav.vue @@ -0,0 +1,31 @@ +<template> +<div class="mkw-nav"> + <mk-widget-container> + <div :class="$style.body"> + <mk-nav/> + </div> + </mk-widget-container> +</div> +</template> + +<script lang="ts"> +import define from '../../../common/define-widget'; +export default define({ + name: 'nav' +}); +</script> + +<style lang="stylus" module> +.body + padding 16px + font-size 12px + color #aaa + background #fff + + a + color #999 + + i + color #ccc + +</style> diff --git a/src/server/web/app/common/views/widgets/photo-stream.vue b/src/server/web/app/common/views/widgets/photo-stream.vue new file mode 100644 index 0000000000..baafd40662 --- /dev/null +++ b/src/server/web/app/common/views/widgets/photo-stream.vue @@ -0,0 +1,104 @@ +<template> +<div class="mkw-photo-stream" :class="$style.root" :data-melt="props.design == 2"> + <mk-widget-container :show-header="props.design == 0" :naked="props.design == 2"> + <template slot="header">%fa:camera%%i18n:desktop.tags.mk-photo-stream-home-widget.title%</template> + + <p :class="$style.fetching" v-if="fetching">%fa:spinner .pulse .fw%%i18n:common.loading%<mk-ellipsis/></p> + <div :class="$style.stream" v-if="!fetching && images.length > 0"> + <div v-for="image in images" :class="$style.img" :style="`background-image: url(${image.url}?thumbnail&size=256)`"></div> + </div> + <p :class="$style.empty" v-if="!fetching && images.length == 0">%i18n:desktop.tags.mk-photo-stream-home-widget.no-photos%</p> + </mk-widget-container> +</div> +</template> + +<script lang="ts"> +import define from '../../../common/define-widget'; +export default define({ + name: 'photo-stream', + props: () => ({ + design: 0 + }) +}).extend({ + data() { + return { + images: [], + fetching: true, + connection: null, + connectionId: null + }; + }, + mounted() { + this.connection = (this as any).os.stream.getConnection(); + this.connectionId = (this as any).os.stream.use(); + + this.connection.on('drive_file_created', this.onDriveFileCreated); + + (this as any).api('drive/stream', { + type: 'image/*', + limit: 9 + }).then(images => { + this.images = images; + this.fetching = false; + }); + }, + beforeDestroy() { + this.connection.off('drive_file_created', this.onDriveFileCreated); + (this as any).os.stream.dispose(this.connectionId); + }, + methods: { + onDriveFileCreated(file) { + if (/^image\/.+$/.test(file.type)) { + this.images.unshift(file); + if (this.images.length > 9) this.images.pop(); + } + }, + func() { + if (this.props.design == 2) { + this.props.design = 0; + } else { + this.props.design++; + } + } + } +}); +</script> + +<style lang="stylus" module> +.root[data-melt] + .stream + padding 0 + + .img + border solid 4px transparent + border-radius 8px + +.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 + border solid 2px transparent + border-radius 4px + +.fetching +.empty + margin 0 + padding 16px + text-align center + color #aaa + + > [data-fa] + margin-right 4px + +</style> diff --git a/src/server/web/app/common/views/widgets/rss.vue b/src/server/web/app/common/views/widgets/rss.vue new file mode 100644 index 0000000000..4d74b2f7a4 --- /dev/null +++ b/src/server/web/app/common/views/widgets/rss.vue @@ -0,0 +1,93 @@ +<template> +<div class="mkw-rss" :data-mobile="isMobile"> + <mk-widget-container :show-header="!props.compact"> + <template slot="header">%fa:rss-square%RSS</template> + <button slot="func" title="設定" @click="setting">%fa:cog%</button> + + <p :class="$style.fetching" v-if="fetching">%fa:spinner .pulse .fw%%i18n:common.loading%<mk-ellipsis/></p> + <div :class="$style.feed" v-else> + <a v-for="item in items" :href="item.link" target="_blank">{{ item.title }}</a> + </div> + </mk-widget-container> +</div> +</template> + +<script lang="ts"> +import define from '../../../common/define-widget'; +export default define({ + name: 'rss', + props: () => ({ + compact: false + }) +}).extend({ + data() { + return { + url: 'http://news.yahoo.co.jp/pickup/rss.xml', + items: [], + fetching: true, + clock: null + }; + }, + mounted() { + this.fetch(); + this.clock = setInterval(this.fetch, 60000); + }, + beforeDestroy() { + clearInterval(this.clock); + }, + methods: { + func() { + this.props.compact = !this.props.compact; + }, + fetch() { + fetch(`https://api.rss2json.com/v1/api.json?rss_url=${this.url}`, { + cache: 'no-cache' + }).then(res => { + res.json().then(feed => { + this.items = feed.items; + this.fetching = false; + }); + }); + }, + setting() { + alert('not implemented yet'); + } + } +}); +</script> + +<style lang="stylus" module> +.feed + padding 12px 16px + font-size 0.9em + + > a + display block + padding 4px 0 + color #666 + border-bottom dashed 1px #eee + + &:last-child + border-bottom none + +.fetching + margin 0 + padding 16px + text-align center + color #aaa + + > [data-fa] + margin-right 4px + +&[data-mobile] + .feed + padding 0 + font-size 1em + + > a + padding 8px 16px + + &:nth-child(even) + background rgba(0, 0, 0, 0.05) + +</style> diff --git a/src/server/web/app/common/views/widgets/server.cpu-memory.vue b/src/server/web/app/common/views/widgets/server.cpu-memory.vue new file mode 100644 index 0000000000..d75a142568 --- /dev/null +++ b/src/server/web/app/common/views/widgets/server.cpu-memory.vue @@ -0,0 +1,127 @@ +<template> +<div class="cpu-memory"> + <svg :viewBox="`0 0 ${ viewBoxX } ${ viewBoxY }`" preserveAspectRatio="none"> + <defs> + <linearGradient :id="cpuGradientId" x1="0" x2="0" y1="1" y2="0"> + <stop offset="0%" stop-color="hsl(180, 80%, 70%)"></stop> + <stop offset="100%" stop-color="hsl(0, 80%, 70%)"></stop> + </linearGradient> + <mask :id="cpuMaskId" x="0" y="0" :width="viewBoxX" :height="viewBoxY"> + <polygon + :points="cpuPolygonPoints" + fill="#fff" + fill-opacity="0.5"/> + <polyline + :points="cpuPolylinePoints" + fill="none" + stroke="#fff" + stroke-width="1"/> + </mask> + </defs> + <rect + x="-1" y="-1" + :width="viewBoxX + 2" :height="viewBoxY + 2" + :style="`stroke: none; fill: url(#${ cpuGradientId }); mask: url(#${ cpuMaskId })`"/> + <text x="1" y="5">CPU <tspan>{{ cpuP }}%</tspan></text> + </svg> + <svg :viewBox="`0 0 ${ viewBoxX } ${ viewBoxY }`" preserveAspectRatio="none"> + <defs> + <linearGradient :id="memGradientId" x1="0" x2="0" y1="1" y2="0"> + <stop offset="0%" stop-color="hsl(180, 80%, 70%)"></stop> + <stop offset="100%" stop-color="hsl(0, 80%, 70%)"></stop> + </linearGradient> + <mask :id="memMaskId" x="0" y="0" :width="viewBoxX" :height="viewBoxY"> + <polygon + :points="memPolygonPoints" + fill="#fff" + fill-opacity="0.5"/> + <polyline + :points="memPolylinePoints" + fill="none" + stroke="#fff" + stroke-width="1"/> + </mask> + </defs> + <rect + x="-1" y="-1" + :width="viewBoxX + 2" :height="viewBoxY + 2" + :style="`stroke: none; fill: url(#${ memGradientId }); mask: url(#${ memMaskId })`"/> + <text x="1" y="5">MEM <tspan>{{ memP }}%</tspan></text> + </svg> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import * as uuid from 'uuid'; + +export default Vue.extend({ + props: ['connection'], + data() { + return { + viewBoxX: 50, + viewBoxY: 30, + stats: [], + cpuGradientId: uuid(), + cpuMaskId: uuid(), + memGradientId: uuid(), + memMaskId: uuid(), + cpuPolylinePoints: '', + memPolylinePoints: '', + cpuPolygonPoints: '', + memPolygonPoints: '', + cpuP: '', + memP: '' + }; + }, + mounted() { + this.connection.on('stats', this.onStats); + }, + beforeDestroy() { + this.connection.off('stats', this.onStats); + }, + methods: { + onStats(stats) { + stats.mem.used = stats.mem.total - stats.mem.free; + this.stats.push(stats); + if (this.stats.length > 50) this.stats.shift(); + + this.cpuPolylinePoints = this.stats.map((s, i) => `${this.viewBoxX - ((this.stats.length - 1) - i)},${(1 - s.cpu_usage) * this.viewBoxY}`).join(' '); + this.memPolylinePoints = this.stats.map((s, i) => `${this.viewBoxX - ((this.stats.length - 1) - i)},${(1 - (s.mem.used / s.mem.total)) * this.viewBoxY}`).join(' '); + + this.cpuPolygonPoints = `${this.viewBoxX - (this.stats.length - 1)},${ this.viewBoxY } ${ this.cpuPolylinePoints } ${ this.viewBoxX },${ this.viewBoxY }`; + this.memPolygonPoints = `${this.viewBoxX - (this.stats.length - 1)},${ this.viewBoxY } ${ this.memPolylinePoints } ${ this.viewBoxX },${ this.viewBoxY }`; + + this.cpuP = (stats.cpu_usage * 100).toFixed(0); + this.memP = (stats.mem.used / stats.mem.total * 100).toFixed(0); + } + } +}); +</script> + +<style lang="stylus" scoped> +.cpu-memory + > svg + display block + padding 10px + width 50% + float left + + &:first-child + padding-right 5px + + &:last-child + padding-left 5px + + > text + font-size 5px + fill rgba(0, 0, 0, 0.55) + + > tspan + opacity 0.5 + + &:after + content "" + display block + clear both +</style> diff --git a/src/server/web/app/common/views/widgets/server.cpu.vue b/src/server/web/app/common/views/widgets/server.cpu.vue new file mode 100644 index 0000000000..596c856da8 --- /dev/null +++ b/src/server/web/app/common/views/widgets/server.cpu.vue @@ -0,0 +1,68 @@ +<template> +<div class="cpu"> + <x-pie class="pie" :value="usage"/> + <div> + <p>%fa:microchip%CPU</p> + <p>{{ meta.cpu.cores }} Cores</p> + <p>{{ meta.cpu.model }}</p> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import XPie from './server.pie.vue'; + +export default Vue.extend({ + components: { + XPie + }, + props: ['connection', 'meta'], + data() { + return { + usage: 0 + }; + }, + mounted() { + this.connection.on('stats', this.onStats); + }, + beforeDestroy() { + this.connection.off('stats', this.onStats); + }, + methods: { + onStats(stats) { + this.usage = stats.cpu_usage; + } + } +}); +</script> + +<style lang="stylus" scoped> +.cpu + > .pie + padding 10px + height 100px + float left + + > div + float left + width calc(100% - 100px) + padding 10px 10px 10px 0 + + > p + margin 0 + font-size 12px + color #505050 + + &:first-child + font-weight bold + + > [data-fa] + margin-right 4px + + &:after + content "" + display block + clear both + +</style> diff --git a/src/server/web/app/common/views/widgets/server.disk.vue b/src/server/web/app/common/views/widgets/server.disk.vue new file mode 100644 index 0000000000..2af1982a96 --- /dev/null +++ b/src/server/web/app/common/views/widgets/server.disk.vue @@ -0,0 +1,76 @@ +<template> +<div class="disk"> + <x-pie class="pie" :value="usage"/> + <div> + <p>%fa:R hdd%Storage</p> + <p>Total: {{ total | bytes(1) }}</p> + <p>Available: {{ available | bytes(1) }}</p> + <p>Used: {{ used | bytes(1) }}</p> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import XPie from './server.pie.vue'; + +export default Vue.extend({ + components: { + XPie + }, + props: ['connection'], + data() { + return { + usage: 0, + total: 0, + used: 0, + available: 0 + }; + }, + mounted() { + this.connection.on('stats', this.onStats); + }, + beforeDestroy() { + this.connection.off('stats', this.onStats); + }, + methods: { + onStats(stats) { + stats.disk.used = stats.disk.total - stats.disk.free; + this.usage = stats.disk.used / stats.disk.total; + this.total = stats.disk.total; + this.used = stats.disk.used; + this.available = stats.disk.available; + } + } +}); +</script> + +<style lang="stylus" scoped> +.disk + > .pie + padding 10px + height 100px + float left + + > div + float left + width calc(100% - 100px) + padding 10px 10px 10px 0 + + > p + margin 0 + font-size 12px + color #505050 + + &:first-child + font-weight bold + + > [data-fa] + margin-right 4px + + &:after + content "" + display block + clear both + +</style> diff --git a/src/server/web/app/common/views/widgets/server.info.vue b/src/server/web/app/common/views/widgets/server.info.vue new file mode 100644 index 0000000000..d243629506 --- /dev/null +++ b/src/server/web/app/common/views/widgets/server.info.vue @@ -0,0 +1,25 @@ +<template> +<div class="info"> + <p>Maintainer: <b><a :href="meta.maintainer.url" target="_blank">{{ meta.maintainer.name }}</a></b></p> + <p>Machine: {{ meta.machine }}</p> + <p>Node: {{ meta.node }}</p> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +export default Vue.extend({ + props: ['meta'] +}); +</script> + +<style lang="stylus" scoped> +.info + padding 10px 14px + + > p + margin 0 + font-size 12px + color #505050 +</style> diff --git a/src/server/web/app/common/views/widgets/server.memory.vue b/src/server/web/app/common/views/widgets/server.memory.vue new file mode 100644 index 0000000000..834a62671d --- /dev/null +++ b/src/server/web/app/common/views/widgets/server.memory.vue @@ -0,0 +1,76 @@ +<template> +<div class="memory"> + <x-pie class="pie" :value="usage"/> + <div> + <p>%fa:flask%Memory</p> + <p>Total: {{ total | bytes(1) }}</p> + <p>Used: {{ used | bytes(1) }}</p> + <p>Free: {{ free | bytes(1) }}</p> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import XPie from './server.pie.vue'; + +export default Vue.extend({ + components: { + XPie + }, + props: ['connection'], + data() { + return { + usage: 0, + total: 0, + used: 0, + free: 0 + }; + }, + mounted() { + this.connection.on('stats', this.onStats); + }, + beforeDestroy() { + this.connection.off('stats', this.onStats); + }, + methods: { + onStats(stats) { + stats.mem.used = stats.mem.total - stats.mem.free; + this.usage = stats.mem.used / stats.mem.total; + this.total = stats.mem.total; + this.used = stats.mem.used; + this.free = stats.mem.free; + } + } +}); +</script> + +<style lang="stylus" scoped> +.memory + > .pie + padding 10px + height 100px + float left + + > div + float left + width calc(100% - 100px) + padding 10px 10px 10px 0 + + > p + margin 0 + font-size 12px + color #505050 + + &:first-child + font-weight bold + + > [data-fa] + margin-right 4px + + &:after + content "" + display block + clear both + +</style> diff --git a/src/server/web/app/common/views/widgets/server.pie.vue b/src/server/web/app/common/views/widgets/server.pie.vue new file mode 100644 index 0000000000..ce2cff1d00 --- /dev/null +++ b/src/server/web/app/common/views/widgets/server.pie.vue @@ -0,0 +1,61 @@ +<template> +<svg viewBox="0 0 1 1" preserveAspectRatio="none"> + <circle + :r="r" + cx="50%" cy="50%" + fill="none" + stroke-width="0.1" + stroke="rgba(0, 0, 0, 0.05)"/> + <circle + :r="r" + cx="50%" cy="50%" + :stroke-dasharray="Math.PI * (r * 2)" + :stroke-dashoffset="strokeDashoffset" + fill="none" + stroke-width="0.1" + :stroke="color"/> + <text x="50%" y="50%" dy="0.05" text-anchor="middle">{{ (value * 100).toFixed(0) }}%</text> +</svg> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +export default Vue.extend({ + props: { + value: { + type: Number, + required: true + } + }, + data() { + return { + r: 0.4 + }; + }, + computed: { + color(): string { + return `hsl(${180 - (this.value * 180)}, 80%, 70%)`; + }, + strokeDashoffset(): number { + return (1 - this.value) * (Math.PI * (this.r * 2)); + } + } +}); +</script> + +<style lang="stylus" scoped> +svg + display block + height 100% + + > circle + transform-origin center + transform rotate(-90deg) + transition stroke-dashoffset 0.5s ease + + > text + font-size 0.15px + fill rgba(0, 0, 0, 0.6) + +</style> diff --git a/src/server/web/app/common/views/widgets/server.uptimes.vue b/src/server/web/app/common/views/widgets/server.uptimes.vue new file mode 100644 index 0000000000..06713d83ce --- /dev/null +++ b/src/server/web/app/common/views/widgets/server.uptimes.vue @@ -0,0 +1,46 @@ +<template> +<div class="uptimes"> + <p>Uptimes</p> + <p>Process: {{ process ? process.toFixed(0) : '---' }}s</p> + <p>OS: {{ os ? os.toFixed(0) : '---' }}s</p> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +export default Vue.extend({ + props: ['connection'], + data() { + return { + process: 0, + os: 0 + }; + }, + mounted() { + this.connection.on('stats', this.onStats); + }, + beforeDestroy() { + this.connection.off('stats', this.onStats); + }, + methods: { + onStats(stats) { + this.process = stats.process_uptime; + this.os = stats.os_uptime; + } + } +}); +</script> + +<style lang="stylus" scoped> +.uptimes + padding 10px 14px + + > p + margin 0 + font-size 12px + color #505050 + + &:first-child + font-weight bold +</style> diff --git a/src/server/web/app/common/views/widgets/server.vue b/src/server/web/app/common/views/widgets/server.vue new file mode 100644 index 0000000000..3d5248998f --- /dev/null +++ b/src/server/web/app/common/views/widgets/server.vue @@ -0,0 +1,93 @@ +<template> +<div class="mkw-server"> + <mk-widget-container :show-header="props.design == 0" :naked="props.design == 2"> + <template slot="header">%fa:server%%i18n:desktop.tags.mk-server-home-widget.title%</template> + <button slot="func" @click="toggle" title="%i18n:desktop.tags.mk-server-home-widget.toggle%">%fa:sort%</button> + + <p :class="$style.fetching" v-if="fetching">%fa:spinner .pulse .fw%%i18n:common.loading%<mk-ellipsis/></p> + <template v-if="!fetching"> + <x-cpu-memory v-show="props.view == 0" :connection="connection"/> + <x-cpu v-show="props.view == 1" :connection="connection" :meta="meta"/> + <x-memory v-show="props.view == 2" :connection="connection"/> + <x-disk v-show="props.view == 3" :connection="connection"/> + <x-uptimes v-show="props.view == 4" :connection="connection"/> + <x-info v-show="props.view == 5" :connection="connection" :meta="meta"/> + </template> + </mk-widget-container> +</div> +</template> + +<script lang="ts"> +import define from '../../../common/define-widget'; +import XCpuMemory from './server.cpu-memory.vue'; +import XCpu from './server.cpu.vue'; +import XMemory from './server.memory.vue'; +import XDisk from './server.disk.vue'; +import XUptimes from './server.uptimes.vue'; +import XInfo from './server.info.vue'; + +export default define({ + name: 'server', + props: () => ({ + design: 0, + view: 0 + }) +}).extend({ + components: { + XCpuMemory, + XCpu, + XMemory, + XDisk, + XUptimes, + XInfo + }, + data() { + return { + fetching: true, + meta: null, + connection: null, + connectionId: null + }; + }, + mounted() { + (this as any).os.getMeta().then(meta => { + this.meta = meta; + this.fetching = false; + }); + + this.connection = (this as any).os.streams.serverStream.getConnection(); + this.connectionId = (this as any).os.streams.serverStream.use(); + }, + beforeDestroy() { + (this as any).os.streams.serverStream.dispose(this.connectionId); + }, + methods: { + toggle() { + if (this.props.view == 5) { + this.props.view = 0; + } else { + this.props.view++; + } + }, + func() { + if (this.props.design == 2) { + this.props.design = 0; + } else { + this.props.design++; + } + } + } +}); +</script> + +<style lang="stylus" module> +.fetching + margin 0 + padding 16px + text-align center + color #aaa + + > [data-fa] + margin-right 4px + +</style> diff --git a/src/server/web/app/common/views/widgets/slideshow.vue b/src/server/web/app/common/views/widgets/slideshow.vue new file mode 100644 index 0000000000..e9451663e2 --- /dev/null +++ b/src/server/web/app/common/views/widgets/slideshow.vue @@ -0,0 +1,159 @@ +<template> +<div class="mkw-slideshow" :data-mobile="isMobile"> + <div @click="choose"> + <p v-if="props.folder === undefined"> + <template v-if="isCustomizeMode">フォルダを指定するには、カスタマイズモードを終了してください</template> + <template v-else>クリックしてフォルダを指定してください</template> + </p> + <p v-if="props.folder !== undefined && images.length == 0 && !fetching">このフォルダには画像がありません</p> + <div ref="slideA" class="slide a"></div> + <div ref="slideB" class="slide b"></div> + </div> +</div> +</template> + +<script lang="ts"> +import * as anime from 'animejs'; +import define from '../../../common/define-widget'; +export default define({ + name: 'slideshow', + props: () => ({ + folder: undefined, + size: 0 + }) +}).extend({ + data() { + return { + images: [], + fetching: true, + clock: null + }; + }, + mounted() { + this.$nextTick(() => { + this.applySize(); + }); + + if (this.props.folder !== undefined) { + this.fetch(); + } + + this.clock = setInterval(this.change, 10000); + }, + beforeDestroy() { + clearInterval(this.clock); + }, + methods: { + func() { + this.resize(); + }, + applySize() { + let h; + + if (this.props.size == 1) { + h = 250; + } else { + h = 170; + } + + this.$el.style.height = `${h}px`; + }, + resize() { + if (this.props.size == 1) { + this.props.size = 0; + } else { + this.props.size++; + } + + this.applySize(); + }, + 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 as any).style.backgroundImage = img; + + anime({ + targets: this.$refs.slideB, + opacity: 1, + duration: 1000, + easing: 'linear', + complete: () => { + // 既にこのウィジェットがunmountされていたら要素がない + if ((this.$refs.slideA as any) == null) return; + + (this.$refs.slideA as any).style.backgroundImage = img; + anime({ + targets: this.$refs.slideB, + opacity: 0, + duration: 0 + }); + } + }); + }, + fetch() { + this.fetching = true; + + (this as any).api('drive/files', { + folder_id: this.props.folder, + type: 'image/*', + limit: 100 + }).then(images => { + this.images = images; + this.fetching = false; + (this.$refs.slideA as any).style.backgroundImage = ''; + (this.$refs.slideB as any).style.backgroundImage = ''; + this.change(); + }); + }, + choose() { + (this as any).apis.chooseDriveFolder().then(folder => { + this.props.folder = folder ? folder.id : null; + this.fetch(); + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +.mkw-slideshow + overflow hidden + background #fff + border solid 1px rgba(0, 0, 0, 0.075) + border-radius 6px + + &[data-mobile] + border none + border-radius 8px + box-shadow 0 0 0 1px rgba(0, 0, 0, 0.2) + + > div + width 100% + height 100% + cursor pointer + + > p + display block + margin 1em + text-align center + color #888 + + > * + pointer-events none + + > .slide + position absolute + top 0 + left 0 + width 100% + height 100% + background-size cover + background-position center + + &.b + opacity 0 + +</style> diff --git a/src/server/web/app/common/views/widgets/tips.vue b/src/server/web/app/common/views/widgets/tips.vue new file mode 100644 index 0000000000..bdecc068e1 --- /dev/null +++ b/src/server/web/app/common/views/widgets/tips.vue @@ -0,0 +1,108 @@ +<template> +<div class="mkw-tips"> + <p ref="tip">%fa:R lightbulb%<span v-html="tip"></span></p> +</div> +</template> + +<script lang="ts"> +import * as anime from 'animejs'; +import define from '../../../common/define-widget'; + +const tips = [ + '<kbd>t</kbd>でタイムラインにフォーカスできます', + '<kbd>p</kbd>または<kbd>n</kbd>で投稿フォームを開きます', + '投稿フォームにはファイルをドラッグ&ドロップできます', + '投稿フォームにクリップボードにある画像データをペーストできます', + 'ドライブにファイルをドラッグ&ドロップしてアップロードできます', + 'ドライブでファイルをドラッグしてフォルダ移動できます', + 'ドライブでフォルダをドラッグしてフォルダ移動できます', + 'ホームは設定からカスタマイズできます', + 'MisskeyはMIT Licenseです', + 'タイムマシンウィジェットを利用すると、簡単に過去のタイムラインに遡れます', + '投稿の ... をクリックして、投稿をユーザーページにピン留めできます', + 'ドライブの容量は(デフォルトで)1GBです', + '投稿に添付したファイルは全てドライブに保存されます', + 'ホームのカスタマイズ中、ウィジェットを右クリックしてデザインを変更できます', + 'タイムライン上部にもウィジェットを設置できます', + '投稿をダブルクリックすると詳細が見れます', + '「**」でテキストを囲むと**強調表示**されます', + 'チャンネルウィジェットを利用すると、よく利用するチャンネルを素早く確認できます', + 'いくつかのウィンドウはブラウザの外に切り離すことができます', + 'カレンダーウィジェットのパーセンテージは、経過の割合を示しています', + 'APIを利用してbotの開発なども行えます', + 'MisskeyはLINEを通じてでも利用できます', + 'まゆかわいいよまゆ', + 'Misskeyは2014年にサービスを開始しました', + '対応ブラウザではMisskeyを開いていなくても通知を受け取れます' +] + +export default define({ + name: 'tips' +}).extend({ + data() { + return { + tip: null, + clock: null + }; + }, + mounted() { + this.$nextTick(() => { + this.set(); + }); + + this.clock = setInterval(this.change, 20000); + }, + beforeDestroy() { + clearInterval(this.clock); + }, + methods: { + set() { + this.tip = tips[Math.floor(Math.random() * tips.length)]; + }, + change() { + anime({ + targets: this.$refs.tip, + opacity: 0, + duration: 500, + easing: 'linear', + complete: this.set + }); + + setTimeout(() => { + anime({ + targets: this.$refs.tip, + opacity: 1, + duration: 500, + easing: 'linear' + }); + }, 500); + } + } +}); +</script> + +<style lang="stylus" scoped> +.mkw-tips + overflow visible !important + + > p + display block + margin 0 + padding 0 12px + text-align center + font-size 0.7em + color #999 + + > [data-fa] + margin-right 4px + + kbd + display inline + padding 0 6px + margin 0 2px + font-size 1em + font-family inherit + border solid 1px #999 + border-radius 2px + +</style> diff --git a/src/server/web/app/common/views/widgets/version.vue b/src/server/web/app/common/views/widgets/version.vue new file mode 100644 index 0000000000..5072d9b74a --- /dev/null +++ b/src/server/web/app/common/views/widgets/version.vue @@ -0,0 +1,28 @@ +<template> +<p>ver {{ v }} (葵 aoi)</p> +</template> + +<script lang="ts"> +import { version } from '../../../config'; +import define from '../../../common/define-widget'; +export default define({ + name: 'version' +}).extend({ + data() { + return { + v: version + }; + } +}); +</script> + +<style lang="stylus" scoped> +p + display block + margin 0 + padding 0 12px + text-align center + font-size 0.7em + color #aaa + +</style> diff --git a/src/server/web/app/config.ts b/src/server/web/app/config.ts new file mode 100644 index 0000000000..8ea6f70101 --- /dev/null +++ b/src/server/web/app/config.ts @@ -0,0 +1,37 @@ +declare const _HOST_: string; +declare const _HOSTNAME_: string; +declare const _URL_: string; +declare const _API_URL_: string; +declare const _WS_URL_: string; +declare const _DOCS_URL_: string; +declare const _STATS_URL_: string; +declare const _STATUS_URL_: string; +declare const _DEV_URL_: string; +declare const _CH_URL_: string; +declare const _LANG_: string; +declare const _RECAPTCHA_SITEKEY_: string; +declare const _SW_PUBLICKEY_: string; +declare const _THEME_COLOR_: string; +declare const _COPYRIGHT_: string; +declare const _VERSION_: string; +declare const _LICENSE_: string; +declare const _GOOGLE_MAPS_API_KEY_: string; + +export const host = _HOST_; +export const hostname = _HOSTNAME_; +export const url = _URL_; +export const apiUrl = _API_URL_; +export const wsUrl = _WS_URL_; +export const docsUrl = _DOCS_URL_; +export const statsUrl = _STATS_URL_; +export const statusUrl = _STATUS_URL_; +export const devUrl = _DEV_URL_; +export const chUrl = _CH_URL_; +export const lang = _LANG_; +export const recaptchaSitekey = _RECAPTCHA_SITEKEY_; +export const swPublickey = _SW_PUBLICKEY_; +export const themeColor = _THEME_COLOR_; +export const copyright = _COPYRIGHT_; +export const version = _VERSION_; +export const license = _LICENSE_; +export const googleMapsApiKey = _GOOGLE_MAPS_API_KEY_; diff --git a/src/server/web/app/desktop/api/choose-drive-file.ts b/src/server/web/app/desktop/api/choose-drive-file.ts new file mode 100644 index 0000000000..fbda600e6e --- /dev/null +++ b/src/server/web/app/desktop/api/choose-drive-file.ts @@ -0,0 +1,30 @@ +import { url } from '../../config'; +import MkChooseFileFromDriveWindow from '../views/components/choose-file-from-drive-window.vue'; + +export default function(opts) { + return new Promise((res, rej) => { + const o = opts || {}; + + if (document.body.clientWidth > 800) { + const w = new MkChooseFileFromDriveWindow({ + propsData: { + title: o.title, + multiple: o.multiple, + initFolder: o.currentFolder + } + }).$mount(); + w.$once('selected', file => { + res(file); + }); + document.body.appendChild(w.$el); + } else { + window['cb'] = file => { + res(file); + }; + + window.open(url + '/selectdrive', + 'choose_drive_window', + 'height=500, width=800'); + } + }); +} diff --git a/src/server/web/app/desktop/api/choose-drive-folder.ts b/src/server/web/app/desktop/api/choose-drive-folder.ts new file mode 100644 index 0000000000..9b33a20d9a --- /dev/null +++ b/src/server/web/app/desktop/api/choose-drive-folder.ts @@ -0,0 +1,17 @@ +import MkChooseFolderFromDriveWindow from '../views/components/choose-folder-from-drive-window.vue'; + +export default function(opts) { + return new Promise((res, rej) => { + const o = opts || {}; + const w = new MkChooseFolderFromDriveWindow({ + propsData: { + title: o.title, + initFolder: o.currentFolder + } + }).$mount(); + w.$once('selected', folder => { + res(folder); + }); + document.body.appendChild(w.$el); + }); +} diff --git a/src/server/web/app/desktop/api/contextmenu.ts b/src/server/web/app/desktop/api/contextmenu.ts new file mode 100644 index 0000000000..b70d7122d3 --- /dev/null +++ b/src/server/web/app/desktop/api/contextmenu.ts @@ -0,0 +1,16 @@ +import Ctx from '../views/components/context-menu.vue'; + +export default function(e, menu, opts?) { + const o = opts || {}; + const vm = new Ctx({ + propsData: { + menu, + x: e.pageX - window.pageXOffset, + y: e.pageY - window.pageYOffset, + } + }).$mount(); + vm.$once('closed', () => { + if (o.closed) o.closed(); + }); + document.body.appendChild(vm.$el); +} diff --git a/src/server/web/app/desktop/api/dialog.ts b/src/server/web/app/desktop/api/dialog.ts new file mode 100644 index 0000000000..07935485b0 --- /dev/null +++ b/src/server/web/app/desktop/api/dialog.ts @@ -0,0 +1,19 @@ +import Dialog from '../views/components/dialog.vue'; + +export default function(opts) { + return new Promise<string>((res, rej) => { + const o = opts || {}; + const d = new Dialog({ + propsData: { + title: o.title, + text: o.text, + modal: o.modal, + buttons: o.actions + } + }).$mount(); + d.$once('clicked', id => { + res(id); + }); + document.body.appendChild(d.$el); + }); +} diff --git a/src/server/web/app/desktop/api/input.ts b/src/server/web/app/desktop/api/input.ts new file mode 100644 index 0000000000..ce26a8112f --- /dev/null +++ b/src/server/web/app/desktop/api/input.ts @@ -0,0 +1,20 @@ +import InputDialog from '../views/components/input-dialog.vue'; + +export default function(opts) { + return new Promise<string>((res, rej) => { + const o = opts || {}; + const d = new InputDialog({ + propsData: { + title: o.title, + placeholder: o.placeholder, + default: o.default, + type: o.type || 'text', + allowEmpty: o.allowEmpty + } + }).$mount(); + d.$once('done', text => { + res(text); + }); + document.body.appendChild(d.$el); + }); +} diff --git a/src/server/web/app/desktop/api/notify.ts b/src/server/web/app/desktop/api/notify.ts new file mode 100644 index 0000000000..1f89f40ce6 --- /dev/null +++ b/src/server/web/app/desktop/api/notify.ts @@ -0,0 +1,10 @@ +import Notification from '../views/components/ui-notification.vue'; + +export default function(message) { + const vm = new Notification({ + propsData: { + message + } + }).$mount(); + document.body.appendChild(vm.$el); +} diff --git a/src/server/web/app/desktop/api/post.ts b/src/server/web/app/desktop/api/post.ts new file mode 100644 index 0000000000..cf49615df3 --- /dev/null +++ b/src/server/web/app/desktop/api/post.ts @@ -0,0 +1,21 @@ +import PostFormWindow from '../views/components/post-form-window.vue'; +import RepostFormWindow from '../views/components/repost-form-window.vue'; + +export default function(opts) { + const o = opts || {}; + if (o.repost) { + const vm = new RepostFormWindow({ + propsData: { + repost: o.repost + } + }).$mount(); + document.body.appendChild(vm.$el); + } else { + const vm = new PostFormWindow({ + propsData: { + reply: o.reply + } + }).$mount(); + document.body.appendChild(vm.$el); + } +} diff --git a/src/server/web/app/desktop/api/update-avatar.ts b/src/server/web/app/desktop/api/update-avatar.ts new file mode 100644 index 0000000000..8f748d853c --- /dev/null +++ b/src/server/web/app/desktop/api/update-avatar.ts @@ -0,0 +1,98 @@ +import OS from '../../common/mios'; +import { apiUrl } from '../../config'; +import CropWindow from '../views/components/crop-window.vue'; +import ProgressDialog from '../views/components/progress-dialog.vue'; + +export default (os: OS) => (cb, file = null) => { + const fileSelected = file => { + + const w = new CropWindow({ + propsData: { + image: file, + title: 'アバターとして表示する部分を選択', + aspectRatio: 1 / 1 + } + }).$mount(); + + w.$once('cropped', blob => { + const data = new FormData(); + data.append('i', os.i.account.token); + data.append('file', blob, file.name + '.cropped.png'); + + os.api('drive/folders/find', { + name: 'アイコン' + }).then(iconFolder => { + if (iconFolder.length === 0) { + os.api('drive/folders/create', { + name: 'アイコン' + }).then(iconFolder => { + upload(data, iconFolder); + }); + } else { + upload(data, iconFolder[0]); + } + }); + }); + + w.$once('skipped', () => { + set(file); + }); + + document.body.appendChild(w.$el); + }; + + const upload = (data, folder) => { + const dialog = new ProgressDialog({ + propsData: { + title: '新しいアバターをアップロードしています' + } + }).$mount(); + document.body.appendChild(dialog.$el); + + if (folder) data.append('folder_id', folder.id); + + const xhr = new XMLHttpRequest(); + xhr.open('POST', apiUrl + '/drive/files/create', true); + xhr.onload = e => { + const file = JSON.parse((e.target as any).response); + (dialog as any).close(); + set(file); + }; + + xhr.upload.onprogress = e => { + if (e.lengthComputable) (dialog as any).update(e.loaded, e.total); + }; + + xhr.send(data); + }; + + const set = file => { + os.api('i/update', { + avatar_id: file.id + }).then(i => { + os.i.avatar_id = i.avatar_id; + os.i.avatar_url = i.avatar_url; + + os.apis.dialog({ + title: '%fa:info-circle%アバターを更新しました', + text: '新しいアバターが反映されるまで時間がかかる場合があります。', + actions: [{ + text: 'わかった' + }] + }); + + if (cb) cb(i); + }); + }; + + if (file) { + fileSelected(file); + } else { + os.apis.chooseDriveFile({ + multiple: false, + title: '%fa:image%アバターにする画像を選択' + }).then(file => { + fileSelected(file); + }); + } +}; diff --git a/src/server/web/app/desktop/api/update-banner.ts b/src/server/web/app/desktop/api/update-banner.ts new file mode 100644 index 0000000000..9ed48b2670 --- /dev/null +++ b/src/server/web/app/desktop/api/update-banner.ts @@ -0,0 +1,98 @@ +import OS from '../../common/mios'; +import { apiUrl } from '../../config'; +import CropWindow from '../views/components/crop-window.vue'; +import ProgressDialog from '../views/components/progress-dialog.vue'; + +export default (os: OS) => (cb, file = null) => { + const fileSelected = file => { + + const w = new CropWindow({ + propsData: { + image: file, + title: 'バナーとして表示する部分を選択', + aspectRatio: 16 / 9 + } + }).$mount(); + + w.$once('cropped', blob => { + const data = new FormData(); + data.append('i', os.i.account.token); + data.append('file', blob, file.name + '.cropped.png'); + + os.api('drive/folders/find', { + name: 'バナー' + }).then(bannerFolder => { + if (bannerFolder.length === 0) { + os.api('drive/folders/create', { + name: 'バナー' + }).then(iconFolder => { + upload(data, iconFolder); + }); + } else { + upload(data, bannerFolder[0]); + } + }); + }); + + w.$once('skipped', () => { + set(file); + }); + + document.body.appendChild(w.$el); + }; + + const upload = (data, folder) => { + const dialog = new ProgressDialog({ + propsData: { + title: '新しいバナーをアップロードしています' + } + }).$mount(); + document.body.appendChild(dialog.$el); + + if (folder) data.append('folder_id', folder.id); + + const xhr = new XMLHttpRequest(); + xhr.open('POST', apiUrl + '/drive/files/create', true); + xhr.onload = e => { + const file = JSON.parse((e.target as any).response); + (dialog as any).close(); + set(file); + }; + + xhr.upload.onprogress = e => { + if (e.lengthComputable) (dialog as any).update(e.loaded, e.total); + }; + + xhr.send(data); + }; + + const set = file => { + os.api('i/update', { + banner_id: file.id + }).then(i => { + os.i.banner_id = i.banner_id; + os.i.banner_url = i.banner_url; + + os.apis.dialog({ + title: '%fa:info-circle%バナーを更新しました', + text: '新しいバナーが反映されるまで時間がかかる場合があります。', + actions: [{ + text: 'わかった' + }] + }); + + if (cb) cb(i); + }); + }; + + if (file) { + fileSelected(file); + } else { + os.apis.chooseDriveFile({ + multiple: false, + title: '%fa:image%バナーにする画像を選択' + }).then(file => { + fileSelected(file); + }); + } +}; diff --git a/src/server/web/app/desktop/assets/grid.svg b/src/server/web/app/desktop/assets/grid.svg new file mode 100644 index 0000000000..d1d72cd8ce --- /dev/null +++ b/src/server/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/server/web/app/desktop/assets/header-logo-white.svg b/src/server/web/app/desktop/assets/header-logo-white.svg new file mode 100644 index 0000000000..8082edb30d --- /dev/null +++ b/src/server/web/app/desktop/assets/header-logo-white.svg @@ -0,0 +1,25 @@ +<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 16.0.3, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg version="1.1" id="レイヤー_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px"
+ y="0px" width="256px" height="256px" viewBox="0 0 256 256" enable-background="new 0 0 256 256" xml:space="preserve">
+<circle fill="#FFFFFF" cx="128" cy="153.6" r="19.201"/>
+<circle fill="#FFFFFF" cx="51.2" cy="153.6" r="19.2"/>
+<circle fill="#FFFFFF" cx="204.8" cy="153.6" r="19.2"/>
+<polyline fill="none" stroke="#FFFFFF" stroke-width="16" stroke-linejoin="round" stroke-miterlimit="10" points="51.2,153.6
+ 89.601,102.4 128,153.6 166.4,102.4 204.799,153.6 "/>
+<circle fill="#FFFFFF" cx="89.6" cy="102.4" r="19.2"/>
+<circle fill="#FFFFFF" cx="166.4" cy="102.4" r="19.199"/>
+<g>
+</g>
+<g>
+</g>
+<g>
+</g>
+<g>
+</g>
+<g>
+</g>
+<g>
+</g>
+</svg>
diff --git a/src/server/web/app/desktop/assets/header-logo.svg b/src/server/web/app/desktop/assets/header-logo.svg new file mode 100644 index 0000000000..3a2207954a --- /dev/null +++ b/src/server/web/app/desktop/assets/header-logo.svg @@ -0,0 +1,25 @@ +<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 16.0.3, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg version="1.1" id="レイヤー_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px"
+ y="0px" width="256px" height="256px" viewBox="0 0 256 256" enable-background="new 0 0 256 256" xml:space="preserve">
+<circle cx="128" cy="153.6" r="19.201"/>
+<circle cx="51.2" cy="153.6" r="19.2"/>
+<circle cx="204.8" cy="153.6" r="19.2"/>
+<polyline fill="none" stroke="#000000" stroke-width="16" stroke-linejoin="round" stroke-miterlimit="10" points="51.2,153.6
+ 89.601,102.4 128,153.6 166.4,102.4 204.799,153.6 "/>
+<circle cx="89.6" cy="102.4" r="19.2"/>
+<circle cx="166.4" cy="102.4" r="19.199"/>
+<g>
+</g>
+<g>
+</g>
+<g>
+</g>
+<g>
+</g>
+<g>
+</g>
+<g>
+</g>
+</svg>
diff --git a/src/server/web/app/desktop/assets/index.jpg b/src/server/web/app/desktop/assets/index.jpg Binary files differnew file mode 100644 index 0000000000..10c412efe2 --- /dev/null +++ b/src/server/web/app/desktop/assets/index.jpg diff --git a/src/server/web/app/desktop/assets/remove.png b/src/server/web/app/desktop/assets/remove.png Binary files differnew file mode 100644 index 0000000000..8b1f4c06c9 --- /dev/null +++ b/src/server/web/app/desktop/assets/remove.png diff --git a/src/server/web/app/desktop/script.ts b/src/server/web/app/desktop/script.ts new file mode 100644 index 0000000000..b95e168544 --- /dev/null +++ b/src/server/web/app/desktop/script.ts @@ -0,0 +1,167 @@ +/** + * Desktop Client + */ + +import VueRouter from 'vue-router'; + +// Style +import './style.styl'; +import '../../element.scss'; + +import init from '../init'; +import fuckAdBlock from '../common/scripts/fuck-ad-block'; +import { HomeStreamManager } from '../common/scripts/streaming/home'; +import composeNotification from '../common/scripts/compose-notification'; + +import chooseDriveFolder from './api/choose-drive-folder'; +import chooseDriveFile from './api/choose-drive-file'; +import dialog from './api/dialog'; +import input from './api/input'; +import post from './api/post'; +import notify from './api/notify'; +import updateAvatar from './api/update-avatar'; +import updateBanner from './api/update-banner'; + +import MkIndex from './views/pages/index.vue'; +import MkUser from './views/pages/user/user.vue'; +import MkSelectDrive from './views/pages/selectdrive.vue'; +import MkDrive from './views/pages/drive.vue'; +import MkHomeCustomize from './views/pages/home-customize.vue'; +import MkMessagingRoom from './views/pages/messaging-room.vue'; +import MkPost from './views/pages/post.vue'; +import MkSearch from './views/pages/search.vue'; +import MkOthello from './views/pages/othello.vue'; + +/** + * init + */ +init(async (launch) => { + // Register directives + require('./views/directives'); + + // Register components + require('./views/components'); + require('./views/widgets'); + + // Init router + const router = new VueRouter({ + mode: 'history', + routes: [ + { path: '/', name: 'index', component: MkIndex }, + { path: '/i/customize-home', component: MkHomeCustomize }, + { path: '/i/messaging/:user', component: MkMessagingRoom }, + { path: '/i/drive', component: MkDrive }, + { path: '/i/drive/folder/:folder', component: MkDrive }, + { path: '/selectdrive', component: MkSelectDrive }, + { path: '/search', component: MkSearch }, + { path: '/othello', component: MkOthello }, + { path: '/othello/:game', component: MkOthello }, + { path: '/@:user', component: MkUser }, + { path: '/@:user/:post', component: MkPost } + ] + }); + + // Launch the app + const [, os] = launch(router, os => ({ + chooseDriveFolder, + chooseDriveFile, + dialog, + input, + post, + notify, + updateAvatar: updateAvatar(os), + updateBanner: updateBanner(os) + })); + + /** + * Fuck AD Block + */ + fuckAdBlock(os); + + /** + * Init Notification + */ + if ('Notification' in window) { + // 許可を得ていなかったらリクエスト + if ((Notification as any).permission == 'default') { + await Notification.requestPermission(); + } + + if ((Notification as any).permission == 'granted') { + registerNotifications(os.stream); + } + } +}, 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); + }); + + connection.on('othello_invited', matching => { + const _n = composeNotification('othello_invited', matching); + const n = new Notification(_n.title, { + body: _n.body, + icon: _n.icon + }); + }); + } +} diff --git a/src/server/web/app/desktop/style.styl b/src/server/web/app/desktop/style.styl new file mode 100644 index 0000000000..49f71fbde7 --- /dev/null +++ b/src/server/web/app/desktop/style.styl @@ -0,0 +1,50 @@ +@import "../app" +@import "../reset" + +@import "./ui" + +*::input-placeholder + color #D8CBC5 + +* + &:focus + outline none + + &::scrollbar + width 5px + background transparent + + &:horizontal + height 5px + + &::scrollbar-button + width 0 + height 0 + background rgba(0, 0, 0, 0.2) + + &::scrollbar-piece + background transparent + + &:start + background transparent + + &::scrollbar-thumb + background rgba(0, 0, 0, 0.2) + + &:hover + background rgba(0, 0, 0, 0.4) + + &:active + background $theme-color + + &::scrollbar-corner + background rgba(0, 0, 0, 0.2) + +html + height 100% + background #f7f7f7 + +body + display flex + flex-direction column + min-height 100% diff --git a/src/server/web/app/desktop/ui.styl b/src/server/web/app/desktop/ui.styl new file mode 100644 index 0000000000..5a8d1718e2 --- /dev/null +++ b/src/server/web/app/desktop/ui.styl @@ -0,0 +1,125 @@ +@import "../../const" + +button + font-family sans-serif + + * + pointer-events none + +button.ui +.button.ui + display inline-block + cursor pointer + padding 0 14px + margin 0 + min-width 100px + line-height 38px + font-size 14px + color #888 + text-decoration none + background linear-gradient(to bottom, #ffffff 0%, #f5f5f5 100%) + border solid 1px #e2e2e2 + border-radius 4px + outline none + + &.block + display block + + &:focus + &:after + content "" + pointer-events none + position absolute + top -5px + right -5px + bottom -5px + left -5px + border 2px solid rgba($theme-color, 0.3) + border-radius 8px + + &:disabled + opacity 0.7 + cursor default + + &:hover + background linear-gradient(to bottom, #f9f9f9 0%, #ececec 100%) + border-color #dcdcdc + + &:active + background #ececec + border-color #dcdcdc + + &.primary + color $theme-color-foreground + background linear-gradient(to bottom, lighten($theme-color, 25%) 0%, lighten($theme-color, 10%) 100%) + border solid 1px lighten($theme-color, 15%) + + &:not(:disabled) + font-weight bold + + &:hover:not(:disabled) + background linear-gradient(to bottom, lighten($theme-color, 8%) 0%, darken($theme-color, 8%) 100%) + border-color $theme-color + + &:active:not(:disabled) + background $theme-color + border-color $theme-color + +input:not([type]).ui +input[type='text'].ui +input[type='password'].ui +input[type='email'].ui +input[type='date'].ui +input[type='number'].ui +textarea.ui + display block + padding 10px + width 100% + height 40px + font-family sans-serif + font-size 16px + color #55595c + border solid 1px #dadada + border-radius 4px + + &:hover + border-color #b0b0b0 + + &:focus + border-color $theme-color + +textarea.ui + min-width 100% + max-width 100% + min-height 64px + +.ui.info + display block + margin 1em 0 + padding 0 1em + font-size 90% + color rgba(#000, 0.87) + background #f8f8f9 + border solid 1px rgba(34, 36, 38, 0.22) + border-radius 4px + + > p + opacity 0.8 + + > [data-fa]:first-child + margin-right 0.25em + + &.warn + color #573a08 + background #FFFAF3 + border-color #C9BA9B + +.ui.from.group + display block + margin 16px 0 + + > p:first-child + margin 0 0 6px 0 + font-size 90% + font-weight bold + color rgba(#373a3c, 0.9) diff --git a/src/server/web/app/desktop/views/components/activity.calendar.vue b/src/server/web/app/desktop/views/components/activity.calendar.vue new file mode 100644 index 0000000000..72233e9aca --- /dev/null +++ b/src/server/web/app/desktop/views/components/activity.calendar.vue @@ -0,0 +1,66 @@ +<template> +<svg viewBox="0 0 21 7" preserveAspectRatio="none"> + <rect v-for="record in data" class="day" + width="1" height="1" + :x="record.x" :y="record.date.weekday" + rx="1" ry="1" + fill="transparent"> + <title>{{ record.date.year }}/{{ record.date.month }}/{{ record.date.day }}</title> + </rect> + <rect v-for="record in data" class="day" + :width="record.v" :height="record.v" + :x="record.x + ((1 - record.v) / 2)" :y="record.date.weekday + ((1 - record.v) / 2)" + rx="1" ry="1" + :fill="record.color" + style="pointer-events: none;"/> + <rect class="today" + width="1" height="1" + :x="data[data.length - 1].x" :y="data[data.length - 1].date.weekday" + rx="1" ry="1" + fill="none" + stroke-width="0.1" + stroke="#f73520"/> +</svg> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +export default Vue.extend({ + props: ['data'], + created() { + 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 = peak == 0 ? 0 : 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> + +<style lang="stylus" scoped> +svg + display block + padding 10px + width 100% + + > rect + transform-origin center + + &.day + &:hover + fill rgba(0, 0, 0, 0.05) + +</style> diff --git a/src/server/web/app/desktop/views/components/activity.chart.vue b/src/server/web/app/desktop/views/components/activity.chart.vue new file mode 100644 index 0000000000..5057786ed4 --- /dev/null +++ b/src/server/web/app/desktop/views/components/activity.chart.vue @@ -0,0 +1,103 @@ +<template> +<svg :viewBox="`0 0 ${ viewBoxX } ${ viewBoxY }`" preserveAspectRatio="none" @mousedown.prevent="onMousedown"> + <title>Black ... Total<br/>Blue ... Posts<br/>Red ... Replies<br/>Green ... Reposts</title> + <polyline + :points="pointsPost" + fill="none" + stroke-width="1" + stroke="#41ddde"/> + <polyline + :points="pointsReply" + fill="none" + stroke-width="1" + stroke="#f7796c"/> + <polyline + :points="pointsRepost" + fill="none" + stroke-width="1" + stroke="#a1de41"/> + <polyline + :points="pointsTotal" + fill="none" + stroke-width="1" + stroke="#555" + stroke-dasharray="2 2"/> +</svg> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +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); +} + +export default Vue.extend({ + props: ['data'], + data() { + return { + viewBoxX: 140, + viewBoxY: 60, + zoom: 1, + pos: 0, + pointsPost: null, + pointsReply: null, + pointsRepost: null, + pointsTotal: null + }; + }, + created() { + this.data.reverse(); + this.data.forEach(d => d.total = d.posts + d.replies + d.reposts); + this.render(); + }, + methods: { + render() { + const peak = Math.max.apply(null, this.data.map(d => d.total)); + if (peak != 0) { + this.pointsPost = this.data.map((d, i) => `${(i * this.zoom) + this.pos},${(1 - (d.posts / peak)) * this.viewBoxY}`).join(' '); + this.pointsReply = this.data.map((d, i) => `${(i * this.zoom) + this.pos},${(1 - (d.replies / peak)) * this.viewBoxY}`).join(' '); + this.pointsRepost = this.data.map((d, i) => `${(i * this.zoom) + this.pos},${(1 - (d.reposts / peak)) * this.viewBoxY}`).join(' '); + this.pointsTotal = this.data.map((d, i) => `${(i * this.zoom) + this.pos},${(1 - (d.total / peak)) * this.viewBoxY}`).join(' '); + } + }, + onMousedown(e) { + 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(); + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +svg + display block + padding 10px + width 100% + cursor all-scroll + +</style> diff --git a/src/server/web/app/desktop/views/components/activity.vue b/src/server/web/app/desktop/views/components/activity.vue new file mode 100644 index 0000000000..33b53eb700 --- /dev/null +++ b/src/server/web/app/desktop/views/components/activity.vue @@ -0,0 +1,116 @@ +<template> +<div class="mk-activity" :data-melt="design == 2"> + <template v-if="design == 0"> + <p class="title">%fa:chart-bar%%i18n:desktop.tags.mk-activity-widget.title%</p> + <button @click="toggle" title="%i18n:desktop.tags.mk-activity-widget.toggle%">%fa:sort%</button> + </template> + <p class="fetching" v-if="fetching">%fa:spinner .pulse .fw%%i18n:common.loading%<mk-ellipsis/></p> + <template v-else> + <x-calendar v-show="view == 0" :data="[].concat(activity)"/> + <x-chart v-show="view == 1" :data="[].concat(activity)"/> + </template> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import XCalendar from './activity.calendar.vue'; +import XChart from './activity.chart.vue'; + +export default Vue.extend({ + components: { + XCalendar, + XChart + }, + props: { + design: { + default: 0 + }, + initView: { + default: 0 + }, + user: { + type: Object, + required: true + } + }, + data() { + return { + fetching: true, + activity: null, + view: this.initView + }; + }, + mounted() { + (this as any).api('aggregation/users/activity', { + user_id: this.user.id, + limit: 20 * 7 + }).then(activity => { + this.activity = activity; + this.fetching = false; + }); + }, + methods: { + toggle() { + if (this.view == 1) { + this.view = 0; + this.$emit('viewChanged', this.view); + } else { + this.view++; + this.$emit('viewChanged', this.view); + } + } + } +}); +</script> + +<style lang="stylus" scoped> +.mk-activity + background #fff + border solid 1px rgba(0, 0, 0, 0.075) + border-radius 6px + + &[data-melt] + background transparent !important + border none !important + + > .title + z-index 1 + margin 0 + padding 0 16px + line-height 42px + font-size 0.9em + font-weight bold + color #888 + box-shadow 0 1px rgba(0, 0, 0, 0.07) + + > [data-fa] + margin-right 4px + + > button + position absolute + z-index 2 + top 0 + right 0 + padding 0 + width 42px + font-size 0.9em + line-height 42px + color #ccc + + &:hover + color #aaa + + &:active + color #999 + + > .fetching + margin 0 + padding 16px + text-align center + color #aaa + + > [data-fa] + margin-right 4px + +</style> diff --git a/src/server/web/app/desktop/views/components/analog-clock.vue b/src/server/web/app/desktop/views/components/analog-clock.vue new file mode 100644 index 0000000000..81eec81598 --- /dev/null +++ b/src/server/web/app/desktop/views/components/analog-clock.vue @@ -0,0 +1,108 @@ +<template> +<canvas class="mk-analog-clock" ref="canvas" width="256" height="256"></canvas> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { themeColor } from '../../../config'; + +const Vec2 = function(this: any, x, y) { + this.x = x; + this.y = y; +}; + +export default Vue.extend({ + data() { + return { + clock: null + }; + }, + mounted() { + this.tick(); + this.clock = setInterval(this.tick, 1000); + }, + beforeDestroy() { + clearInterval(this.clock); + }, + methods: { + tick() { + const canv = this.$refs.canvas as any; + + const now = new Date(); + const s = now.getSeconds(); + const m = now.getMinutes(); + const h = now.getHours(); + + const ctx = canv.getContext('2d'); + const canvW = canv.width; + const canvH = canv.height; + ctx.clearRect(0, 0, canvW, canvH); + + { // 背景 + const center = Math.min((canvW / 2), (canvH / 2)); + const lineStart = center * 0.90; + const shortLineEnd = center * 0.87; + const longLineEnd = center * 0.84; + for (let i = 0; i < 60; i++) { + const angle = Math.PI * i / 30; + const uv = new Vec2(Math.sin(angle), -Math.cos(angle)); + ctx.beginPath(); + ctx.lineWidth = 1; + ctx.moveTo((canvW / 2) + uv.x * lineStart, (canvH / 2) + uv.y * lineStart); + if (i % 5 == 0) { + ctx.strokeStyle = 'rgba(255, 255, 255, 0.2)'; + ctx.lineTo((canvW / 2) + uv.x * longLineEnd, (canvH / 2) + uv.y * longLineEnd); + } else { + ctx.strokeStyle = 'rgba(255, 255, 255, 0.1)'; + ctx.lineTo((canvW / 2) + uv.x * shortLineEnd, (canvH / 2) + uv.y * shortLineEnd); + } + ctx.stroke(); + } + } + + { // 分 + const angle = Math.PI * (m + s / 60) / 30; + const length = Math.min(canvW, canvH) / 2.6; + const uv = new Vec2(Math.sin(angle), -Math.cos(angle)); + ctx.beginPath(); + ctx.strokeStyle = '#ffffff'; + 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); + ctx.stroke(); + } + + { // 時 + const angle = Math.PI * (h % 12 + m / 60) / 6; + const length = Math.min(canvW, canvH) / 4; + const uv = new Vec2(Math.sin(angle), -Math.cos(angle)); + ctx.beginPath(); + ctx.strokeStyle = themeColor; + 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); + ctx.stroke(); + } + + { // 秒 + const angle = Math.PI * s / 30; + const length = Math.min(canvW, canvH) / 2.6; + const uv = new Vec2(Math.sin(angle), -Math.cos(angle)); + ctx.beginPath(); + ctx.strokeStyle = 'rgba(255, 255, 255, 0.5)'; + ctx.lineWidth = 1; + 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); + ctx.stroke(); + } + } + } +}); +</script> + +<style lang="stylus" scoped> +.mk-analog-clock + display block + width 256px + height 256px +</style> diff --git a/src/server/web/app/desktop/views/components/calendar.vue b/src/server/web/app/desktop/views/components/calendar.vue new file mode 100644 index 0000000000..71aab2e8a5 --- /dev/null +++ b/src/server/web/app/desktop/views/components/calendar.vue @@ -0,0 +1,252 @@ +<template> +<div class="mk-calendar" :data-melt="design == 4 || design == 5"> + <template v-if="design == 0 || design == 1"> + <button @click="prev" title="%i18n:desktop.tags.mk-calendar-widget.prev%">%fa:chevron-circle-left%</button> + <p class="title">{{ '%i18n:desktop.tags.mk-calendar-widget.title%'.replace('{1}', year).replace('{2}', month) }}</p> + <button @click="next" title="%i18n:desktop.tags.mk-calendar-widget.next%">%fa:chevron-circle-right%</button> + </template> + + <div class="calendar"> + <template v-if="design == 0 || design == 2 || design == 4"> + <div class="weekday" + v-for="(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> + </template> + <div v-for="n in paddingDays"></div> + <div class="day" v-for="(day, i) in days" + :data-today="isToday(i + 1)" + :data-selected="isSelected(i + 1)" + :data-is-out-of-range="isOutOfRange(i + 1)" + :data-is-donichi="isDonichi(i + 1)" + @click="go(i + 1)" + :title="isOutOfRange(i + 1) ? null : '%i18n:desktop.tags.mk-calendar-widget.go%'" + > + <div>{{ i + 1 }}</div> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +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; +} + +export default Vue.extend({ + props: { + design: { + default: 0 + }, + start: { + type: Date, + required: false + } + }, + data() { + return { + today: new Date(), + year: new Date().getFullYear(), + month: new Date().getMonth() + 1, + selected: new Date(), + 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%' + ] + }; + }, + computed: { + paddingDays(): number { + const date = new Date(this.year, this.month - 1, 1); + return date.getDay(); + }, + days(): number { + let days = eachMonthDays[this.month - 1]; + + // うるう年なら+1日 + if (this.month == 2 && isLeapYear(this.year)) days++; + + return days; + } + }, + methods: { + isToday(day) { + return this.year == this.today.getFullYear() && this.month == this.today.getMonth() + 1 && day == this.today.getDate(); + }, + + isSelected(day) { + return this.year == this.selected.getFullYear() && this.month == this.selected.getMonth() + 1 && day == this.selected.getDate(); + }, + + isOutOfRange(day) { + const test = (new Date(this.year, this.month - 1, day)).getTime(); + return test > this.today.getTime() || + (this.start ? test < (this.start as any).getTime() : false); + }, + + isDonichi(day) { + const weekday = (new Date(this.year, this.month - 1, day)).getDay(); + return weekday == 0 || weekday == 6; + }, + + prev() { + if (this.month == 1) { + this.year = this.year - 1; + this.month = 12; + } else { + this.month--; + } + }, + + next() { + if (this.month == 12) { + this.year = this.year + 1; + this.month = 1; + } else { + this.month++; + } + }, + + go(day) { + if (this.isOutOfRange(day)) return; + const date = new Date(this.year, this.month - 1, day, 23, 59, 59, 999); + this.selected = date; + this.$emit('chosen', this.selected); + } + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +.mk-calendar + 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) + + > [data-fa] + 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> diff --git a/src/server/web/app/desktop/views/components/choose-file-from-drive-window.vue b/src/server/web/app/desktop/views/components/choose-file-from-drive-window.vue new file mode 100644 index 0000000000..9a1e9c958a --- /dev/null +++ b/src/server/web/app/desktop/views/components/choose-file-from-drive-window.vue @@ -0,0 +1,180 @@ +<template> +<mk-window ref="window" is-modal width="800px" height="500px" @closed="$destroy"> + <span slot="header"> + <span v-html="title" :class="$style.title"></span> + <span :class="$style.count" v-if="multiple && files.length > 0">({{ files.length }}ファイル選択中)</span> + </span> + + <mk-drive + ref="browser" + :class="$style.browser" + :multiple="multiple" + @selected="onSelected" + @change-selection="onChangeSelection" + /> + <div :class="$style.footer"> + <button :class="$style.upload" title="PCからドライブにファイルをアップロード" @click="upload">%fa:upload%</button> + <button :class="$style.cancel" @click="cancel">キャンセル</button> + <button :class="$style.ok" :disabled="multiple && files.length == 0" @click="ok">決定</button> + </div> +</mk-window> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + props: { + multiple: { + default: false + }, + title: { + default: '%fa:R file%ファイルを選択' + } + }, + data() { + return { + files: [] + }; + }, + methods: { + onSelected(file) { + this.files = [file]; + this.ok(); + }, + onChangeSelection(files) { + this.files = files; + }, + upload() { + (this.$refs.browser as any).selectLocalFile(); + }, + ok() { + this.$emit('selected', this.multiple ? this.files : this.files[0]); + (this.$refs.window as any).close(); + }, + cancel() { + (this.$refs.window as any).close(); + } + } +}); +</script> + +<style lang="stylus" module> +@import '~const.styl' + +.title + > [data-fa] + margin-right 4px + +.count + margin-left 8px + opacity 0.7 + +.browser + height calc(100% - 72px) + +.footer + height 72px + background lighten($theme-color, 95%) + +.upload + display inline-block + position absolute + top 8px + left 16px + cursor pointer + padding 0 + margin 8px 4px 0 0 + width 40px + height 40px + font-size 1em + color rgba($theme-color, 0.5) + background transparent + outline none + border solid 1px transparent + border-radius 4px + + &:hover + background transparent + border-color rgba($theme-color, 0.3) + + &:active + color rgba($theme-color, 0.6) + background transparent + border-color rgba($theme-color, 0.5) + box-shadow 0 2px 4px rgba(darken($theme-color, 50%), 0.15) inset + + &:focus + &:after + content "" + pointer-events none + position absolute + top -5px + right -5px + bottom -5px + left -5px + border 2px solid rgba($theme-color, 0.3) + border-radius 8px + +.ok +.cancel + display block + position absolute + bottom 16px + cursor pointer + padding 0 + margin 0 + width 120px + height 40px + font-size 1em + outline none + border-radius 4px + + &:focus + &:after + content "" + pointer-events none + position absolute + top -5px + right -5px + bottom -5px + left -5px + border 2px solid rgba($theme-color, 0.3) + border-radius 8px + + &:disabled + opacity 0.7 + cursor default + +.ok + right 16px + color $theme-color-foreground + background linear-gradient(to bottom, lighten($theme-color, 25%) 0%, lighten($theme-color, 10%) 100%) + border solid 1px lighten($theme-color, 15%) + + &:not(:disabled) + font-weight bold + + &:hover:not(:disabled) + background linear-gradient(to bottom, lighten($theme-color, 8%) 0%, darken($theme-color, 8%) 100%) + border-color $theme-color + + &:active:not(:disabled) + background $theme-color + border-color $theme-color + +.cancel + right 148px + color #888 + background linear-gradient(to bottom, #ffffff 0%, #f5f5f5 100%) + border solid 1px #e2e2e2 + + &:hover + background linear-gradient(to bottom, #f9f9f9 0%, #ececec 100%) + border-color #dcdcdc + + &:active + background #ececec + border-color #dcdcdc + +</style> + diff --git a/src/server/web/app/desktop/views/components/choose-folder-from-drive-window.vue b/src/server/web/app/desktop/views/components/choose-folder-from-drive-window.vue new file mode 100644 index 0000000000..f99533176d --- /dev/null +++ b/src/server/web/app/desktop/views/components/choose-folder-from-drive-window.vue @@ -0,0 +1,114 @@ +<template> +<mk-window ref="window" is-modal width="800px" height="500px" @closed="$destroy"> + <span slot="header"> + <span v-html="title" :class="$style.title"></span> + </span> + + <mk-drive + ref="browser" + :class="$style.browser" + :multiple="false" + /> + <div :class="$style.footer"> + <button :class="$style.cancel" @click="cancel">キャンセル</button> + <button :class="$style.ok" @click="ok">決定</button> + </div> +</mk-window> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + props: { + title: { + default: '%fa:R folder%フォルダを選択' + } + }, + methods: { + ok() { + this.$emit('selected', (this.$refs.browser as any).folder); + (this.$refs.window as any).close(); + }, + cancel() { + (this.$refs.window as any).close(); + } + } +}); +</script> + +<style lang="stylus" module> +@import '~const.styl' + +.title + > [data-fa] + margin-right 4px + +.browser + height calc(100% - 72px) + +.footer + 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> diff --git a/src/server/web/app/desktop/views/components/context-menu.menu.vue b/src/server/web/app/desktop/views/components/context-menu.menu.vue new file mode 100644 index 0000000000..6359dbf1b4 --- /dev/null +++ b/src/server/web/app/desktop/views/components/context-menu.menu.vue @@ -0,0 +1,121 @@ +<template> +<ul class="menu"> + <li v-for="(item, i) in menu" :class="item.type"> + <template v-if="item.type == 'item'"> + <p @click="click(item)"><span :class="$style.icon" v-if="item.icon" v-html="item.icon"></span>{{ item.text }}</p> + </template> + <template v-if="item.type == 'link'"> + <a :href="item.href" :target="item.target" @click="click(item)"><span :class="$style.icon" v-if="item.icon" v-html="item.icon"></span>{{ item.text }}</a> + </template> + <template v-else-if="item.type == 'nest'"> + <p><span :class="$style.icon" v-if="item.icon" v-html="item.icon"></span>{{ item.text }}...<span class="caret">%fa:caret-right%</span></p> + <me-nu :menu="item.menu" @x="click"/> + </template> + </li> +</ul> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + name: 'me-nu', + props: ['menu'], + methods: { + click(item) { + this.$emit('x', item); + } + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +.menu + $width = 240px + $item-height = 38px + $padding = 10px + + margin 0 + padding $padding 0 + list-style none + + li + display block + + &.divider + margin-top $padding + padding-top $padding + border-top solid 1px #eee + + &.nest + > p + cursor default + + > .caret + position absolute + top 0 + right 8px + + > * + line-height $item-height + width 28px + text-align center + + &:hover > ul + visibility visible + + &:active + > p, a + background $theme-color + + > p, a + display block + z-index 1 + margin 0 + padding 0 32px 0 38px + line-height $item-height + color #868C8C + text-decoration none + cursor pointer + + &:hover + text-decoration none + + * + pointer-events none + + &:hover + > p, a + text-decoration none + background $theme-color + color $theme-color-foreground + + &:active + > p, a + text-decoration none + background darken($theme-color, 10%) + color $theme-color-foreground + + li > ul + visibility hidden + position absolute + top 0 + left $width + margin-top -($padding) + width $width + background #fff + border-radius 0 4px 4px 4px + box-shadow 2px 2px 8px rgba(0, 0, 0, 0.2) + transition visibility 0s linear 0.2s + +</style> + +<style lang="stylus" module> +.icon + > * + width 28px + margin-left -28px + text-align center +</style> + diff --git a/src/server/web/app/desktop/views/components/context-menu.vue b/src/server/web/app/desktop/views/components/context-menu.vue new file mode 100644 index 0000000000..8bd9945840 --- /dev/null +++ b/src/server/web/app/desktop/views/components/context-menu.vue @@ -0,0 +1,74 @@ +<template> +<div class="context-menu" :style="{ left: `${x}px`, top: `${y}px` }" @contextmenu.prevent="() => {}"> + <x-menu :menu="menu" @x="click"/> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import * as anime from 'animejs'; +import contains from '../../../common/scripts/contains'; +import XMenu from './context-menu.menu.vue'; + +export default Vue.extend({ + components: { + XMenu + }, + props: ['x', 'y', 'menu'], + mounted() { + this.$nextTick(() => { + Array.from(document.querySelectorAll('body *')).forEach(el => { + el.addEventListener('mousedown', this.onMousedown); + }); + + this.$el.style.display = 'block'; + + anime({ + targets: this.$el, + opacity: [0, 1], + duration: 100, + easing: 'linear' + }); + }); + }, + methods: { + onMousedown(e) { + e.preventDefault(); + if (!contains(this.$el, e.target) && (this.$el != e.target)) this.close(); + return false; + }, + click(item) { + if (item.onClick) item.onClick(); + this.close(); + }, + close() { + Array.from(document.querySelectorAll('body *')).forEach(el => { + el.removeEventListener('mousedown', this.onMousedown); + }); + + this.$emit('closed'); + this.$destroy(); + } + } +}); +</script> + +<style lang="stylus" scoped> +.context-menu + $width = 240px + $item-height = 38px + $padding = 10px + + display none + position fixed + top 0 + left 0 + z-index 4096 + width $width + font-size 0.8em + background #fff + border-radius 0 4px 4px 4px + box-shadow 2px 2px 8px rgba(0, 0, 0, 0.2) + opacity 0 + +</style> diff --git a/src/server/web/app/desktop/views/components/crop-window.vue b/src/server/web/app/desktop/views/components/crop-window.vue new file mode 100644 index 0000000000..eb6a55d959 --- /dev/null +++ b/src/server/web/app/desktop/views/components/crop-window.vue @@ -0,0 +1,178 @@ +<template> + <mk-window ref="window" is-modal width="800px" :can-close="false"> + <span slot="header">%fa:crop%{{ title }}</span> + <div class="body"> + <vue-cropper ref="cropper" + :src="image.url" + :view-mode="1" + :aspect-ratio="aspectRatio" + :container-style="{ width: '100%', 'max-height': '400px' }" + /> + </div> + <div :class="$style.actions"> + <button :class="$style.skip" @click="skip">クロップをスキップ</button> + <button :class="$style.cancel" @click="cancel">キャンセル</button> + <button :class="$style.ok" @click="ok">決定</button> + </div> + </mk-window> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import VueCropper from 'vue-cropperjs'; + +export default Vue.extend({ + components: { + VueCropper + }, + props: { + image: { + type: Object, + required: true + }, + title: { + type: String, + required: true + }, + aspectRatio: { + type: Number, + required: true + } + }, + methods: { + ok() { + (this.$refs.cropper as any).getCroppedCanvas().toBlob(blob => { + this.$emit('cropped', blob); + (this.$refs.window as any).close(); + }); + }, + + skip() { + this.$emit('skipped'); + (this.$refs.window as any).close(); + }, + + cancel() { + this.$emit('canceled'); + (this.$refs.window as any).close(); + } + } +}); +</script> + +<style lang="stylus" module> +@import '~const.styl' + +.header + > [data-fa] + margin-right 4px + +.img + width 100% + max-height 400px + +.actions + height 72px + background lighten($theme-color, 95%) + +.ok +.cancel +.skip + display block + position absolute + bottom 16px + cursor pointer + padding 0 + margin 0 + 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 +.cancel + width 120px + +.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 +.skip + 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 + +.cancel + right 148px + +.skip + left 16px + width 150px + +</style> + +<style lang="stylus"> +.cropper-modal { + opacity: 0.8; +} + +.cropper-view-box { + outline-color: $theme-color; +} + +.cropper-line, .cropper-point { + background-color: $theme-color; +} + +.cropper-bg { + animation: cropper-bg 0.5s linear infinite; +} + +@keyframes cropper-bg { + 0% { + background-position: 0 0; + } + + 100% { + background-position: -8px -8px; + } +} +</style> diff --git a/src/server/web/app/desktop/views/components/dialog.vue b/src/server/web/app/desktop/views/components/dialog.vue new file mode 100644 index 0000000000..fa17e4a9d2 --- /dev/null +++ b/src/server/web/app/desktop/views/components/dialog.vue @@ -0,0 +1,170 @@ +<template> +<div class="mk-dialog"> + <div class="bg" ref="bg" @click="onBgClick"></div> + <div class="main" ref="main"> + <header v-html="title" :class="$style.header"></header> + <div class="body" v-html="text"></div> + <div class="buttons"> + <button v-for="button in buttons" @click="click(button)">{{ button.text }}</button> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import * as anime from 'animejs'; + +export default Vue.extend({ + props: { + title: { + type: String, + required: false + }, + text: { + type: String, + required: true + }, + buttons: { + type: Array, + default: () => { + return [{ + text: 'OK' + }]; + } + }, + modal: { + type: Boolean, + default: false + } + }, + mounted() { + this.$nextTick(() => { + (this.$refs.bg as any).style.pointerEvents = 'auto'; + anime({ + targets: this.$refs.bg, + opacity: 1, + duration: 100, + easing: 'linear' + }); + + anime({ + targets: this.$refs.main, + opacity: 1, + scale: [1.2, 1], + duration: 300, + easing: [0, 0.5, 0.5, 1] + }); + }); + }, + methods: { + click(button) { + this.$emit('clicked', button.id); + this.close(); + }, + close() { + (this.$refs.bg as any).style.pointerEvents = 'none'; + anime({ + targets: this.$refs.bg, + opacity: 0, + duration: 300, + easing: 'linear' + }); + + (this.$refs.main as any).style.pointerEvents = 'none'; + anime({ + targets: this.$refs.main, + opacity: 0, + scale: 0.8, + duration: 300, + easing: [ 0.5, -0.5, 1, 0.5 ], + complete: () => this.$destroy() + }); + }, + onBgClick() { + if (!this.modal) { + this.close(); + } + } + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +.mk-dialog + > .bg + display block + position fixed + z-index 8192 + top 0 + left 0 + width 100% + height 100% + background rgba(0, 0, 0, 0.7) + opacity 0 + pointer-events none + + > .main + display block + position fixed + z-index 8192 + top 20% + left 0 + right 0 + margin 0 auto 0 auto + padding 32px 42px + width 480px + background #fff + opacity 0 + + > .body + margin 1em 0 + color #888 + + > .buttons + > button + display inline-block + float right + margin 0 + padding 10px 10px + font-size 1.1em + font-weight normal + text-decoration none + color #888 + background transparent + outline none + border none + border-radius 0 + cursor pointer + transition color 0.1s ease + + i + margin 0 0.375em + + &:hover + color $theme-color + + &:active + color darken($theme-color, 10%) + transition color 0s ease + +</style> + +<style lang="stylus" module> +@import '~const.styl' + +.header + margin 1em 0 + color $theme-color + // color #43A4EC + font-weight bold + + &:empty + display none + + > i + margin-right 0.5em + +</style> diff --git a/src/server/web/app/desktop/views/components/drive-window.vue b/src/server/web/app/desktop/views/components/drive-window.vue new file mode 100644 index 0000000000..3a072f4794 --- /dev/null +++ b/src/server/web/app/desktop/views/components/drive-window.vue @@ -0,0 +1,56 @@ +<template> +<mk-window ref="window" @closed="$destroy" width="800px" height="500px" :popout-url="popout"> + <template slot="header"> + <p v-if="usage" :class="$style.info"><b>{{ usage.toFixed(1) }}%</b> %i18n:desktop.tags.mk-drive-browser-window.used%</p> + <span :class="$style.title">%fa:cloud%%i18n:desktop.tags.mk-drive-browser-window.drive%</span> + </template> + <mk-drive :class="$style.browser" multiple :init-folder="folder" ref="browser"/> +</mk-window> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { url } from '../../../config'; + +export default Vue.extend({ + props: ['folder'], + data() { + return { + usage: null + }; + }, + mounted() { + (this as any).api('drive').then(info => { + this.usage = info.usage / info.capacity * 100; + }); + }, + methods: { + popout() { + const folder = (this.$refs.browser as any) ? (this.$refs.browser as any).folder : null; + if (folder) { + return `${url}/i/drive/folder/${folder.id}`; + } else { + return `${url}/i/drive`; + } + } + } +}); +</script> + +<style lang="stylus" module> +.title + > [data-fa] + margin-right 4px + +.info + position absolute + top 0 + left 16px + margin 0 + font-size 80% + +.browser + height 100% + +</style> + diff --git a/src/server/web/app/desktop/views/components/drive.file.vue b/src/server/web/app/desktop/views/components/drive.file.vue new file mode 100644 index 0000000000..924ff7052d --- /dev/null +++ b/src/server/web/app/desktop/views/components/drive.file.vue @@ -0,0 +1,317 @@ +<template> +<div class="root file" + :data-is-selected="isSelected" + :data-is-contextmenu-showing="isContextmenuShowing" + @click="onClick" + draggable="true" + @dragstart="onDragstart" + @dragend="onDragend" + @contextmenu.prevent.stop="onContextmenu" + :title="title" +> + <div class="label" v-if="os.i.avatar_id == file.id"><img src="/assets/label.svg"/> + <p>%i18n:desktop.tags.mk-drive-browser-file.avatar%</p> + </div> + <div class="label" v-if="os.i.banner_id == file.id"><img src="/assets/label.svg"/> + <p>%i18n:desktop.tags.mk-drive-browser-file.banner%</p> + </div> + <div class="thumbnail" ref="thumbnail" :style="`background-color: ${ background }`"> + <img :src="`${file.url}?thumbnail&size=128`" alt="" @load="onThumbnailLoaded"/> + </div> + <p class="name"> + <span>{{ file.name.lastIndexOf('.') != -1 ? file.name.substr(0, file.name.lastIndexOf('.')) : file.name }}</span> + <span class="ext" v-if="file.name.lastIndexOf('.') != -1">{{ file.name.substr(file.name.lastIndexOf('.')) }}</span> + </p> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import * as anime from 'animejs'; +import contextmenu from '../../api/contextmenu'; +import copyToClipboard from '../../../common/scripts/copy-to-clipboard'; + +export default Vue.extend({ + props: ['file'], + data() { + return { + isContextmenuShowing: false, + isDragging: false + }; + }, + computed: { + browser(): any { + return this.$parent; + }, + isSelected(): boolean { + return this.browser.selectedFiles.some(f => f.id == this.file.id); + }, + title(): string { + return `${this.file.name}\n${this.file.type} ${Vue.filter('bytes')(this.file.datasize)}`; + }, + background(): string { + return this.file.properties.average_color + ? `rgb(${this.file.properties.average_color.join(',')})` + : 'transparent'; + } + }, + methods: { + onClick() { + this.browser.chooseFile(this.file); + }, + + onContextmenu(e) { + this.isContextmenuShowing = true; + contextmenu(e, [{ + type: 'item', + text: '%i18n:desktop.tags.mk-drive-browser-file-contextmenu.rename%', + icon: '%fa:i-cursor%', + onClick: this.rename + }, { + type: 'item', + text: '%i18n:desktop.tags.mk-drive-browser-file-contextmenu.copy-url%', + icon: '%fa:link%', + onClick: this.copyUrl + }, { + type: 'link', + href: `${this.file.url}?download`, + text: '%i18n:desktop.tags.mk-drive-browser-file-contextmenu.download%', + icon: '%fa:download%', + }, { + type: 'divider', + }, { + type: 'item', + text: '%i18n:common.delete%', + icon: '%fa:R trash-alt%', + onClick: this.deleteFile + }, { + type: 'divider', + }, { + type: 'nest', + text: '%i18n:desktop.tags.mk-drive-browser-file-contextmenu.else-files%', + menu: [{ + type: 'item', + text: '%i18n:desktop.tags.mk-drive-browser-file-contextmenu.set-as-avatar%', + onClick: this.setAsAvatar + }, { + type: 'item', + text: '%i18n:desktop.tags.mk-drive-browser-file-contextmenu.set-as-banner%', + onClick: this.setAsBanner + }] + }, { + type: 'nest', + text: '%i18n:desktop.tags.mk-drive-browser-file-contextmenu.open-in-app%', + menu: [{ + type: 'item', + text: '%i18n:desktop.tags.mk-drive-browser-file-contextmenu.add-app%...', + onClick: this.addApp + }] + }], { + closed: () => { + this.isContextmenuShowing = false; + } + }); + }, + + onDragstart(e) { + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('mk_drive_file', JSON.stringify(this.file)); + this.isDragging = true; + + // 親ブラウザに対して、ドラッグが開始されたフラグを立てる + // (=あなたの子供が、ドラッグを開始しましたよ) + this.browser.isDragSource = true; + }, + + onDragend(e) { + this.isDragging = false; + this.browser.isDragSource = false; + }, + + onThumbnailLoaded() { + if (this.file.properties.average_color) { + anime({ + targets: this.$refs.thumbnail, + backgroundColor: `rgba(${this.file.properties.average_color.join(',')}, 0)`, + duration: 100, + easing: 'linear' + }); + } + }, + + rename() { + (this as any).apis.input({ + title: '%i18n:desktop.tags.mk-drive-browser-file-contextmenu.rename-file%', + placeholder: '%i18n:desktop.tags.mk-drive-browser-file-contextmenu.input-new-file-name%', + default: this.file.name, + allowEmpty: false + }).then(name => { + (this as any).api('drive/files/update', { + file_id: this.file.id, + name: name + }) + }); + }, + + copyUrl() { + copyToClipboard(this.file.url); + (this as any).apis.dialog({ + title: '%fa:check%%i18n:desktop.tags.mk-drive-browser-file-contextmenu.copied%', + text: '%i18n:desktop.tags.mk-drive-browser-file-contextmenu.copied-url-to-clipboard%', + actions: [{ + text: '%i18n:common.ok%' + }] + }); + }, + + setAsAvatar() { + (this as any).apis.updateAvatar(this.file); + }, + + setAsBanner() { + (this as any).apis.updateBanner(this.file); + }, + + addApp() { + alert('not implemented yet'); + }, + + deleteFile() { + alert('not implemented yet'); + } + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +.root.file + padding 8px 0 0 0 + height 180px + border-radius 4px + + &, * + cursor pointer + + &:hover + background rgba(0, 0, 0, 0.05) + + > .label + &:before + &:after + background #0b65a5 + + &:active + background rgba(0, 0, 0, 0.1) + + > .label + &:before + &:after + background #0b588c + + &[data-is-selected] + background $theme-color + + &:hover + background lighten($theme-color, 10%) + + &:active + background darken($theme-color, 10%) + + > .label + &:before + &:after + display none + + > .name + color $theme-color-foreground + + &[data-is-contextmenu-showing] + &:after + content "" + pointer-events none + position absolute + top -4px + right -4px + bottom -4px + left -4px + border 2px dashed rgba($theme-color, 0.3) + border-radius 4px + + > .label + position absolute + top 0 + left 0 + pointer-events none + + &:before + content "" + display block + position absolute + z-index 1 + top 0 + left 57px + width 28px + height 8px + background #0c7ac9 + + &:after + content "" + display block + position absolute + z-index 1 + top 57px + left 0 + width 8px + height 28px + background #0c7ac9 + + > img + position absolute + z-index 2 + top 0 + left 0 + + > p + position absolute + z-index 3 + top 19px + left -28px + width 120px + margin 0 + text-align center + line-height 28px + color #fff + transform rotate(-45deg) + + > .thumbnail + width 128px + height 128px + margin auto + + > img + display block + position absolute + top 0 + left 0 + right 0 + bottom 0 + margin auto + max-width 128px + max-height 128px + pointer-events none + + > .name + display block + margin 4px 0 0 0 + font-size 0.8em + text-align center + word-break break-all + color #444 + overflow hidden + + > .ext + opacity 0.5 + +</style> diff --git a/src/server/web/app/desktop/views/components/drive.folder.vue b/src/server/web/app/desktop/views/components/drive.folder.vue new file mode 100644 index 0000000000..a8a9a01370 --- /dev/null +++ b/src/server/web/app/desktop/views/components/drive.folder.vue @@ -0,0 +1,267 @@ +<template> +<div class="root folder" + :data-is-contextmenu-showing="isContextmenuShowing" + :data-draghover="draghover" + @click="onClick" + @mouseover="onMouseover" + @mouseout="onMouseout" + @dragover.prevent.stop="onDragover" + @dragenter.prevent="onDragenter" + @dragleave="onDragleave" + @drop.prevent.stop="onDrop" + draggable="true" + @dragstart="onDragstart" + @dragend="onDragend" + @contextmenu.prevent.stop="onContextmenu" + :title="title" +> + <p class="name"> + <template v-if="hover">%fa:R folder-open .fw%</template> + <template v-if="!hover">%fa:R folder .fw%</template> + {{ folder.name }} + </p> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import contextmenu from '../../api/contextmenu'; + +export default Vue.extend({ + props: ['folder'], + data() { + return { + hover: false, + draghover: false, + isDragging: false, + isContextmenuShowing: false + }; + }, + computed: { + browser(): any { + return this.$parent; + }, + title(): string { + return this.folder.name; + } + }, + methods: { + onClick() { + this.browser.move(this.folder); + }, + + onContextmenu(e) { + this.isContextmenuShowing = true; + contextmenu(e, [{ + type: 'item', + text: '%i18n:desktop.tags.mk-drive-browser-folder-contextmenu.move-to-this-folder%', + icon: '%fa:arrow-right%', + onClick: this.go + }, { + type: 'item', + text: '%i18n:desktop.tags.mk-drive-browser-folder-contextmenu.show-in-new-window%', + icon: '%fa:R window-restore%', + onClick: this.newWindow + }, { + type: 'divider', + }, { + type: 'item', + text: '%i18n:desktop.tags.mk-drive-browser-folder-contextmenu.rename%', + icon: '%fa:i-cursor%', + onClick: this.rename + }, { + type: 'divider', + }, { + type: 'item', + text: '%i18n:common.delete%', + icon: '%fa:R trash-alt%', + onClick: this.deleteFolder + }], { + closed: () => { + this.isContextmenuShowing = false; + } + }); + }, + + onMouseover() { + this.hover = true; + }, + + onMouseout() { + this.hover = false + }, + + onDragover(e) { + // 自分自身がドラッグされている場合 + if (this.isDragging) { + // 自分自身にはドロップさせない + e.dataTransfer.dropEffect = 'none'; + return; + } + + const isFile = e.dataTransfer.items[0].kind == 'file'; + const isDriveFile = e.dataTransfer.types[0] == 'mk_drive_file'; + const isDriveFolder = e.dataTransfer.types[0] == 'mk_drive_folder'; + + if (isFile || isDriveFile || isDriveFolder) { + e.dataTransfer.dropEffect = e.dataTransfer.effectAllowed == 'all' ? 'copy' : 'move'; + } else { + e.dataTransfer.dropEffect = 'none'; + } + }, + + onDragenter() { + if (!this.isDragging) this.draghover = true; + }, + + onDragleave() { + this.draghover = false; + }, + + onDrop(e) { + this.draghover = false; + + // ファイルだったら + if (e.dataTransfer.files.length > 0) { + Array.from(e.dataTransfer.files).forEach(file => { + this.browser.upload(file, this.folder); + }); + return; + } + + //#region ドライブのファイル + const driveFile = e.dataTransfer.getData('mk_drive_file'); + if (driveFile != null && driveFile != '') { + const file = JSON.parse(driveFile); + this.browser.removeFile(file.id); + (this as any).api('drive/files/update', { + file_id: file.id, + folder_id: this.folder.id + }); + } + //#endregion + + //#region ドライブのフォルダ + const driveFolder = e.dataTransfer.getData('mk_drive_folder'); + if (driveFolder != null && driveFolder != '') { + const folder = JSON.parse(driveFolder); + + // 移動先が自分自身ならreject + if (folder.id == this.folder.id) return; + + this.browser.removeFolder(folder.id); + (this as any).api('drive/folders/update', { + folder_id: folder.id, + parent_id: this.folder.id + }).then(() => { + // noop + }).catch(err => { + switch (err) { + case 'detected-circular-definition': + (this as any).apis.dialog({ + title: '%fa:exclamation-triangle%%i18n:desktop.tags.mk-drive-browser-folder.unable-to-process%', + text: '%i18n:desktop.tags.mk-drive-browser-folder.circular-reference-detected%', + actions: [{ + text: '%i18n:common.ok%' + }] + }); + break; + default: + alert('%i18n:desktop.tags.mk-drive-browser-folder.unhandled-error% ' + err); + } + }); + } + //#endregion + }, + + onDragstart(e) { + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('mk_drive_folder', JSON.stringify(this.folder)); + this.isDragging = true; + + // 親ブラウザに対して、ドラッグが開始されたフラグを立てる + // (=あなたの子供が、ドラッグを開始しましたよ) + this.browser.isDragSource = true; + }, + + onDragend() { + this.isDragging = false; + this.browser.isDragSource = false; + }, + + go() { + this.browser.move(this.folder.id); + }, + + newWindow() { + this.browser.newWindow(this.folder); + }, + + rename() { + (this as any).apis.input({ + title: '%i18n:desktop.tags.mk-drive-browser-folder-contextmenu.rename-folder%', + placeholder: '%i18n:desktop.tags.mk-drive-browser-folder-contextmenu.input-new-folder-name%', + default: this.folder.name + }).then(name => { + (this as any).api('drive/folders/update', { + folder_id: this.folder.id, + name: name + }); + }); + }, + + deleteFolder() { + alert('not implemented yet'); + } + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +.root.folder + padding 8px + height 64px + background lighten($theme-color, 95%) + border-radius 4px + + &, * + cursor pointer + + * + pointer-events none + + &:hover + background lighten($theme-color, 90%) + + &:active + background lighten($theme-color, 85%) + + &[data-is-contextmenu-showing] + &[data-draghover] + &:after + content "" + pointer-events none + position absolute + top -4px + right -4px + bottom -4px + left -4px + border 2px dashed rgba($theme-color, 0.3) + border-radius 4px + + &[data-draghover] + background lighten($theme-color, 90%) + + > .name + margin 0 + font-size 0.9em + color darken($theme-color, 30%) + + > [data-fa] + margin-right 4px + margin-left 2px + text-align left + +</style> diff --git a/src/server/web/app/desktop/views/components/drive.nav-folder.vue b/src/server/web/app/desktop/views/components/drive.nav-folder.vue new file mode 100644 index 0000000000..dfbf116bff --- /dev/null +++ b/src/server/web/app/desktop/views/components/drive.nav-folder.vue @@ -0,0 +1,113 @@ +<template> +<div class="root nav-folder" + :data-draghover="draghover" + @click="onClick" + @dragover.prevent.stop="onDragover" + @dragenter="onDragenter" + @dragleave="onDragleave" + @drop.stop="onDrop" +> + <template v-if="folder == null">%fa:cloud%</template> + <span>{{ folder == null ? '%i18n:desktop.tags.mk-drive-browser-nav-folder.drive%' : folder.name }}</span> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + props: ['folder'], + data() { + return { + hover: false, + draghover: false + }; + }, + computed: { + browser(): any { + return this.$parent; + } + }, + methods: { + onClick() { + this.browser.move(this.folder); + }, + onMouseover() { + this.hover = true; + }, + onMouseout() { + this.hover = false; + }, + onDragover(e) { + // このフォルダがルートかつカレントディレクトリならドロップ禁止 + if (this.folder == null && this.browser.folder == null) { + e.dataTransfer.dropEffect = 'none'; + } + + const isFile = e.dataTransfer.items[0].kind == 'file'; + const isDriveFile = e.dataTransfer.types[0] == 'mk_drive_file'; + const isDriveFolder = e.dataTransfer.types[0] == 'mk_drive_folder'; + + if (isFile || isDriveFile || isDriveFolder) { + e.dataTransfer.dropEffect = e.dataTransfer.effectAllowed == 'all' ? 'copy' : 'move'; + } else { + e.dataTransfer.dropEffect = 'none'; + } + + return false; + }, + onDragenter() { + if (this.folder || this.browser.folder) this.draghover = true; + }, + onDragleave() { + if (this.folder || this.browser.folder) this.draghover = false; + }, + onDrop(e) { + this.draghover = false; + + // ファイルだったら + if (e.dataTransfer.files.length > 0) { + Array.from(e.dataTransfer.files).forEach(file => { + this.browser.upload(file, this.folder); + }); + return; + } + + //#region ドライブのファイル + const driveFile = e.dataTransfer.getData('mk_drive_file'); + if (driveFile != null && driveFile != '') { + const file = JSON.parse(driveFile); + this.browser.removeFile(file.id); + (this as any).api('drive/files/update', { + file_id: file.id, + folder_id: this.folder ? this.folder.id : null + }); + } + //#endregion + + //#region ドライブのフォルダ + const driveFolder = e.dataTransfer.getData('mk_drive_folder'); + if (driveFolder != null && driveFolder != '') { + const folder = JSON.parse(driveFolder); + // 移動先が自分自身ならreject + if (this.folder && folder.id == this.folder.id) return; + this.browser.removeFolder(folder.id); + (this as any).api('drive/folders/update', { + folder_id: folder.id, + parent_id: this.folder ? this.folder.id : null + }); + } + //#endregion + } + } +}); +</script> + +<style lang="stylus" scoped> +.root.nav-folder + > * + pointer-events none + + &[data-draghover] + background #eee + +</style> diff --git a/src/server/web/app/desktop/views/components/drive.vue b/src/server/web/app/desktop/views/components/drive.vue new file mode 100644 index 0000000000..0fafa8cf23 --- /dev/null +++ b/src/server/web/app/desktop/views/components/drive.vue @@ -0,0 +1,773 @@ +<template> +<div class="mk-drive"> + <nav> + <div class="path" @contextmenu.prevent.stop="() => {}"> + <x-nav-folder :class="{ current: folder == null }"/> + <template v-for="folder in hierarchyFolders"> + <span class="separator">%fa:angle-right%</span> + <x-nav-folder :folder="folder" :key="folder.id"/> + </template> + <span class="separator" v-if="folder != null">%fa:angle-right%</span> + <span class="folder current" v-if="folder != null">{{ folder.name }}</span> + </div> + <input class="search" type="search" placeholder=" %i18n:desktop.tags.mk-drive-browser.search%"/> + </nav> + <div class="main" :class="{ uploading: uploadings.length > 0, fetching }" + ref="main" + @mousedown="onMousedown" + @dragover.prevent.stop="onDragover" + @dragenter="onDragenter" + @dragleave="onDragleave" + @drop.prevent.stop="onDrop" + @contextmenu.prevent.stop="onContextmenu" + > + <div class="selection" ref="selection"></div> + <div class="contents" ref="contents"> + <div class="folders" ref="foldersContainer" v-if="folders.length > 0"> + <x-folder v-for="folder in folders" :key="folder.id" class="folder" :folder="folder"/> + <!-- SEE: https://stackoverflow.com/questions/18744164/flex-box-align-last-row-to-grid --> + <div class="padding" v-for="n in 16"></div> + <button v-if="moreFolders">%i18n:desktop.tags.mk-drive-browser.load-more%</button> + </div> + <div class="files" ref="filesContainer" v-if="files.length > 0"> + <x-file v-for="file in files" :key="file.id" class="file" :file="file"/> + <!-- SEE: https://stackoverflow.com/questions/18744164/flex-box-align-last-row-to-grid --> + <div class="padding" v-for="n in 16"></div> + <button v-if="moreFiles" @click="fetchMoreFiles">%i18n:desktop.tags.mk-drive-browser.load-more%</button> + </div> + <div class="empty" v-if="files.length == 0 && folders.length == 0 && !fetching"> + <p v-if="draghover">%i18n:desktop.tags.mk-drive-browser.empty-draghover%</p> + <p v-if="!draghover && folder == null"><strong>%i18n:desktop.tags.mk-drive-browser.empty-drive%</strong><br/>%i18n:desktop.tags.mk-drive-browser.empty-drive-description%</p> + <p v-if="!draghover && folder != null">%i18n:desktop.tags.mk-drive-browser.empty-folder%</p> + </div> + </div> + <div class="fetching" v-if="fetching"> + <div class="spinner"> + <div class="dot1"></div> + <div class="dot2"></div> + </div> + </div> + </div> + <div class="dropzone" v-if="draghover"></div> + <mk-uploader ref="uploader" @change="onChangeUploaderUploads" @uploaded="onUploaderUploaded"/> + <input ref="fileInput" type="file" accept="*/*" multiple="multiple" tabindex="-1" @change="onChangeFileInput"/> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import MkDriveWindow from './drive-window.vue'; +import XNavFolder from './drive.nav-folder.vue'; +import XFolder from './drive.folder.vue'; +import XFile from './drive.file.vue'; +import contains from '../../../common/scripts/contains'; +import contextmenu from '../../api/contextmenu'; +import { url } from '../../../config'; + +export default Vue.extend({ + components: { + XNavFolder, + XFolder, + XFile + }, + props: { + initFolder: { + type: Object, + required: false + }, + multiple: { + type: Boolean, + default: false + } + }, + data() { + return { + /** + * 現在の階層(フォルダ) + * * null でルートを表す + */ + folder: null, + + files: [], + folders: [], + moreFiles: false, + moreFolders: false, + hierarchyFolders: [], + selectedFiles: [], + uploadings: [], + connection: null, + connectionId: null, + + /** + * ドロップされようとしているか + */ + draghover: false, + + /** + * 自信の所有するアイテムがドラッグをスタートさせたか + * (自分自身の階層にドロップできないようにするためのフラグ) + */ + isDragSource: false, + + fetching: true + }; + }, + mounted() { + this.connection = (this as any).os.streams.driveStream.getConnection(); + this.connectionId = (this as any).os.streams.driveStream.use(); + + 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.initFolder) { + this.move(this.initFolder); + } else { + this.fetch(); + } + }, + beforeDestroy() { + 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 as any).os.streams.driveStream.dispose(this.connectionId); + }, + methods: { + onContextmenu(e) { + contextmenu(e, [{ + type: 'item', + text: '%i18n:desktop.tags.mk-drive-browser-base-contextmenu.create-folder%', + icon: '%fa:R folder%', + onClick: this.createFolder + }, { + type: 'item', + text: '%i18n:desktop.tags.mk-drive-browser-base-contextmenu.upload%', + icon: '%fa:upload%', + onClick: this.selectLocalFile + }, { + type: 'item', + text: '%i18n:desktop.tags.mk-drive-browser-base-contextmenu.url-upload%', + icon: '%fa:cloud-upload-alt%', + onClick: this.urlUpload + }]); + }, + + onStreamDriveFileCreated(file) { + this.addFile(file, true); + }, + + onStreamDriveFileUpdated(file) { + const current = this.folder ? this.folder.id : null; + if (current != file.folder_id) { + this.removeFile(file); + } else { + this.addFile(file, true); + } + }, + + onStreamDriveFolderCreated(folder) { + this.addFolder(folder, true); + }, + + onStreamDriveFolderUpdated(folder) { + const current = this.folder ? this.folder.id : null; + if (current != folder.parent_id) { + this.removeFolder(folder); + } else { + this.addFolder(folder, true); + } + }, + + onChangeUploaderUploads(uploads) { + this.uploadings = uploads; + }, + + onUploaderUploaded(file) { + this.addFile(file, true); + }, + + onMousedown(e): any { + if (contains(this.$refs.foldersContainer, e.target) || contains(this.$refs.filesContainer, e.target)) return true; + + const main = this.$refs.main as any; + const selection = this.$refs.selection as any; + + const rect = main.getBoundingClientRect(); + + const left = e.pageX + main.scrollLeft - rect.left - window.pageXOffset + const top = e.pageY + main.scrollTop - rect.top - window.pageYOffset + + const move = e => { + selection.style.display = 'block'; + + const cursorX = e.pageX + main.scrollLeft - rect.left - window.pageXOffset; + const cursorY = e.pageY + main.scrollTop - rect.top - window.pageYOffset; + const w = cursorX - left; + const h = cursorY - top; + + if (w > 0) { + selection.style.width = w + 'px'; + selection.style.left = left + 'px'; + } else { + selection.style.width = -w + 'px'; + selection.style.left = cursorX + 'px'; + } + + if (h > 0) { + selection.style.height = h + 'px'; + selection.style.top = top + 'px'; + } else { + selection.style.height = -h + 'px'; + selection.style.top = cursorY + 'px'; + } + }; + + const up = e => { + document.documentElement.removeEventListener('mousemove', move); + document.documentElement.removeEventListener('mouseup', up); + + selection.style.display = 'none'; + }; + + document.documentElement.addEventListener('mousemove', move); + document.documentElement.addEventListener('mouseup', up); + }, + + onDragover(e): any { + // ドラッグ元が自分自身の所有するアイテムだったら + if (this.isDragSource) { + // 自分自身にはドロップさせない + e.dataTransfer.dropEffect = 'none'; + return; + } + + const isFile = e.dataTransfer.items[0].kind == 'file'; + const isDriveFile = e.dataTransfer.types[0] == 'mk_drive_file'; + const isDriveFolder = e.dataTransfer.types[0] == 'mk_drive_folder'; + + if (isFile || isDriveFile || isDriveFolder) { + e.dataTransfer.dropEffect = e.dataTransfer.effectAllowed == 'all' ? 'copy' : 'move'; + } else { + e.dataTransfer.dropEffect = 'none'; + } + + return false; + }, + + onDragenter(e) { + if (!this.isDragSource) this.draghover = true; + }, + + onDragleave(e) { + this.draghover = false; + }, + + onDrop(e): any { + this.draghover = false; + + // ドロップされてきたものがファイルだったら + if (e.dataTransfer.files.length > 0) { + Array.from(e.dataTransfer.files).forEach(file => { + this.upload(file, this.folder); + }); + return; + } + + //#region ドライブのファイル + const driveFile = e.dataTransfer.getData('mk_drive_file'); + if (driveFile != null && driveFile != '') { + const file = JSON.parse(driveFile); + if (this.files.some(f => f.id == file.id)) return; + this.removeFile(file.id); + (this as any).api('drive/files/update', { + file_id: file.id, + folder_id: this.folder ? this.folder.id : null + }); + } + //#endregion + + //#region ドライブのフォルダ + const driveFolder = e.dataTransfer.getData('mk_drive_folder'); + if (driveFolder != null && driveFolder != '') { + const folder = JSON.parse(driveFolder); + + // 移動先が自分自身ならreject + if (this.folder && folder.id == this.folder.id) return false; + if (this.folders.some(f => f.id == folder.id)) return false; + this.removeFolder(folder.id); + (this as any).api('drive/folders/update', { + folder_id: folder.id, + parent_id: this.folder ? this.folder.id : null + }).then(() => { + // noop + }).catch(err => { + switch (err) { + case 'detected-circular-definition': + (this as any).apis.dialog({ + title: '%fa:exclamation-triangle%%i18n:desktop.tags.mk-drive-browser.unable-to-process%', + text: '%i18n:desktop.tags.mk-drive-browser.circular-reference-detected%', + actions: [{ + text: '%i18n:common.ok%' + }] + }); + break; + default: + alert('%i18n:desktop.tags.mk-drive-browser.unhandled-error% ' + err); + } + }); + } + //#endregion + }, + + selectLocalFile() { + (this.$refs.fileInput as any).click(); + }, + + urlUpload() { + (this as any).apis.input({ + title: '%i18n:desktop.tags.mk-drive-browser.url-upload%', + placeholder: '%i18n:desktop.tags.mk-drive-browser.url-of-file%' + }).then(url => { + (this as any).api('drive/files/upload_from_url', { + url: url, + folder_id: this.folder ? this.folder.id : undefined + }); + + (this as any).apis.dialog({ + title: '%fa:check%%i18n:desktop.tags.mk-drive-browser.url-upload-requested%', + text: '%i18n:desktop.tags.mk-drive-browser.may-take-time%', + actions: [{ + text: '%i18n:common.ok%' + }] + }); + }); + }, + + createFolder() { + (this as any).apis.input({ + title: '%i18n:desktop.tags.mk-drive-browser.create-folder%', + placeholder: '%i18n:desktop.tags.mk-drive-browser.folder-name%' + }).then(name => { + (this as any).api('drive/folders/create', { + name: name, + folder_id: this.folder ? this.folder.id : undefined + }).then(folder => { + this.addFolder(folder, true); + }); + }); + }, + + onChangeFileInput() { + Array.from((this.$refs.fileInput as any).files).forEach(file => { + this.upload(file, this.folder); + }); + }, + + upload(file, folder) { + if (folder && typeof folder == 'object') folder = folder.id; + (this.$refs.uploader as any).upload(file, folder); + }, + + chooseFile(file) { + const isAlreadySelected = this.selectedFiles.some(f => f.id == file.id); + if (this.multiple) { + if (isAlreadySelected) { + this.selectedFiles = this.selectedFiles.filter(f => f.id != file.id); + } else { + this.selectedFiles.push(file); + } + this.$emit('change-selection', this.selectedFiles); + } else { + if (isAlreadySelected) { + this.$emit('selected', file); + } else { + this.selectedFiles = [file]; + this.$emit('change-selection', [file]); + } + } + }, + + newWindow(folder) { + if (document.body.clientWidth > 800) { + (this as any).os.new(MkDriveWindow, { + folder: folder + }); + } else { + window.open(url + '/i/drive/folder/' + folder.id, + 'drive_window', + 'height=500, width=800'); + } + }, + + move(target) { + if (target == null) { + this.goRoot(); + return; + } else if (typeof target == 'object') { + target = target.id; + } + + this.fetching = true; + + (this as any).api('drive/folders/show', { + folder_id: target + }).then(folder => { + this.folder = folder; + this.hierarchyFolders = []; + + const dive = folder => { + this.hierarchyFolders.unshift(folder); + if (folder.parent) dive(folder.parent); + }; + + if (folder.parent) dive(folder.parent); + + this.$emit('open-folder', folder); + this.fetch(); + }); + }, + + addFolder(folder, unshift = false) { + const current = this.folder ? this.folder.id : null; + if (current != folder.parent_id) return; + + if (this.folders.some(f => f.id == folder.id)) { + const exist = this.folders.map(f => f.id).indexOf(folder.id); + Vue.set(this.folders, exist, folder); + return; + } + + if (unshift) { + this.folders.unshift(folder); + } else { + this.folders.push(folder); + } + }, + + addFile(file, unshift = false) { + const current = this.folder ? this.folder.id : null; + if (current != file.folder_id) return; + + if (this.files.some(f => f.id == file.id)) { + const exist = this.files.map(f => f.id).indexOf(file.id); + Vue.set(this.files, exist, file); + return; + } + + if (unshift) { + this.files.unshift(file); + } else { + this.files.push(file); + } + }, + + removeFolder(folder) { + if (typeof folder == 'object') folder = folder.id; + this.folders = this.folders.filter(f => f.id != folder); + }, + + removeFile(file) { + if (typeof file == 'object') file = file.id; + this.files = this.files.filter(f => f.id != file); + }, + + appendFile(file) { + this.addFile(file); + }, + + appendFolder(folder) { + this.addFolder(folder); + }, + + prependFile(file) { + this.addFile(file, true); + }, + + prependFolder(folder) { + this.addFolder(folder, true); + }, + + goRoot() { + // 既にrootにいるなら何もしない + if (this.folder == null) return; + + this.folder = null; + this.hierarchyFolders = []; + this.$emit('move-root'); + this.fetch(); + }, + + fetch() { + this.folders = []; + this.files = []; + this.moreFolders = false; + this.moreFiles = false; + this.fetching = true; + + let fetchedFolders = null; + let fetchedFiles = null; + + const foldersMax = 30; + const filesMax = 30; + + // フォルダ一覧取得 + (this as any).api('drive/folders', { + folder_id: this.folder ? this.folder.id : null, + limit: foldersMax + 1 + }).then(folders => { + if (folders.length == foldersMax + 1) { + this.moreFolders = true; + folders.pop(); + } + fetchedFolders = folders; + complete(); + }); + + // ファイル一覧取得 + (this as any).api('drive/files', { + folder_id: this.folder ? this.folder.id : null, + limit: filesMax + 1 + }).then(files => { + if (files.length == filesMax + 1) { + this.moreFiles = true; + files.pop(); + } + fetchedFiles = files; + complete(); + }); + + let flag = false; + const complete = () => { + if (flag) { + fetchedFolders.forEach(this.appendFolder); + fetchedFiles.forEach(this.appendFile); + this.fetching = false; + } else { + flag = true; + } + }; + }, + + fetchMoreFiles() { + this.fetching = true; + + const max = 30; + + // ファイル一覧取得 + (this as any).api('drive/files', { + folder_id: this.folder ? this.folder.id : null, + limit: max + 1 + }).then(files => { + if (files.length == max + 1) { + this.moreFiles = true; + files.pop(); + } else { + this.moreFiles = false; + } + files.forEach(this.appendFile); + this.fetching = false; + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +.mk-drive + + > nav + display block + z-index 2 + width 100% + overflow auto + font-size 0.9em + color #555 + background #fff + //border-bottom 1px solid #dfdfdf + box-shadow 0 1px 0 rgba(0, 0, 0, 0.05) + + &, * + user-select none + + > .path + display inline-block + vertical-align bottom + margin 0 + padding 0 8px + width calc(100% - 200px) + line-height 38px + white-space nowrap + + > * + display inline-block + margin 0 + padding 0 8px + line-height 38px + cursor pointer + + i + margin-right 4px + + * + pointer-events none + + &:hover + text-decoration underline + + &.current + font-weight bold + cursor default + + &:hover + text-decoration none + + &.separator + margin 0 + padding 0 + opacity 0.5 + cursor default + + > [data-fa] + margin 0 + + > .search + display inline-block + vertical-align bottom + user-select text + cursor auto + margin 0 + padding 0 18px + width 200px + font-size 1em + line-height 38px + background transparent + outline none + //border solid 1px #ddd + border none + border-radius 0 + box-shadow none + transition color 0.5s ease, border 0.5s ease + font-family FontAwesome, sans-serif + + &[data-active='true'] + background #fff + + &::-webkit-input-placeholder, + &:-ms-input-placeholder, + &:-moz-placeholder + color $ui-control-foreground-color + + > .main + padding 8px + height calc(100% - 38px) + overflow auto + + &, * + user-select none + + &.fetching + cursor wait !important + + * + pointer-events none + + > .contents + opacity 0.5 + + &.uploading + height calc(100% - 38px - 100px) + + > .selection + display none + position absolute + z-index 128 + top 0 + left 0 + border solid 1px $theme-color + background rgba($theme-color, 0.5) + pointer-events none + + > .contents + + > .folders + > .files + display flex + flex-wrap wrap + + > .folder + > .file + flex-grow 1 + width 144px + margin 4px + + > .padding + flex-grow 1 + pointer-events none + width 144px + 8px // 8px is margin + + > .empty + padding 16px + text-align center + color #999 + pointer-events none + + > p + margin 0 + + > .fetching + .spinner + margin 100px auto + width 40px + height 40px + text-align center + + animation sk-rotate 2.0s infinite linear + + .dot1, .dot2 + width 60% + height 60% + display inline-block + position absolute + top 0 + background-color rgba(0, 0, 0, 0.3) + border-radius 100% + + animation sk-bounce 2.0s infinite ease-in-out + + .dot2 + top auto + bottom 0 + animation-delay -1.0s + + @keyframes sk-rotate { 100% { transform: rotate(360deg); }} + + @keyframes sk-bounce { + 0%, 100% { + transform: scale(0.0); + } 50% { + transform: scale(1.0); + } + } + + > .dropzone + position absolute + left 0 + top 38px + width 100% + height calc(100% - 38px) + border dashed 2px rgba($theme-color, 0.5) + pointer-events none + + > .mk-uploader + height 100px + padding 16px + background #fff + + > input + display none + +</style> diff --git a/src/server/web/app/desktop/views/components/ellipsis-icon.vue b/src/server/web/app/desktop/views/components/ellipsis-icon.vue new file mode 100644 index 0000000000..c54a7db29d --- /dev/null +++ b/src/server/web/app/desktop/views/components/ellipsis-icon.vue @@ -0,0 +1,37 @@ +<template> +<div class="mk-ellipsis-icon"> + <div></div><div></div><div></div> +</div> +</template> + +<style lang="stylus" scoped> +.mk-ellipsis-icon + width 70px + margin 0 auto + text-align center + + > div + display inline-block + width 18px + height 18px + background-color rgba(0, 0, 0, 0.3) + border-radius 100% + animation bounce 1.4s infinite ease-in-out both + + &:nth-child(1) + animation-delay 0s + + &:nth-child(2) + margin 0 6px + animation-delay 0.16s + + &:nth-child(3) + animation-delay 0.32s + + @keyframes bounce + 0%, 80%, 100% + transform scale(0) + 40% + transform scale(1) + +</style> diff --git a/src/server/web/app/desktop/views/components/follow-button.vue b/src/server/web/app/desktop/views/components/follow-button.vue new file mode 100644 index 0000000000..fc4f871888 --- /dev/null +++ b/src/server/web/app/desktop/views/components/follow-button.vue @@ -0,0 +1,164 @@ +<template> +<button class="mk-follow-button" + :class="{ wait, follow: !user.is_following, unfollow: user.is_following, big: size == 'big' }" + @click="onClick" + :disabled="wait" + :title="user.is_following ? 'フォロー解除' : 'フォローする'" +> + <template v-if="!wait && user.is_following"> + <template v-if="size == 'compact'">%fa:minus%</template> + <template v-if="size == 'big'">%fa:minus%フォロー解除</template> + </template> + <template v-if="!wait && !user.is_following"> + <template v-if="size == 'compact'">%fa:plus%</template> + <template v-if="size == 'big'">%fa:plus%フォロー</template> + </template> + <template v-if="wait">%fa:spinner .pulse .fw%</template> +</button> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + props: { + user: { + type: Object, + required: true + }, + size: { + type: String, + default: 'compact' + } + }, + data() { + return { + wait: false, + connection: null, + connectionId: null + }; + }, + mounted() { + this.connection = (this as any).os.stream.getConnection(); + this.connectionId = (this as any).os.stream.use(); + + this.connection.on('follow', this.onFollow); + this.connection.on('unfollow', this.onUnfollow); + }, + beforeDestroy() { + this.connection.off('follow', this.onFollow); + this.connection.off('unfollow', this.onUnfollow); + (this as any).os.stream.dispose(this.connectionId); + }, + methods: { + + onFollow(user) { + if (user.id == this.user.id) { + this.user.is_following = user.is_following; + } + }, + + onUnfollow(user) { + if (user.id == this.user.id) { + this.user.is_following = user.is_following; + } + }, + + onClick() { + this.wait = true; + if (this.user.is_following) { + (this as any).api('following/delete', { + user_id: this.user.id + }).then(() => { + this.user.is_following = false; + }).catch(err => { + console.error(err); + }).then(() => { + this.wait = false; + }); + } else { + (this as any).api('following/create', { + user_id: this.user.id + }).then(() => { + this.user.is_following = true; + }).catch(err => { + console.error(err); + }).then(() => { + this.wait = false; + }); + } + } + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +.mk-follow-button + display block + cursor pointer + padding 0 + margin 0 + width 32px + height 32px + font-size 1em + outline none + border-radius 4px + + * + pointer-events none + + &: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 + + &.follow + 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 + + &.unfollow + 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 + + &.wait + cursor wait !important + opacity 0.7 + + &.big + width 100% + height 38px + line-height 38px + + i + margin-right 8px + +</style> diff --git a/src/server/web/app/desktop/views/components/followers-window.vue b/src/server/web/app/desktop/views/components/followers-window.vue new file mode 100644 index 0000000000..d41d356f9b --- /dev/null +++ b/src/server/web/app/desktop/views/components/followers-window.vue @@ -0,0 +1,26 @@ +<template> +<mk-window width="400px" height="550px" @closed="$destroy"> + <span slot="header" :class="$style.header"> + <img :src="`${user.avatar_url}?thumbnail&size=64`" alt=""/>{{ user.name }}のフォロワー + </span> + <mk-followers :user="user"/> +</mk-window> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + props: ['user'] +}); +</script> + +<style lang="stylus" module> +.header + > img + display inline-block + vertical-align bottom + height calc(100% - 10px) + margin 5px + border-radius 4px + +</style> diff --git a/src/server/web/app/desktop/views/components/followers.vue b/src/server/web/app/desktop/views/components/followers.vue new file mode 100644 index 0000000000..4541a00072 --- /dev/null +++ b/src/server/web/app/desktop/views/components/followers.vue @@ -0,0 +1,26 @@ +<template> +<mk-users-list + :fetch="fetch" + :count="user.followers_count" + :you-know-count="user.followers_you_know_count" +> + フォロワーはいないようです。 +</mk-users-list> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + props: ['user'], + methods: { + fetch(iknow, limit, cursor, cb) { + (this as any).api('users/followers', { + user_id: this.user.id, + iknow: iknow, + limit: limit, + cursor: cursor ? cursor : undefined + }).then(cb); + } + } +}); +</script> diff --git a/src/server/web/app/desktop/views/components/following-window.vue b/src/server/web/app/desktop/views/components/following-window.vue new file mode 100644 index 0000000000..c516b3b17b --- /dev/null +++ b/src/server/web/app/desktop/views/components/following-window.vue @@ -0,0 +1,26 @@ +<template> +<mk-window width="400px" height="550px" @closed="$destroy"> + <span slot="header" :class="$style.header"> + <img :src="`${user.avatar_url}?thumbnail&size=64`" alt=""/>{{ user.name }}のフォロー + </span> + <mk-following :user="user"/> +</mk-window> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + props: ['user'] +}); +</script> + +<style lang="stylus" module> +.header + > img + display inline-block + vertical-align bottom + height calc(100% - 10px) + margin 5px + border-radius 4px + +</style> diff --git a/src/server/web/app/desktop/views/components/following.vue b/src/server/web/app/desktop/views/components/following.vue new file mode 100644 index 0000000000..e0b9f11695 --- /dev/null +++ b/src/server/web/app/desktop/views/components/following.vue @@ -0,0 +1,26 @@ +<template> +<mk-users-list + :fetch="fetch" + :count="user.following_count" + :you-know-count="user.following_you_know_count" +> + フォロー中のユーザーはいないようです。 +</mk-users-list> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + props: ['user'], + methods: { + fetch(iknow, limit, cursor, cb) { + (this as any).api('users/following', { + user_id: this.user.id, + iknow: iknow, + limit: limit, + cursor: cursor ? cursor : undefined + }).then(cb); + } + } +}); +</script> diff --git a/src/server/web/app/desktop/views/components/friends-maker.vue b/src/server/web/app/desktop/views/components/friends-maker.vue new file mode 100644 index 0000000000..eed15e0773 --- /dev/null +++ b/src/server/web/app/desktop/views/components/friends-maker.vue @@ -0,0 +1,171 @@ +<template> +<div class="mk-friends-maker"> + <p class="title">気になるユーザーをフォロー:</p> + <div class="users" v-if="!fetching && users.length > 0"> + <div class="user" v-for="user in users" :key="user.id"> + <router-link class="avatar-anchor" :to="`/@${getAcct(user)}`"> + <img class="avatar" :src="`${user.avatar_url}?thumbnail&size=42`" alt="" v-user-preview="user.id"/> + </router-link> + <div class="body"> + <router-link class="name" :to="`/@${getAcct(user)}`" v-user-preview="user.id">{{ user.name }}</router-link> + <p class="username">@{{ getAcct(user) }}</p> + </div> + <mk-follow-button :user="user"/> + </div> + </div> + <p class="empty" v-if="!fetching && users.length == 0">おすすめのユーザーは見つかりませんでした。</p> + <p class="fetching" v-if="fetching">%fa:spinner .pulse .fw%読み込んでいます<mk-ellipsis/></p> + <a class="refresh" @click="refresh">もっと見る</a> + <button class="close" @click="$destroy()" title="閉じる">%fa:times%</button> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import getAcct from '../../../../../common/user/get-acct'; + +export default Vue.extend({ + data() { + return { + users: [], + fetching: true, + limit: 6, + page: 0 + }; + }, + mounted() { + this.fetch(); + }, + methods: { + getAcct, + fetch() { + this.fetching = true; + this.users = []; + + (this as any).api('users/recommendation', { + limit: this.limit, + offset: this.limit * this.page + }).then(users => { + this.users = users; + this.fetching = false; + }); + }, + refresh() { + if (this.users.length < this.limit) { + this.page = 0; + } else { + this.page++; + } + this.fetch(); + } + } +}); +</script> + +<style lang="stylus" scoped> +.mk-friends-maker + padding 24px + + > .title + margin 0 0 12px 0 + font-size 1em + font-weight bold + color #888 + + > .users + &:after + content "" + display block + clear both + + > .user + padding 16px + width 238px + float left + + &: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 + margin 0 + font-size 15px + line-height 16px + color #ccc + + > .mk-follow-button + position absolute + top 16px + right 16px + + > .empty + margin 0 + padding 16px + text-align center + color #aaa + + > .fetching + margin 0 + padding 16px + text-align center + color #aaa + + > [data-fa] + margin-right 4px + + > .refresh + display block + margin 0 8px 0 0 + text-align right + font-size 0.9em + color #999 + + > .close + cursor pointer + display block + position absolute + top 6px + right 6px + z-index 1 + margin 0 + padding 0 + font-size 1.2em + color #999 + border none + outline none + background transparent + + &:hover + color #555 + + &:active + color #222 + + > [data-fa] + padding 14px + +</style> diff --git a/src/server/web/app/desktop/views/components/game-window.vue b/src/server/web/app/desktop/views/components/game-window.vue new file mode 100644 index 0000000000..3c8bf40e12 --- /dev/null +++ b/src/server/web/app/desktop/views/components/game-window.vue @@ -0,0 +1,37 @@ +<template> +<mk-window ref="window" width="500px" height="560px" :popout-url="popout" @closed="$destroy"> + <span slot="header" :class="$style.header">%fa:gamepad%オセロ</span> + <mk-othello :class="$style.content" @gamed="g => game = g"/> +</mk-window> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { url } from '../../../config'; + +export default Vue.extend({ + data() { + return { + game: null + }; + }, + computed: { + popout(): string { + return this.game + ? `${url}/othello/${this.game.id}` + : `${url}/othello`; + } + } +}); +</script> + +<style lang="stylus" module> +.header + > [data-fa] + margin-right 4px + +.content + height 100% + overflow auto + +</style> diff --git a/src/server/web/app/desktop/views/components/home.vue b/src/server/web/app/desktop/views/components/home.vue new file mode 100644 index 0000000000..a4ce1ef94b --- /dev/null +++ b/src/server/web/app/desktop/views/components/home.vue @@ -0,0 +1,357 @@ +<template> +<div class="mk-home" :data-customize="customize"> + <div class="customize" v-if="customize"> + <router-link to="/">%fa:check%完了</router-link> + <div> + <div class="adder"> + <p>ウィジェットを追加:</p> + <select v-model="widgetAdderSelected"> + <option value="profile">プロフィール</option> + <option value="calendar">カレンダー</option> + <option value="timemachine">カレンダー(タイムマシン)</option> + <option value="activity">アクティビティ</option> + <option value="rss">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="users">おすすめユーザー</option> + <option value="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 @click="addWidget">追加</button> + </div> + <div class="trash"> + <x-draggable v-model="trash" :options="{ group: 'x' }" @add="onTrash"></x-draggable> + <p>ゴミ箱</p> + </div> + </div> + </div> + <div class="main"> + <template v-if="customize"> + <x-draggable v-for="place in ['left', 'right']" + :list="widgets[place]" + :class="place" + :data-place="place" + :options="{ group: 'x', animation: 150 }" + @sort="onWidgetSort" + :key="place" + > + <div v-for="widget in widgets[place]" class="customize-container" :key="widget.id" @contextmenu.stop.prevent="onWidgetContextmenu(widget.id)"> + <component :is="`mkw-${widget.name}`" :widget="widget" :ref="widget.id" :is-customize-mode="true"/> + </div> + </x-draggable> + <div class="main"> + <a @click="hint">カスタマイズのヒント</a> + <div> + <mk-post-form v-if="os.i.account.client_settings.showPostFormOnTopOfTl"/> + <mk-timeline ref="tl" @loaded="onTlLoaded"/> + </div> + </div> + </template> + <template v-else> + <div v-for="place in ['left', 'right']" :class="place"> + <component v-for="widget in widgets[place]" :is="`mkw-${widget.name}`" :key="widget.id" :ref="widget.id" :widget="widget" @chosen="warp"/> + </div> + <div class="main"> + <mk-post-form v-if="os.i.account.client_settings.showPostFormOnTopOfTl"/> + <mk-timeline ref="tl" @loaded="onTlLoaded" v-if="mode == 'timeline'"/> + <mk-mentions @loaded="onTlLoaded" v-if="mode == 'mentions'"/> + </div> + </template> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import * as XDraggable from 'vuedraggable'; +import * as uuid from 'uuid'; + +export default Vue.extend({ + components: { + XDraggable + }, + props: { + customize: Boolean, + mode: { + type: String, + default: 'timeline' + } + }, + data() { + return { + connection: null, + connectionId: null, + widgetAdderSelected: null, + trash: [], + widgets: { + left: [], + right: [] + } + }; + }, + computed: { + home: { + get(): any[] { + //#region 互換性のため + (this as any).os.i.account.client_settings.home.forEach(w => { + if (w.name == 'rss-reader') w.name = 'rss'; + if (w.name == 'user-recommendation') w.name = 'users'; + if (w.name == 'recommended-polls') w.name = 'polls'; + }); + //#endregion + return (this as any).os.i.account.client_settings.home; + }, + set(value) { + (this as any).os.i.account.client_settings.home = value; + } + }, + left(): any[] { + return this.home.filter(w => w.place == 'left'); + }, + right(): any[] { + return this.home.filter(w => w.place == 'right'); + } + }, + created() { + this.widgets.left = this.left; + this.widgets.right = this.right; + this.$watch('os.i.account.client_settings', i => { + this.widgets.left = this.left; + this.widgets.right = this.right; + }, { + deep: true + }); + }, + mounted() { + this.connection = (this as any).os.stream.getConnection(); + this.connectionId = (this as any).os.stream.use(); + + this.connection.on('home_updated', this.onHomeUpdated); + }, + beforeDestroy() { + this.connection.off('home_updated', this.onHomeUpdated); + (this as any).os.stream.dispose(this.connectionId); + }, + methods: { + hint() { + (this as any).apis.dialog({ + title: '%fa:info-circle%カスタマイズのヒント', + text: '<p>ホームのカスタマイズでは、ウィジェットを追加/削除したり、ドラッグ&ドロップして並べ替えたりすることができます。</p>' + + '<p>一部のウィジェットは、<strong><strong>右</strong>クリック</strong>することで表示を変更することができます。</p>' + + '<p>ウィジェットを削除するには、ヘッダーの<strong>「ゴミ箱」</strong>と書かれたエリアにウィジェットをドラッグ&ドロップします。</p>' + + '<p>カスタマイズを終了するには、右上の「完了」をクリックします。</p>', + actions: [{ + text: 'Got it!' + }] + }); + }, + onTlLoaded() { + this.$emit('loaded'); + }, + onHomeUpdated(data) { + if (data.home) { + (this as any).os.i.account.client_settings.home = data.home; + this.widgets.left = data.home.filter(w => w.place == 'left'); + this.widgets.right = data.home.filter(w => w.place == 'right'); + } else { + const w = (this as any).os.i.account.client_settings.home.find(w => w.id == data.id); + if (w != null) { + w.data = data.data; + this.$refs[w.id][0].preventSave = true; + this.$refs[w.id][0].props = w.data; + this.widgets.left = (this as any).os.i.account.client_settings.home.filter(w => w.place == 'left'); + this.widgets.right = (this as any).os.i.account.client_settings.home.filter(w => w.place == 'right'); + } + } + }, + onWidgetContextmenu(widgetId) { + const w = (this.$refs[widgetId] as any)[0]; + if (w.func) w.func(); + }, + onWidgetSort() { + this.saveHome(); + }, + onTrash(evt) { + this.saveHome(); + }, + addWidget() { + const widget = { + name: this.widgetAdderSelected, + id: uuid(), + place: 'left', + data: {} + }; + + this.widgets.left.unshift(widget); + this.saveHome(); + }, + saveHome() { + const left = this.widgets.left; + const right = this.widgets.right; + this.home = left.concat(right); + left.forEach(w => w.place = 'left'); + right.forEach(w => w.place = 'right'); + (this as any).api('i/update_home', { + home: this.home + }); + }, + warp(date) { + (this.$refs.tl as any).warp(date); + } + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +.mk-home + display block + + &[data-customize] + padding-top 48px + background-image url('/assets/desktop/grid.svg') + + > .main > .main + > a + display block + margin-bottom 8px + text-align center + + > div + cursor not-allowed !important + + > * + 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 + + > [data-fa] + 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 + + > * + .customize-container + cursor move + border-radius 6px + + &:hover + box-shadow 0 0 8px rgba(64, 120, 200, 0.3) + + > * + pointer-events none + + > .main + padding 16px + width calc(100% - 275px * 2) + order 2 + + .mk-post-form + margin-bottom 16px + border solid 1px #e5e5e5 + border-radius 4px + + > *:not(.main) + width 275px + padding 16px 0 16px 0 + + > *:not(:last-child) + margin-bottom 16px + + > .left + padding-left 16px + order 1 + + > .right + padding-right 16px + order 3 + + @media (max-width 1100px) + > *:not(.main) + display none + + > .main + float none + width 100% + max-width 700px + margin 0 auto + +</style> diff --git a/src/server/web/app/desktop/views/components/index.ts b/src/server/web/app/desktop/views/components/index.ts new file mode 100644 index 0000000000..3798bf6d2d --- /dev/null +++ b/src/server/web/app/desktop/views/components/index.ts @@ -0,0 +1,61 @@ +import Vue from 'vue'; + +import ui from './ui.vue'; +import uiNotification from './ui-notification.vue'; +import home from './home.vue'; +import timeline from './timeline.vue'; +import posts from './posts.vue'; +import subPostContent from './sub-post-content.vue'; +import window from './window.vue'; +import postFormWindow from './post-form-window.vue'; +import repostFormWindow from './repost-form-window.vue'; +import analogClock from './analog-clock.vue'; +import ellipsisIcon from './ellipsis-icon.vue'; +import mediaImage from './media-image.vue'; +import mediaImageDialog from './media-image-dialog.vue'; +import mediaVideo from './media-video.vue'; +import notifications from './notifications.vue'; +import postForm from './post-form.vue'; +import repostForm from './repost-form.vue'; +import followButton from './follow-button.vue'; +import postPreview from './post-preview.vue'; +import drive from './drive.vue'; +import postDetail from './post-detail.vue'; +import settings from './settings.vue'; +import calendar from './calendar.vue'; +import activity from './activity.vue'; +import friendsMaker from './friends-maker.vue'; +import followers from './followers.vue'; +import following from './following.vue'; +import usersList from './users-list.vue'; +import widgetContainer from './widget-container.vue'; + +Vue.component('mk-ui', ui); +Vue.component('mk-ui-notification', uiNotification); +Vue.component('mk-home', home); +Vue.component('mk-timeline', timeline); +Vue.component('mk-posts', posts); +Vue.component('mk-sub-post-content', subPostContent); +Vue.component('mk-window', window); +Vue.component('mk-post-form-window', postFormWindow); +Vue.component('mk-repost-form-window', repostFormWindow); +Vue.component('mk-analog-clock', analogClock); +Vue.component('mk-ellipsis-icon', ellipsisIcon); +Vue.component('mk-media-image', mediaImage); +Vue.component('mk-media-image-dialog', mediaImageDialog); +Vue.component('mk-media-video', mediaVideo); +Vue.component('mk-notifications', notifications); +Vue.component('mk-post-form', postForm); +Vue.component('mk-repost-form', repostForm); +Vue.component('mk-follow-button', followButton); +Vue.component('mk-post-preview', postPreview); +Vue.component('mk-drive', drive); +Vue.component('mk-post-detail', postDetail); +Vue.component('mk-settings', settings); +Vue.component('mk-calendar', calendar); +Vue.component('mk-activity', activity); +Vue.component('mk-friends-maker', friendsMaker); +Vue.component('mk-followers', followers); +Vue.component('mk-following', following); +Vue.component('mk-users-list', usersList); +Vue.component('mk-widget-container', widgetContainer); diff --git a/src/server/web/app/desktop/views/components/input-dialog.vue b/src/server/web/app/desktop/views/components/input-dialog.vue new file mode 100644 index 0000000000..e939fc1903 --- /dev/null +++ b/src/server/web/app/desktop/views/components/input-dialog.vue @@ -0,0 +1,180 @@ +<template> +<mk-window ref="window" is-modal width="500px" @before-close="beforeClose" @closed="$destroy"> + <span slot="header" :class="$style.header"> + %fa:i-cursor%{{ title }} + </span> + + <div :class="$style.body"> + <input ref="text" v-model="text" :type="type" @keydown="onKeydown" :placeholder="placeholder"/> + </div> + <div :class="$style.actions"> + <button :class="$style.cancel" @click="cancel">キャンセル</button> + <button :class="$style.ok" :disabled="!allowEmpty && text.length == 0" @click="ok">決定</button> + </div> +</mk-window> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + props: { + title: { + type: String + }, + placeholder: { + type: String + }, + default: { + type: String + }, + allowEmpty: { + default: true + }, + type: { + default: 'text' + } + }, + data() { + return { + done: false, + text: '' + }; + }, + mounted() { + if (this.default) this.text = this.default; + this.$nextTick(() => { + (this.$refs.text as any).focus(); + }); + }, + methods: { + ok() { + if (!this.allowEmpty && this.text == '') return; + this.done = true; + (this.$refs.window as any).close(); + }, + cancel() { + this.done = false; + (this.$refs.window as any).close(); + }, + beforeClose() { + if (this.done) { + this.$emit('done', this.text); + } else { + this.$emit('canceled'); + } + }, + onKeydown(e) { + if (e.which == 13) { // Enter + e.preventDefault(); + e.stopPropagation(); + this.ok(); + } + } + } +}); +</script> + + +<style lang="stylus" module> +@import '~const.styl' + +.header + > [data-fa] + margin-right 4px + +.body + padding 16px + + > input + display block + padding 8px + margin 0 + width 100% + max-width 100% + min-width 100% + font-size 1em + color #333 + background #fff + outline none + border solid 1px rgba($theme-color, 0.1) + border-radius 4px + transition border-color .3s ease + + &:hover + border-color rgba($theme-color, 0.2) + transition border-color .1s ease + + &:focus + color $theme-color + border-color rgba($theme-color, 0.5) + transition border-color 0s ease + + &::-webkit-input-placeholder + color rgba($theme-color, 0.3) + +.actions + 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> diff --git a/src/server/web/app/desktop/views/components/media-image-dialog.vue b/src/server/web/app/desktop/views/components/media-image-dialog.vue new file mode 100644 index 0000000000..dec140d1c9 --- /dev/null +++ b/src/server/web/app/desktop/views/components/media-image-dialog.vue @@ -0,0 +1,69 @@ +<template> +<div class="mk-media-image-dialog"> + <div class="bg" @click="close"></div> + <img :src="image.url" :alt="image.name" :title="image.name" @click="close"/> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import * as anime from 'animejs'; + +export default Vue.extend({ + props: ['image'], + mounted() { + anime({ + targets: this.$el, + opacity: 1, + duration: 100, + easing: 'linear' + }); + }, + methods: { + close() { + anime({ + targets: this.$el, + opacity: 0, + duration: 100, + easing: 'linear', + complete: () => this.$destroy() + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +.mk-media-image-dialog + display block + position fixed + z-index 2048 + top 0 + left 0 + width 100% + height 100% + opacity 0 + + > .bg + display block + position fixed + z-index 1 + top 0 + left 0 + width 100% + height 100% + background rgba(0, 0, 0, 0.7) + + > img + position fixed + z-index 2 + top 0 + right 0 + bottom 0 + left 0 + max-width 100% + max-height 100% + margin auto + cursor zoom-out + +</style> diff --git a/src/server/web/app/desktop/views/components/media-image.vue b/src/server/web/app/desktop/views/components/media-image.vue new file mode 100644 index 0000000000..bc02d0f9be --- /dev/null +++ b/src/server/web/app/desktop/views/components/media-image.vue @@ -0,0 +1,63 @@ +<template> +<a class="mk-media-image" + :href="image.url" + @mousemove="onMousemove" + @mouseleave="onMouseleave" + @click.prevent="onClick" + :style="style" + :title="image.name" +></a> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import MkMediaImageDialog from './media-image-dialog.vue'; + +export default Vue.extend({ + props: ['image'], + computed: { + style(): any { + return { + 'background-color': this.image.properties.average_color ? `rgb(${this.image.properties.average_color.join(',')})` : 'transparent', + 'background-image': `url(${this.image.url}?thumbnail&size=512)` + }; + } + }, + methods: { + onMousemove(e) { + const rect = this.$el.getBoundingClientRect(); + const mouseX = e.clientX - rect.left; + const mouseY = e.clientY - rect.top; + const xp = mouseX / this.$el.offsetWidth * 100; + const yp = mouseY / this.$el.offsetHeight * 100; + this.$el.style.backgroundPosition = xp + '% ' + yp + '%'; + this.$el.style.backgroundImage = 'url("' + this.image.url + '?thumbnail")'; + }, + + onMouseleave() { + this.$el.style.backgroundPosition = ''; + }, + + onClick() { + (this as any).os.new(MkMediaImageDialog, { + image: this.image + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +.mk-media-image + display block + cursor zoom-in + overflow hidden + width 100% + height 100% + background-position center + border-radius 4px + + &:not(:hover) + background-size cover + +</style> diff --git a/src/server/web/app/desktop/views/components/media-video-dialog.vue b/src/server/web/app/desktop/views/components/media-video-dialog.vue new file mode 100644 index 0000000000..cbf862cd1c --- /dev/null +++ b/src/server/web/app/desktop/views/components/media-video-dialog.vue @@ -0,0 +1,70 @@ +<template> +<div class="mk-media-video-dialog"> + <div class="bg" @click="close"></div> + <video :src="video.url" :title="video.name" controls autoplay ref="video"/> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import * as anime from 'animejs'; + +export default Vue.extend({ + props: ['video', 'start'], + mounted() { + anime({ + targets: this.$el, + opacity: 1, + duration: 100, + easing: 'linear' + }); + const videoTag = this.$refs.video as HTMLVideoElement + if (this.start) videoTag.currentTime = this.start + }, + methods: { + close() { + anime({ + targets: this.$el, + opacity: 0, + duration: 100, + easing: 'linear', + complete: () => this.$destroy() + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +.mk-media-video-dialog + display block + position fixed + z-index 2048 + top 0 + left 0 + width 100% + height 100% + opacity 0 + + > .bg + display block + position fixed + z-index 1 + top 0 + left 0 + width 100% + height 100% + background rgba(0, 0, 0, 0.7) + + > video + position fixed + z-index 2 + top 0 + right 0 + bottom 0 + left 0 + max-width 80vw + max-height 80vh + margin auto + +</style> diff --git a/src/server/web/app/desktop/views/components/media-video.vue b/src/server/web/app/desktop/views/components/media-video.vue new file mode 100644 index 0000000000..4fd955a821 --- /dev/null +++ b/src/server/web/app/desktop/views/components/media-video.vue @@ -0,0 +1,67 @@ +<template> + <video class="mk-media-video" + :src="video.url" + :title="video.name" + controls + @dblclick.prevent="onClick" + ref="video" + v-if="inlinePlayable" /> + <a class="mk-media-video-thumbnail" + :href="video.url" + :style="imageStyle" + @click.prevent="onClick" + :title="video.name" + v-else> + %fa:R play-circle% + </a> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import MkMediaVideoDialog from './media-video-dialog.vue'; + +export default Vue.extend({ + props: ['video', 'inlinePlayable'], + computed: { + imageStyle(): any { + return { + 'background-image': `url(${this.video.url}?thumbnail&size=512)` + }; + } + }, + methods: { + onClick() { + const videoTag = this.$refs.video as (HTMLVideoElement | null) + var start = 0 + if (videoTag) { + start = videoTag.currentTime + videoTag.pause() + } + (this as any).os.new(MkMediaVideoDialog, { + video: this.video, + start, + }) + } + } +}) +</script> + +<style lang="stylus" scoped> +.mk-media-video + display block + width 100% + height 100% + border-radius 4px +.mk-media-video-thumbnail + display flex + justify-content center + align-items center + font-size 3.5em + + cursor zoom-in + overflow hidden + background-position center + background-size cover + width 100% + height 100% +</style> diff --git a/src/server/web/app/desktop/views/components/mentions.vue b/src/server/web/app/desktop/views/components/mentions.vue new file mode 100644 index 0000000000..47066e813f --- /dev/null +++ b/src/server/web/app/desktop/views/components/mentions.vue @@ -0,0 +1,125 @@ +<template> +<div class="mk-mentions"> + <header> + <span :data-is-active="mode == 'all'" @click="mode = 'all'">すべて</span> + <span :data-is-active="mode == 'following'" @click="mode = 'following'">フォロー中</span> + </header> + <div class="fetching" v-if="fetching"> + <mk-ellipsis-icon/> + </div> + <p class="empty" v-if="posts.length == 0 && !fetching"> + %fa:R comments% + <span v-if="mode == 'all'">あなた宛ての投稿はありません。</span> + <span v-if="mode == 'following'">あなたがフォローしているユーザーからの言及はありません。</span> + </p> + <mk-posts :posts="posts" ref="timeline"/> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + data() { + return { + fetching: true, + moreFetching: false, + mode: 'all', + posts: [] + }; + }, + watch: { + mode() { + this.fetch(); + } + }, + mounted() { + document.addEventListener('keydown', this.onDocumentKeydown); + window.addEventListener('scroll', this.onScroll); + + this.fetch(() => this.$emit('loaded')); + }, + beforeDestroy() { + document.removeEventListener('keydown', this.onDocumentKeydown); + window.removeEventListener('scroll', this.onScroll); + }, + methods: { + onDocumentKeydown(e) { + if (e.target.tagName != 'INPUT' && e.target.tagName != 'TEXTAREA') { + if (e.which == 84) { // t + (this.$refs.timeline as any).focus(); + } + } + }, + onScroll() { + const current = window.scrollY + window.innerHeight; + if (current > document.body.offsetHeight - 8) this.more(); + }, + fetch(cb?) { + this.fetching = true; + this.posts = []; + (this as any).api('posts/mentions', { + following: this.mode == 'following' + }).then(posts => { + this.posts = posts; + this.fetching = false; + if (cb) cb(); + }); + }, + more() { + if (this.moreFetching || this.fetching || this.posts.length == 0) return; + this.moreFetching = true; + (this as any).api('posts/mentions', { + following: this.mode == 'following', + until_id: this.posts[this.posts.length - 1].id + }).then(posts => { + this.posts = this.posts.concat(posts); + this.moreFetching = false; + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +.mk-mentions + background #fff + border solid 1px rgba(0, 0, 0, 0.075) + border-radius 6px + + > header + padding 8px 16px + border-bottom solid 1px #eee + + > span + margin-right 16px + line-height 27px + font-size 18px + color #555 + + &:not([data-is-active]) + color $theme-color + cursor pointer + + &:hover + text-decoration underline + + > .fetching + padding 64px 0 + + > .empty + display block + margin 0 auto + padding 32px + max-width 400px + text-align center + color #999 + + > [data-fa] + display block + margin-bottom 16px + font-size 3em + color #ccc + +</style> diff --git a/src/server/web/app/desktop/views/components/messaging-room-window.vue b/src/server/web/app/desktop/views/components/messaging-room-window.vue new file mode 100644 index 0000000000..3735267811 --- /dev/null +++ b/src/server/web/app/desktop/views/components/messaging-room-window.vue @@ -0,0 +1,32 @@ +<template> +<mk-window ref="window" width="500px" height="560px" :popout-url="popout" @closed="$destroy"> + <span slot="header" :class="$style.header">%fa:comments%メッセージ: {{ user.name }}</span> + <mk-messaging-room :user="user" :class="$style.content"/> +</mk-window> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { url } from '../../../config'; +import getAcct from '../../../../../common/user/get-acct'; + +export default Vue.extend({ + props: ['user'], + computed: { + popout(): string { + return `${url}/i/messaging/${getAcct(this.user)}`; + } + } +}); +</script> + +<style lang="stylus" module> +.header + > [data-fa] + margin-right 4px + +.content + height 100% + overflow auto + +</style> diff --git a/src/server/web/app/desktop/views/components/messaging-window.vue b/src/server/web/app/desktop/views/components/messaging-window.vue new file mode 100644 index 0000000000..ac27465987 --- /dev/null +++ b/src/server/web/app/desktop/views/components/messaging-window.vue @@ -0,0 +1,32 @@ +<template> +<mk-window ref="window" width="500px" height="560px" @closed="$destroy"> + <span slot="header" :class="$style.header">%fa:comments%メッセージ</span> + <mk-messaging :class="$style.content" @navigate="navigate"/> +</mk-window> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import MkMessagingRoomWindow from './messaging-room-window.vue'; + +export default Vue.extend({ + methods: { + navigate(user) { + (this as any).os.new(MkMessagingRoomWindow, { + user: user + }); + } + } +}); +</script> + +<style lang="stylus" module> +.header + > [data-fa] + margin-right 4px + +.content + height 100% + overflow auto + +</style> diff --git a/src/server/web/app/desktop/views/components/notifications.vue b/src/server/web/app/desktop/views/components/notifications.vue new file mode 100644 index 0000000000..b48ffc1746 --- /dev/null +++ b/src/server/web/app/desktop/views/components/notifications.vue @@ -0,0 +1,317 @@ +<template> +<div class="mk-notifications"> + <div class="notifications" v-if="notifications.length != 0"> + <template v-for="(notification, i) in _notifications"> + <div class="notification" :class="notification.type" :key="notification.id"> + <mk-time :time="notification.created_at"/> + <template v-if="notification.type == 'reaction'"> + <router-link class="avatar-anchor" :to="`/@${getAcct(notification.user)}`" v-user-preview="notification.user.id"> + <img class="avatar" :src="`${notification.user.avatar_url}?thumbnail&size=48`" alt="avatar"/> + </router-link> + <div class="text"> + <p> + <mk-reaction-icon :reaction="notification.reaction"/> + <router-link :to="`/@${getAcct(notification.user)}`" v-user-preview="notification.user.id">{{ notification.user.name }}</router-link> + </p> + <router-link class="post-ref" :to="`/@${getAcct(notification.post.user)}/${notification.post.id}`"> + %fa:quote-left%{{ getPostSummary(notification.post) }}%fa:quote-right% + </router-link> + </div> + </template> + <template v-if="notification.type == 'repost'"> + <router-link class="avatar-anchor" :to="`/@${getAcct(notification.post.user)}`" v-user-preview="notification.post.user_id"> + <img class="avatar" :src="`${notification.post.user.avatar_url}?thumbnail&size=48`" alt="avatar"/> + </router-link> + <div class="text"> + <p>%fa:retweet% + <router-link :to="`/@${getAcct(notification.post.user)}`" v-user-preview="notification.post.user_id">{{ notification.post.user.name }}</router-link> + </p> + <router-link class="post-ref" :to="`/@${getAcct(notification.post.user)}/${notification.post.id}`"> + %fa:quote-left%{{ getPostSummary(notification.post.repost) }}%fa:quote-right% + </router-link> + </div> + </template> + <template v-if="notification.type == 'quote'"> + <router-link class="avatar-anchor" :to="`/@${getAcct(notification.post.user)}`" v-user-preview="notification.post.user_id"> + <img class="avatar" :src="`${notification.post.user.avatar_url}?thumbnail&size=48`" alt="avatar"/> + </router-link> + <div class="text"> + <p>%fa:quote-left% + <router-link :to="`/@${getAcct(notification.post.user)}`" v-user-preview="notification.post.user_id">{{ notification.post.user.name }}</router-link> + </p> + <router-link class="post-preview" :to="`/@${getAcct(notification.post.user)}/${notification.post.id}`">{{ getPostSummary(notification.post) }}</router-link> + </div> + </template> + <template v-if="notification.type == 'follow'"> + <router-link class="avatar-anchor" :to="`/@${getAcct(notification.user)}`" v-user-preview="notification.user.id"> + <img class="avatar" :src="`${notification.user.avatar_url}?thumbnail&size=48`" alt="avatar"/> + </router-link> + <div class="text"> + <p>%fa:user-plus% + <router-link :to="`/@${getAcct(notification.user)}`" v-user-preview="notification.user.id">{{ notification.user.name }}</router-link> + </p> + </div> + </template> + <template v-if="notification.type == 'reply'"> + <router-link class="avatar-anchor" :to="`/@${getAcct(notification.post.user)}`" v-user-preview="notification.post.user_id"> + <img class="avatar" :src="`${notification.post.user.avatar_url}?thumbnail&size=48`" alt="avatar"/> + </router-link> + <div class="text"> + <p>%fa:reply% + <router-link :to="`/@${getAcct(notification.post.user)}`" v-user-preview="notification.post.user_id">{{ notification.post.user.name }}</router-link> + </p> + <router-link class="post-preview" :to="`/@${getAcct(notification.post.user)}/${notification.post.id}`">{{ getPostSummary(notification.post) }}</router-link> + </div> + </template> + <template v-if="notification.type == 'mention'"> + <router-link class="avatar-anchor" :to="`/@${getAcct(notification.post.user)}`" v-user-preview="notification.post.user_id"> + <img class="avatar" :src="`${notification.post.user.avatar_url}?thumbnail&size=48`" alt="avatar"/> + </router-link> + <div class="text"> + <p>%fa:at% + <router-link :to="`/@${getAcct(notification.post.user)}`" v-user-preview="notification.post.user_id">{{ notification.post.user.name }}</router-link> + </p> + <a class="post-preview" :href="`/@${getAcct(notification.post.user)}/${notification.post.id}`">{{ getPostSummary(notification.post) }}</a> + </div> + </template> + <template v-if="notification.type == 'poll_vote'"> + <router-link class="avatar-anchor" :to="`/@${getAcct(notification.user)}`" v-user-preview="notification.user.id"> + <img class="avatar" :src="`${notification.user.avatar_url}?thumbnail&size=48`" alt="avatar"/> + </router-link> + <div class="text"> + <p>%fa:chart-pie%<a :href="`/@${getAcct(notification.user)}`" v-user-preview="notification.user.id">{{ notification.user.name }}</a></p> + <router-link class="post-ref" :to="`/@${getAcct(notification.post.user)}/${notification.post.id}`"> + %fa:quote-left%{{ getPostSummary(notification.post) }}%fa:quote-right% + </router-link> + </div> + </template> + </div> + <p class="date" v-if="i != notifications.length - 1 && notification._date != _notifications[i + 1]._date" :key="notification.id + '-time'"> + <span>%fa:angle-up%{{ notification._datetext }}</span> + <span>%fa:angle-down%{{ _notifications[i + 1]._datetext }}</span> + </p> + </template> + </div> + <button class="more" :class="{ fetching: fetchingMoreNotifications }" v-if="moreNotifications" @click="fetchMoreNotifications" :disabled="fetchingMoreNotifications"> + <template v-if="fetchingMoreNotifications">%fa:spinner .pulse .fw%</template>{{ fetchingMoreNotifications ? '%i18n:common.loading%' : '%i18n:desktop.tags.mk-notifications.more%' }} + </button> + <p class="empty" v-if="notifications.length == 0 && !fetching">ありません!</p> + <p class="loading" v-if="fetching">%fa:spinner .pulse .fw%%i18n:common.loading%<mk-ellipsis/></p> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import getAcct from '../../../../../common/user/get-acct'; +import getPostSummary from '../../../../../common/get-post-summary'; + +export default Vue.extend({ + data() { + return { + fetching: true, + fetchingMoreNotifications: false, + notifications: [], + moreNotifications: false, + connection: null, + connectionId: null, + getPostSummary + }; + }, + computed: { + _notifications(): any[] { + return (this.notifications as any).map(notification => { + const date = new Date(notification.created_at).getDate(); + const month = new Date(notification.created_at).getMonth() + 1; + notification._date = date; + notification._datetext = `${month}月 ${date}日`; + return notification; + }); + } + }, + mounted() { + this.connection = (this as any).os.stream.getConnection(); + this.connectionId = (this as any).os.stream.use(); + + this.connection.on('notification', this.onNotification); + + const max = 10; + + (this as any).api('i/notifications', { + limit: max + 1 + }).then(notifications => { + if (notifications.length == max + 1) { + this.moreNotifications = true; + notifications.pop(); + } + + this.notifications = notifications; + this.fetching = false; + }); + }, + beforeDestroy() { + this.connection.off('notification', this.onNotification); + (this as any).os.stream.dispose(this.connectionId); + }, + methods: { + getAcct, + fetchMoreNotifications() { + this.fetchingMoreNotifications = true; + + const max = 30; + + (this as any).api('i/notifications', { + limit: max + 1, + until_id: this.notifications[this.notifications.length - 1].id + }).then(notifications => { + if (notifications.length == max + 1) { + this.moreNotifications = true; + notifications.pop(); + } else { + this.moreNotifications = false; + } + this.notifications = this.notifications.concat(notifications); + this.fetchingMoreNotifications = false; + }); + }, + onNotification(notification) { + // TODO: ユーザーが画面を見てないと思われるとき(ブラウザやタブがアクティブじゃないなど)は送信しない + this.connection.send({ + type: 'read_notification', + id: notification.id + }); + + this.notifications.unshift(notification); + } + } +}); +</script> + +<style lang="stylus" scoped> +.mk-notifications + > .notifications + > .notification + margin 0 + padding 16px + overflow-wrap break-word + font-size 0.9em + border-bottom solid 1px rgba(0, 0, 0, 0.05) + + &:last-child + border-bottom none + + > .mk-time + display inline + position absolute + top 16px + right 12px + vertical-align top + color rgba(0, 0, 0, 0.6) + font-size small + + &:after + content "" + display block + clear both + + > .avatar-anchor + display block + float left + position -webkit-sticky + position sticky + top 16px + + > img + display block + min-width 36px + min-height 36px + max-width 36px + max-height 36px + border-radius 6px + + > .text + float right + width calc(100% - 36px) + padding-left 8px + + p + margin 0 + + i, .mk-reaction-icon + margin-right 4px + + .post-preview + color rgba(0, 0, 0, 0.7) + + .post-ref + color rgba(0, 0, 0, 0.7) + + [data-fa] + font-size 1em + font-weight normal + font-style normal + display inline-block + margin-right 3px + + &.repost, &.quote + .text p i + color #77B255 + + &.follow + .text p i + color #53c7ce + + &.reply, &.mention + .text p i + color #555 + + > .date + display block + margin 0 + line-height 32px + text-align center + font-size 0.8em + color #aaa + background #fdfdfd + border-bottom solid 1px rgba(0, 0, 0, 0.05) + + span + margin 0 16px + + [data-fa] + margin-right 8px + + > .more + display block + width 100% + padding 16px + color #555 + border-top solid 1px rgba(0, 0, 0, 0.05) + + &:hover + background rgba(0, 0, 0, 0.025) + + &:active + background rgba(0, 0, 0, 0.05) + + &.fetching + cursor wait + + > [data-fa] + margin-right 4px + + > .empty + margin 0 + padding 16px + text-align center + color #aaa + + > .loading + margin 0 + padding 16px + text-align center + color #aaa + + > [data-fa] + margin-right 4px + +</style> diff --git a/src/server/web/app/desktop/views/components/post-detail.sub.vue b/src/server/web/app/desktop/views/components/post-detail.sub.vue new file mode 100644 index 0000000000..59d8db04ce --- /dev/null +++ b/src/server/web/app/desktop/views/components/post-detail.sub.vue @@ -0,0 +1,126 @@ +<template> +<div class="sub" :title="title"> + <router-link class="avatar-anchor" :to="`/@${acct}`"> + <img class="avatar" :src="`${post.user.avatar_url}?thumbnail&size=64`" alt="avatar" v-user-preview="post.user_id"/> + </router-link> + <div class="main"> + <header> + <div class="left"> + <router-link class="name" :to="`/@${acct}`" v-user-preview="post.user_id">{{ post.user.name }}</router-link> + <span class="username">@{{ acct }}</span> + </div> + <div class="right"> + <router-link class="time" :to="`/@${acct}/${post.id}`"> + <mk-time :time="post.created_at"/> + </router-link> + </div> + </header> + <div class="body"> + <mk-post-html v-if="post.ast" :ast="post.ast" :i="os.i" :class="$style.text"/> + <div class="media" v-if="post.media"> + <mk-media-list :media-list="post.media"/> + </div> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import dateStringify from '../../../common/scripts/date-stringify'; +import getAcct from '../../../../../common/user/get-acct'; + +export default Vue.extend({ + props: ['post'], + computed: { + acct() { + return getAcct(this.post.user); + }, + title(): string { + return dateStringify(this.post.created_at); + } + } +}); +</script> + +<style lang="stylus" scoped> +.sub + margin 0 + padding 20px 32px + background #fdfdfd + + &:after + content "" + display block + clear both + + &:hover + > .main > footer > button + color #888 + + > .avatar-anchor + display block + float left + margin 0 16px 0 0 + + > .avatar + display block + width 44px + height 44px + margin 0 + border-radius 4px + vertical-align bottom + + > .main + float left + width calc(100% - 60px) + + > header + margin-bottom 4px + white-space nowrap + + &:after + content "" + display block + clear both + + > .left + float left + + > .name + display inline + margin 0 + padding 0 + color #777 + font-size 1em + font-weight 700 + text-align left + text-decoration none + + &:hover + text-decoration underline + + > .username + text-align left + margin 0 0 0 8px + color #ccc + + > .right + float right + + > .time + font-size 0.9em + color #c0c0c0 + +</style> + +<style lang="stylus" module> +.text + cursor default + display block + margin 0 + padding 0 + overflow-wrap break-word + font-size 1em + color #717171 +</style> diff --git a/src/server/web/app/desktop/views/components/post-detail.vue b/src/server/web/app/desktop/views/components/post-detail.vue new file mode 100644 index 0000000000..f09bf4cbd5 --- /dev/null +++ b/src/server/web/app/desktop/views/components/post-detail.vue @@ -0,0 +1,433 @@ +<template> +<div class="mk-post-detail" :title="title"> + <button + class="read-more" + v-if="p.reply && p.reply.reply_id && context == null" + title="会話をもっと読み込む" + @click="fetchContext" + :disabled="contextFetching" + > + <template v-if="!contextFetching">%fa:ellipsis-v%</template> + <template v-if="contextFetching">%fa:spinner .pulse%</template> + </button> + <div class="context"> + <x-sub v-for="post in context" :key="post.id" :post="post"/> + </div> + <div class="reply-to" v-if="p.reply"> + <x-sub :post="p.reply"/> + </div> + <div class="repost" v-if="isRepost"> + <p> + <router-link class="avatar-anchor" :to="`/@${acct}`" v-user-preview="post.user_id"> + <img class="avatar" :src="`${post.user.avatar_url}?thumbnail&size=32`" alt="avatar"/> + </router-link> + %fa:retweet% + <router-link class="name" :href="`/@${acct}`">{{ post.user.name }}</router-link> + がRepost + </p> + </div> + <article> + <router-link class="avatar-anchor" :to="`/@${acct}`"> + <img class="avatar" :src="`${p.user.avatar_url}?thumbnail&size=64`" alt="avatar" v-user-preview="p.user.id"/> + </router-link> + <header> + <router-link class="name" :to="`/@${acct}`" v-user-preview="p.user.id">{{ p.user.name }}</router-link> + <span class="username">@{{ acct }}</span> + <router-link class="time" :to="`/@${acct}/${p.id}`"> + <mk-time :time="p.created_at"/> + </router-link> + </header> + <div class="body"> + <mk-post-html :class="$style.text" v-if="p.ast" :ast="p.ast" :i="os.i"/> + <div class="media" v-if="p.media"> + <mk-media-list :media-list="p.media"/> + </div> + <mk-poll v-if="p.poll" :post="p"/> + <mk-url-preview v-for="url in urls" :url="url" :key="url"/> + <div class="tags" v-if="p.tags && p.tags.length > 0"> + <router-link v-for="tag in p.tags" :key="tag" :to="`/search?q=#${tag}`">{{ tag }}</router-link> + </div> + <a class="location" v-if="p.geo" :href="`http://maps.google.com/maps?q=${p.geo.latitude},${p.geo.longitude}`" target="_blank">%fa:map-marker-alt% 位置情報</a> + <div class="map" v-if="p.geo" ref="map"></div> + <div class="repost" v-if="p.repost"> + <mk-post-preview :post="p.repost"/> + </div> + </div> + <footer> + <mk-reactions-viewer :post="p"/> + <button @click="reply" title="返信"> + %fa:reply%<p class="count" v-if="p.replies_count > 0">{{ p.replies_count }}</p> + </button> + <button @click="repost" title="Repost"> + %fa:retweet%<p class="count" v-if="p.repost_count > 0">{{ p.repost_count }}</p> + </button> + <button :class="{ reacted: p.my_reaction != null }" @click="react" ref="reactButton" title="リアクション"> + %fa:plus%<p class="count" v-if="p.reactions_count > 0">{{ p.reactions_count }}</p> + </button> + <button @click="menu" ref="menuButton"> + %fa:ellipsis-h% + </button> + </footer> + </article> + <div class="replies" v-if="!compact"> + <x-sub v-for="post in replies" :key="post.id" :post="post"/> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import dateStringify from '../../../common/scripts/date-stringify'; +import getAcct from '../../../../../common/user/get-acct'; + +import MkPostFormWindow from './post-form-window.vue'; +import MkRepostFormWindow from './repost-form-window.vue'; +import MkPostMenu from '../../../common/views/components/post-menu.vue'; +import MkReactionPicker from '../../../common/views/components/reaction-picker.vue'; +import XSub from './post-detail.sub.vue'; + +export default Vue.extend({ + components: { + XSub + }, + props: { + post: { + type: Object, + required: true + }, + compact: { + default: false + } + }, + computed: { + acct() { + return getAcct(this.post.user); + } + }, + data() { + return { + context: [], + contextFetching: false, + replies: [], + }; + }, + computed: { + isRepost(): boolean { + return (this.post.repost && + this.post.text == null && + this.post.media_ids == null && + this.post.poll == null); + }, + p(): any { + return this.isRepost ? this.post.repost : this.post; + }, + reactionsCount(): number { + return this.p.reaction_counts + ? Object.keys(this.p.reaction_counts) + .map(key => this.p.reaction_counts[key]) + .reduce((a, b) => a + b) + : 0; + }, + title(): string { + return dateStringify(this.p.created_at); + }, + urls(): string[] { + if (this.p.ast) { + return this.p.ast + .filter(t => (t.type == 'url' || t.type == 'link') && !t.silent) + .map(t => t.url); + } else { + return null; + } + } + }, + mounted() { + // Get replies + if (!this.compact) { + (this as any).api('posts/replies', { + post_id: this.p.id, + limit: 8 + }).then(replies => { + this.replies = replies; + }); + } + + // Draw map + if (this.p.geo) { + const shouldShowMap = (this as any).os.isSignedIn ? (this as any).os.i.account.client_settings.showMaps : true; + if (shouldShowMap) { + (this as any).os.getGoogleMaps().then(maps => { + const uluru = new maps.LatLng(this.p.geo.latitude, this.p.geo.longitude); + const map = new maps.Map(this.$refs.map, { + center: uluru, + zoom: 15 + }); + new maps.Marker({ + position: uluru, + map: map + }); + }); + } + } + }, + methods: { + fetchContext() { + this.contextFetching = true; + + // Fetch context + (this as any).api('posts/context', { + post_id: this.p.reply_id + }).then(context => { + this.contextFetching = false; + this.context = context.reverse(); + }); + }, + reply() { + (this as any).os.new(MkPostFormWindow, { + reply: this.p + }); + }, + repost() { + (this as any).os.new(MkRepostFormWindow, { + post: this.p + }); + }, + react() { + (this as any).os.new(MkReactionPicker, { + source: this.$refs.reactButton, + post: this.p + }); + }, + menu() { + (this as any).os.new(MkPostMenu, { + source: this.$refs.menuButton, + post: this.p + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +.mk-post-detail + margin 0 + padding 0 + overflow hidden + text-align left + background #fff + border solid 1px rgba(0, 0, 0, 0.1) + border-radius 8px + + > .read-more + display block + margin 0 + padding 10px 0 + width 100% + font-size 1em + text-align center + color #999 + cursor pointer + background #fafafa + outline none + border none + border-bottom solid 1px #eef0f2 + border-radius 6px 6px 0 0 + + &:hover + background #f6f6f6 + + &:active + background #f0f0f0 + + &:disabled + color #ccc + + > .context + > * + border-bottom 1px solid #eef0f2 + + > .repost + color #9dbb00 + background linear-gradient(to bottom, #edfde2 0%, #fff 100%) + + > p + margin 0 + padding 16px 32px + + .avatar-anchor + display inline-block + + .avatar + vertical-align bottom + min-width 28px + min-height 28px + max-width 28px + max-height 28px + margin 0 8px 0 0 + border-radius 6px + + [data-fa] + margin-right 4px + + .name + font-weight bold + + & + article + padding-top 8px + + > .reply-to + border-bottom 1px solid #eef0f2 + + > article + padding 28px 32px 18px 32px + + &:after + content "" + display block + clear both + + &:hover + > .main > footer > button + color #888 + + > .avatar-anchor + display block + width 60px + height 60px + + > .avatar + display block + width 60px + height 60px + margin 0 + border-radius 8px + vertical-align bottom + + > header + position absolute + top 28px + left 108px + width calc(100% - 108px) + + > .name + display inline-block + margin 0 + line-height 24px + color #777 + font-size 18px + font-weight 700 + text-align left + text-decoration none + + &:hover + text-decoration underline + + > .username + display block + text-align left + margin 0 + color #ccc + + > .time + position absolute + top 0 + right 32px + font-size 1em + color #c0c0c0 + + > .body + padding 8px 0 + + > .repost + margin 8px 0 + + > .mk-post-preview + padding 16px + border dashed 1px #c0dac6 + border-radius 8px + + > .location + margin 4px 0 + font-size 12px + color #ccc + + > .map + width 100% + height 300px + + &:empty + display none + + > .mk-url-preview + margin-top 8px + + > .tags + margin 4px 0 0 0 + + > * + display inline-block + margin 0 8px 0 0 + padding 2px 8px 2px 16px + font-size 90% + color #8d969e + background #edf0f3 + border-radius 4px + + &:before + content "" + display block + position absolute + top 0 + bottom 0 + left 4px + width 8px + height 8px + margin auto 0 + background #fff + border-radius 100% + + &:hover + text-decoration none + background #e2e7ec + + > footer + font-size 1.2em + + > button + margin 0 28px 0 0 + padding 8px + background transparent + border none + font-size 1em + color #ddd + cursor pointer + + &:hover + color #666 + + > .count + display inline + margin 0 0 0 8px + color #999 + + &.reacted + color $theme-color + + > .replies + > * + border-top 1px solid #eef0f2 + +</style> + +<style lang="stylus" module> +.text + cursor default + display block + margin 0 + padding 0 + overflow-wrap break-word + font-size 1.5em + color #717171 +</style> diff --git a/src/server/web/app/desktop/views/components/post-form-window.vue b/src/server/web/app/desktop/views/components/post-form-window.vue new file mode 100644 index 0000000000..d0b115e852 --- /dev/null +++ b/src/server/web/app/desktop/views/components/post-form-window.vue @@ -0,0 +1,76 @@ +<template> +<mk-window ref="window" is-modal @closed="$destroy"> + <span slot="header"> + <span :class="$style.icon" v-if="geo">%fa:map-marker-alt%</span> + <span v-if="!reply">%i18n:desktop.tags.mk-post-form-window.post%</span> + <span v-if="reply">%i18n:desktop.tags.mk-post-form-window.reply%</span> + <span :class="$style.count" v-if="media.length != 0">{{ '%i18n:desktop.tags.mk-post-form-window.attaches%'.replace('{}', media.length) }}</span> + <span :class="$style.count" v-if="uploadings.length != 0">{{ '%i18n:desktop.tags.mk-post-form-window.uploading-media%'.replace('{}', uploadings.length) }}<mk-ellipsis/></span> + </span> + + <mk-post-preview v-if="reply" :class="$style.postPreview" :post="reply"/> + <mk-post-form ref="form" + :reply="reply" + @posted="onPosted" + @change-uploadings="onChangeUploadings" + @change-attached-media="onChangeMedia" + @geo-attached="onGeoAttached" + @geo-dettached="onGeoDettached"/> +</mk-window> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +export default Vue.extend({ + props: ['reply'], + data() { + return { + uploadings: [], + media: [], + geo: null + }; + }, + mounted() { + this.$nextTick(() => { + (this.$refs.form as any).focus(); + }); + }, + methods: { + onChangeUploadings(files) { + this.uploadings = files; + }, + onChangeMedia(media) { + this.media = media; + }, + onGeoAttached(geo) { + this.geo = geo; + }, + onGeoDettached() { + this.geo = null; + }, + onPosted() { + (this.$refs.window as any).close(); + } + } +}); +</script> + +<style lang="stylus" module> +.icon + margin-right 8px + +.count + margin-left 8px + opacity 0.8 + + &:before + content '(' + + &:after + content ')' + +.postPreview + margin 16px 22px + +</style> diff --git a/src/server/web/app/desktop/views/components/post-form.vue b/src/server/web/app/desktop/views/components/post-form.vue new file mode 100644 index 0000000000..78f6d445af --- /dev/null +++ b/src/server/web/app/desktop/views/components/post-form.vue @@ -0,0 +1,537 @@ +<template> +<div class="mk-post-form" + @dragover.stop="onDragover" + @dragenter="onDragenter" + @dragleave="onDragleave" + @drop.stop="onDrop" +> + <div class="content"> + <textarea :class="{ with: (files.length != 0 || poll) }" + ref="text" v-model="text" :disabled="posting" + @keydown="onKeydown" @paste="onPaste" :placeholder="placeholder" + v-autocomplete="'text'" + ></textarea> + <div class="medias" :class="{ with: poll }" v-show="files.length != 0"> + <x-draggable :list="files" :options="{ animation: 150 }"> + <div v-for="file in files" :key="file.id"> + <div class="img" :style="{ backgroundImage: `url(${file.url}?thumbnail&size=64)` }" :title="file.name"></div> + <img class="remove" @click="detachMedia(file.id)" src="/assets/desktop/remove.png" title="%i18n:desktop.tags.mk-post-form.attach-cancel%" alt=""/> + </div> + </x-draggable> + <p class="remain">{{ 4 - files.length }}/4</p> + </div> + <mk-poll-editor v-if="poll" ref="poll" @destroyed="poll = false" @updated="saveDraft()"/> + </div> + <mk-uploader ref="uploader" @uploaded="attachMedia" @change="onChangeUploadings"/> + <button class="upload" title="%i18n:desktop.tags.mk-post-form.attach-media-from-local%" @click="chooseFile">%fa:upload%</button> + <button class="drive" title="%i18n:desktop.tags.mk-post-form.attach-media-from-drive%" @click="chooseFileFromDrive">%fa:cloud%</button> + <button class="kao" title="%i18n:desktop.tags.mk-post-form.insert-a-kao%" @click="kao">%fa:R smile%</button> + <button class="poll" title="%i18n:desktop.tags.mk-post-form.create-poll%" @click="poll = true">%fa:chart-pie%</button> + <button class="geo" title="位置情報を添付する" @click="geo ? removeGeo() : setGeo()">%fa:map-marker-alt%</button> + <p class="text-count" :class="{ over: text.length > 1000 }">{{ '%i18n:desktop.tags.mk-post-form.text-remain%'.replace('{}', 1000 - text.length) }}</p> + <button :class="{ posting }" class="submit" :disabled="!canPost" @click="post"> + {{ posting ? '%i18n:desktop.tags.mk-post-form.posting%' : submitText }}<mk-ellipsis v-if="posting"/> + </button> + <input ref="file" type="file" accept="image/*" multiple="multiple" tabindex="-1" @change="onChangeFile"/> + <div class="dropzone" v-if="draghover"></div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import * as XDraggable from 'vuedraggable'; +import getKao from '../../../common/scripts/get-kao'; + +export default Vue.extend({ + components: { + XDraggable + }, + props: ['reply', 'repost'], + data() { + return { + posting: false, + text: '', + files: [], + uploadings: [], + poll: false, + geo: null, + autocomplete: null, + draghover: false + }; + }, + computed: { + draftId(): string { + return this.repost + ? 'repost:' + this.repost.id + : this.reply + ? 'reply:' + this.reply.id + : 'post'; + }, + placeholder(): string { + return this.repost + ? '%i18n:desktop.tags.mk-post-form.quote-placeholder%' + : this.reply + ? '%i18n:desktop.tags.mk-post-form.reply-placeholder%' + : '%i18n:desktop.tags.mk-post-form.post-placeholder%'; + }, + submitText(): string { + return this.repost + ? '%i18n:desktop.tags.mk-post-form.repost%' + : this.reply + ? '%i18n:desktop.tags.mk-post-form.reply%' + : '%i18n:desktop.tags.mk-post-form.post%'; + }, + canPost(): boolean { + return !this.posting && (this.text.length != 0 || this.files.length != 0 || this.poll || this.repost); + } + }, + watch: { + text() { + this.saveDraft(); + }, + poll() { + this.saveDraft(); + }, + files() { + this.saveDraft(); + } + }, + mounted() { + this.$nextTick(() => { + // 書きかけの投稿を復元 + const draft = JSON.parse(localStorage.getItem('drafts') || '{}')[this.draftId]; + if (draft) { + this.text = draft.data.text; + this.files = draft.data.files; + if (draft.data.poll) { + this.poll = true; + this.$nextTick(() => { + (this.$refs.poll as any).set(draft.data.poll); + }); + } + this.$emit('change-attached-media', this.files); + } + }); + }, + methods: { + focus() { + (this.$refs.text as any).focus(); + }, + chooseFile() { + (this.$refs.file as any).click(); + }, + chooseFileFromDrive() { + (this as any).apis.chooseDriveFile({ + multiple: true + }).then(files => { + files.forEach(this.attachMedia); + }); + }, + attachMedia(driveFile) { + this.files.push(driveFile); + this.$emit('change-attached-media', this.files); + }, + detachMedia(id) { + this.files = this.files.filter(x => x.id != id); + this.$emit('change-attached-media', this.files); + }, + onChangeFile() { + Array.from((this.$refs.file as any).files).forEach(this.upload); + }, + upload(file) { + (this.$refs.uploader as any).upload(file); + }, + onChangeUploadings(uploads) { + this.$emit('change-uploadings', uploads); + }, + clear() { + this.text = ''; + this.files = []; + this.poll = false; + this.$emit('change-attached-media', this.files); + }, + onKeydown(e) { + if ((e.which == 10 || e.which == 13) && (e.ctrlKey || e.metaKey)) this.post(); + }, + onPaste(e) { + Array.from(e.clipboardData.items).forEach((item: any) => { + if (item.kind == 'file') { + this.upload(item.getAsFile()); + } + }); + }, + onDragover(e) { + const isFile = e.dataTransfer.items[0].kind == 'file'; + const isDriveFile = e.dataTransfer.types[0] == 'mk_drive_file'; + if (isFile || isDriveFile) { + e.preventDefault(); + this.draghover = true; + e.dataTransfer.dropEffect = e.dataTransfer.effectAllowed == 'all' ? 'copy' : 'move'; + } + }, + onDragenter(e) { + this.draghover = true; + }, + onDragleave(e) { + this.draghover = false; + }, + onDrop(e): void { + this.draghover = false; + + // ファイルだったら + if (e.dataTransfer.files.length > 0) { + e.preventDefault(); + Array.from(e.dataTransfer.files).forEach(this.upload); + return; + } + + //#region ドライブのファイル + const driveFile = e.dataTransfer.getData('mk_drive_file'); + if (driveFile != null && driveFile != '') { + const file = JSON.parse(driveFile); + this.files.push(file); + this.$emit('change-attached-media', this.files); + e.preventDefault(); + } + //#endregion + }, + setGeo() { + if (navigator.geolocation == null) { + alert('お使いの端末は位置情報に対応していません'); + return; + } + + navigator.geolocation.getCurrentPosition(pos => { + this.geo = pos.coords; + this.$emit('geo-attached', this.geo); + }, err => { + alert('エラー: ' + err.message); + }, { + enableHighAccuracy: true + }); + }, + removeGeo() { + this.geo = null; + this.$emit('geo-dettached'); + }, + post() { + this.posting = true; + + (this as any).api('posts/create', { + text: this.text == '' ? undefined : this.text, + media_ids: this.files.length > 0 ? this.files.map(f => f.id) : undefined, + reply_id: this.reply ? this.reply.id : undefined, + repost_id: this.repost ? this.repost.id : undefined, + poll: this.poll ? (this.$refs.poll as any).get() : undefined, + geo: this.geo ? { + latitude: this.geo.latitude, + longitude: this.geo.longitude, + altitude: this.geo.altitude, + accuracy: this.geo.accuracy, + altitudeAccuracy: this.geo.altitudeAccuracy, + heading: isNaN(this.geo.heading) ? null : this.geo.heading, + speed: this.geo.speed, + } : null + }).then(data => { + this.clear(); + this.deleteDraft(); + this.$emit('posted'); + (this as any).apis.notify(this.repost + ? '%i18n:desktop.tags.mk-post-form.reposted%' + : this.reply + ? '%i18n:desktop.tags.mk-post-form.replied%' + : '%i18n:desktop.tags.mk-post-form.posted%'); + }).catch(err => { + (this as any).apis.notify(this.repost + ? '%i18n:desktop.tags.mk-post-form.repost-failed%' + : this.reply + ? '%i18n:desktop.tags.mk-post-form.reply-failed%' + : '%i18n:desktop.tags.mk-post-form.post-failed%'); + }).then(() => { + this.posting = false; + }); + }, + saveDraft() { + const data = JSON.parse(localStorage.getItem('drafts') || '{}'); + + data[this.draftId] = { + updated_at: new Date(), + data: { + text: this.text, + files: this.files, + poll: this.poll && this.$refs.poll ? (this.$refs.poll as any).get() : undefined + } + } + + localStorage.setItem('drafts', JSON.stringify(data)); + }, + deleteDraft() { + const data = JSON.parse(localStorage.getItem('drafts') || '{}'); + + delete data[this.draftId]; + + localStorage.setItem('drafts', JSON.stringify(data)); + }, + kao() { + this.text += getKao(); + } + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +.mk-post-form + display block + padding 16px + background lighten($theme-color, 95%) + + &:after + content "" + display block + clear both + + > .content + + textarea + display block + padding 12px + margin 0 + width 100% + max-width 100% + min-width 100% + min-height calc(16px + 12px + 12px) + font-size 16px + color #333 + background #fff + outline none + border solid 1px rgba($theme-color, 0.1) + border-radius 4px + transition border-color .3s ease + + &:hover + border-color rgba($theme-color, 0.2) + transition border-color .1s ease + + & + * + & + * + * + border-color rgba($theme-color, 0.2) + transition border-color .1s ease + + &:focus + color $theme-color + border-color rgba($theme-color, 0.5) + transition border-color 0s ease + + & + * + & + * + * + border-color rgba($theme-color, 0.5) + transition border-color 0s ease + + &:disabled + opacity 0.5 + + &::-webkit-input-placeholder + color rgba($theme-color, 0.3) + + &.with + border-bottom solid 1px rgba($theme-color, 0.1) !important + border-radius 4px 4px 0 0 + + > .medias + margin 0 + padding 0 + background lighten($theme-color, 98%) + border solid 1px rgba($theme-color, 0.1) + border-top none + border-radius 0 0 4px 4px + transition border-color .3s ease + + &.with + border-bottom solid 1px rgba($theme-color, 0.1) !important + border-radius 0 + + > .remain + display block + position absolute + top 8px + right 8px + margin 0 + padding 0 + color rgba($theme-color, 0.4) + + > div + padding 4px + + &:after + content "" + display block + clear both + + > div + float left + border solid 4px transparent + cursor move + + &:hover > .remove + display block + + > .img + width 64px + height 64px + background-size cover + background-position center center + + > .remove + display none + position absolute + top -6px + right -6px + width 16px + height 16px + cursor pointer + + > .mk-poll-editor + background lighten($theme-color, 98%) + border solid 1px rgba($theme-color, 0.1) + border-top none + border-radius 0 0 4px 4px + transition border-color .3s ease + + > .mk-uploader + margin 8px 0 0 0 + padding 8px + border solid 1px rgba($theme-color, 0.2) + border-radius 4px + + input[type='file'] + display none + + .text-count + pointer-events none + display block + position absolute + bottom 16px + right 138px + margin 0 + line-height 40px + color rgba($theme-color, 0.5) + + &.over + color #ec3828 + + .submit + display block + position absolute + bottom 16px + right 16px + cursor pointer + padding 0 + margin 0 + width 110px + height 40px + font-size 1em + color $theme-color-foreground + background linear-gradient(to bottom, lighten($theme-color, 25%) 0%, lighten($theme-color, 10%) 100%) + outline none + border solid 1px lighten($theme-color, 15%) + border-radius 4px + + &:not(:disabled) + font-weight bold + + &:hover:not(:disabled) + background linear-gradient(to bottom, lighten($theme-color, 8%) 0%, darken($theme-color, 8%) 100%) + border-color $theme-color + + &:active:not(:disabled) + background $theme-color + border-color $theme-color + + &: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 + + &.wait + background linear-gradient( + 45deg, + darken($theme-color, 10%) 25%, + $theme-color 25%, + $theme-color 50%, + darken($theme-color, 10%) 50%, + darken($theme-color, 10%) 75%, + $theme-color 75%, + $theme-color + ) + background-size 32px 32px + animation stripe-bg 1.5s linear infinite + opacity 0.7 + cursor wait + + @keyframes stripe-bg + from {background-position: 0 0;} + to {background-position: -64px 32px;} + + > .upload + > .drive + > .kao + > .poll + > .geo + display inline-block + cursor pointer + padding 0 + margin 8px 4px 0 0 + width 40px + height 40px + font-size 1em + color rgba($theme-color, 0.5) + background transparent + outline none + border solid 1px transparent + border-radius 4px + + &:hover + background transparent + border-color rgba($theme-color, 0.3) + + &:active + color rgba($theme-color, 0.6) + background linear-gradient(to bottom, lighten($theme-color, 80%) 0%, lighten($theme-color, 90%) 100%) + border-color rgba($theme-color, 0.5) + box-shadow 0 2px 4px rgba(0, 0, 0, 0.15) inset + + &: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 + + > .dropzone + position absolute + left 0 + top 0 + width 100% + height 100% + border dashed 2px rgba($theme-color, 0.5) + pointer-events none + +</style> diff --git a/src/server/web/app/desktop/views/components/post-preview.vue b/src/server/web/app/desktop/views/components/post-preview.vue new file mode 100644 index 0000000000..808220c0e0 --- /dev/null +++ b/src/server/web/app/desktop/views/components/post-preview.vue @@ -0,0 +1,103 @@ +<template> +<div class="mk-post-preview" :title="title"> + <router-link class="avatar-anchor" :to="`/@${acct}`"> + <img class="avatar" :src="`${post.user.avatar_url}?thumbnail&size=64`" alt="avatar" v-user-preview="post.user_id"/> + </router-link> + <div class="main"> + <header> + <router-link class="name" :to="`/@${acct}`" v-user-preview="post.user_id">{{ post.user.name }}</router-link> + <span class="username">@{{ acct }}</span> + <router-link class="time" :to="`/@${acct}/${post.id}`"> + <mk-time :time="post.created_at"/> + </router-link> + </header> + <div class="body"> + <mk-sub-post-content class="text" :post="post"/> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import dateStringify from '../../../common/scripts/date-stringify'; +import getAcct from '../../../../../common/user/get-acct'; + +export default Vue.extend({ + props: ['post'], + computed: { + acct() { + return getAcct(this.post.user); + }, + title(): string { + return dateStringify(this.post.created_at); + } + } +}); +</script> + +<style lang="stylus" scoped> +.mk-post-preview + font-size 0.9em + background #fff + + &:after + content "" + display block + clear both + + &:hover + > .main > footer > button + color #888 + + > .avatar-anchor + display block + float left + margin 0 16px 0 0 + + > .avatar + display block + width 52px + height 52px + margin 0 + border-radius 8px + vertical-align bottom + + > .main + float left + width calc(100% - 68px) + + > header + display flex + white-space nowrap + + > .name + margin 0 .5em 0 0 + padding 0 + color #607073 + font-size 1em + font-weight bold + text-decoration none + white-space normal + + &:hover + text-decoration underline + + > .username + margin 0 .5em 0 0 + color #d1d8da + + > .time + margin-left auto + color #b2b8bb + + > .body + + > .text + cursor default + margin 0 + padding 0 + font-size 1.1em + color #717171 + +</style> diff --git a/src/server/web/app/desktop/views/components/posts.post.sub.vue b/src/server/web/app/desktop/views/components/posts.post.sub.vue new file mode 100644 index 0000000000..120700877c --- /dev/null +++ b/src/server/web/app/desktop/views/components/posts.post.sub.vue @@ -0,0 +1,112 @@ +<template> +<div class="sub" :title="title"> + <router-link class="avatar-anchor" :to="`/@${acct}`"> + <img class="avatar" :src="`${post.user.avatar_url}?thumbnail&size=64`" alt="avatar" v-user-preview="post.user_id"/> + </router-link> + <div class="main"> + <header> + <router-link class="name" :to="`/@${acct}`" v-user-preview="post.user_id">{{ post.user.name }}</router-link> + <span class="username">@{{ acct }}</span> + <router-link class="created-at" :to="`/@${acct}/${post.id}`"> + <mk-time :time="post.created_at"/> + </router-link> + </header> + <div class="body"> + <mk-sub-post-content class="text" :post="post"/> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import dateStringify from '../../../common/scripts/date-stringify'; +import getAcct from '../../../../../common/user/get-acct'; + +export default Vue.extend({ + props: ['post'], + computed: { + acct() { + return getAcct(this.post.user); + }, + title(): string { + return dateStringify(this.post.created_at); + } + } +}); +</script> + +<style lang="stylus" scoped> +.sub + margin 0 + padding 16px + font-size 0.9em + + &:after + content "" + display block + clear both + + &:hover + > .main > footer > button + color #888 + + > .avatar-anchor + display block + float left + margin 0 14px 0 0 + + > .avatar + display block + width 52px + height 52px + margin 0 + border-radius 8px + vertical-align bottom + + > .main + float left + width calc(100% - 66px) + + > header + display flex + margin-bottom 2px + white-space nowrap + line-height 21px + + > .name + display block + margin 0 .5em 0 0 + padding 0 + overflow hidden + color #607073 + font-size 1em + font-weight bold + text-decoration none + text-overflow ellipsis + + &:hover + text-decoration underline + + > .username + margin 0 .5em 0 0 + color #d1d8da + + > .created-at + margin-left auto + color #b2b8bb + + > .body + + > .text + cursor default + margin 0 + padding 0 + font-size 1.1em + color #717171 + + pre + max-height 120px + font-size 80% + +</style> diff --git a/src/server/web/app/desktop/views/components/posts.post.vue b/src/server/web/app/desktop/views/components/posts.post.vue new file mode 100644 index 0000000000..6b4d3d2789 --- /dev/null +++ b/src/server/web/app/desktop/views/components/posts.post.vue @@ -0,0 +1,582 @@ +<template> +<div class="post" tabindex="-1" :title="title" @keydown="onKeydown"> + <div class="reply-to" v-if="p.reply"> + <x-sub :post="p.reply"/> + </div> + <div class="repost" v-if="isRepost"> + <p> + <router-link class="avatar-anchor" :to="`/@${acct}`" v-user-preview="post.user_id"> + <img class="avatar" :src="`${post.user.avatar_url}?thumbnail&size=32`" alt="avatar"/> + </router-link> + %fa:retweet% + <span>{{ '%i18n:desktop.tags.mk-timeline-post.reposted-by%'.substr(0, '%i18n:desktop.tags.mk-timeline-post.reposted-by%'.indexOf('{')) }}</span> + <a class="name" :href="`/@${acct}`" v-user-preview="post.user_id">{{ post.user.name }}</a> + <span>{{ '%i18n:desktop.tags.mk-timeline-post.reposted-by%'.substr('%i18n:desktop.tags.mk-timeline-post.reposted-by%'.indexOf('}') + 1) }}</span> + </p> + <mk-time :time="post.created_at"/> + </div> + <article> + <router-link class="avatar-anchor" :to="`/@${acct}`"> + <img class="avatar" :src="`${p.user.avatar_url}?thumbnail&size=64`" alt="avatar" v-user-preview="p.user.id"/> + </router-link> + <div class="main"> + <header> + <router-link class="name" :to="`/@${acct}`" v-user-preview="p.user.id">{{ acct }}</router-link> + <span class="is-bot" v-if="p.user.host === null && p.user.account.is_bot">bot</span> + <span class="username">@{{ acct }}</span> + <div class="info"> + <span class="app" v-if="p.app">via <b>{{ p.app.name }}</b></span> + <span class="mobile" v-if="p.via_mobile">%fa:mobile-alt%</span> + <router-link class="created-at" :to="url"> + <mk-time :time="p.created_at"/> + </router-link> + </div> + </header> + <div class="body"> + <p class="channel" v-if="p.channel"> + <a :href="`${_CH_URL_}/${p.channel.id}`" target="_blank">{{ p.channel.title }}</a>: + </p> + <div class="text"> + <a class="reply" v-if="p.reply">%fa:reply%</a> + <mk-post-html v-if="p.ast" :ast="p.ast" :i="os.i" :class="$style.text"/> + <a class="rp" v-if="p.repost">RP:</a> + </div> + <div class="media" v-if="p.media"> + <mk-media-list :media-list="p.media"/> + </div> + <mk-poll v-if="p.poll" :post="p" ref="pollViewer"/> + <div class="tags" v-if="p.tags && p.tags.length > 0"> + <router-link v-for="tag in p.tags" :key="tag" :to="`/search?q=#${tag}`">{{ tag }}</router-link> + </div> + <a class="location" v-if="p.geo" :href="`http://maps.google.com/maps?q=${p.geo.latitude},${p.geo.longitude}`" target="_blank">%fa:map-marker-alt% 位置情報</a> + <div class="map" v-if="p.geo" ref="map"></div> + <div class="repost" v-if="p.repost"> + <mk-post-preview :post="p.repost"/> + </div> + <mk-url-preview v-for="url in urls" :url="url" :key="url"/> + </div> + <footer> + <mk-reactions-viewer :post="p" ref="reactionsViewer"/> + <button @click="reply" title="%i18n:desktop.tags.mk-timeline-post.reply%"> + %fa:reply%<p class="count" v-if="p.replies_count > 0">{{ p.replies_count }}</p> + </button> + <button @click="repost" title="%i18n:desktop.tags.mk-timeline-post.repost%"> + %fa:retweet%<p class="count" v-if="p.repost_count > 0">{{ p.repost_count }}</p> + </button> + <button :class="{ reacted: p.my_reaction != null }" @click="react" ref="reactButton" title="%i18n:desktop.tags.mk-timeline-post.add-reaction%"> + %fa:plus%<p class="count" v-if="p.reactions_count > 0">{{ p.reactions_count }}</p> + </button> + <button @click="menu" ref="menuButton"> + %fa:ellipsis-h% + </button> + <button title="%i18n:desktop.tags.mk-timeline-post.detail"> + <template v-if="!isDetailOpened">%fa:caret-down%</template> + <template v-if="isDetailOpened">%fa:caret-up%</template> + </button> + </footer> + </div> + </article> + <div class="detail" v-if="isDetailOpened"> + <mk-post-status-graph width="462" height="130" :post="p"/> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import dateStringify from '../../../common/scripts/date-stringify'; +import getAcct from '../../../../../common/user/get-acct'; +import MkPostFormWindow from './post-form-window.vue'; +import MkRepostFormWindow from './repost-form-window.vue'; +import MkPostMenu from '../../../common/views/components/post-menu.vue'; +import MkReactionPicker from '../../../common/views/components/reaction-picker.vue'; +import XSub from './posts.post.sub.vue'; + +function focus(el, fn) { + const target = fn(el); + if (target) { + if (target.hasAttribute('tabindex')) { + target.focus(); + } else { + focus(target, fn); + } + } +} + +export default Vue.extend({ + components: { + XSub + }, + props: ['post'], + data() { + return { + isDetailOpened: false, + connection: null, + connectionId: null + }; + }, + computed: { + acct() { + return getAcct(this.p.user); + }, + isRepost(): boolean { + return (this.post.repost && + this.post.text == null && + this.post.media_ids == null && + this.post.poll == null); + }, + p(): any { + return this.isRepost ? this.post.repost : this.post; + }, + reactionsCount(): number { + return this.p.reaction_counts + ? Object.keys(this.p.reaction_counts) + .map(key => this.p.reaction_counts[key]) + .reduce((a, b) => a + b) + : 0; + }, + title(): string { + return dateStringify(this.p.created_at); + }, + url(): string { + return `/@${this.acct}/${this.p.id}`; + }, + urls(): string[] { + if (this.p.ast) { + return this.p.ast + .filter(t => (t.type == 'url' || t.type == 'link') && !t.silent) + .map(t => t.url); + } else { + return null; + } + } + }, + created() { + if ((this as any).os.isSignedIn) { + this.connection = (this as any).os.stream.getConnection(); + this.connectionId = (this as any).os.stream.use(); + } + }, + mounted() { + this.capture(true); + + if ((this as any).os.isSignedIn) { + this.connection.on('_connected_', this.onStreamConnected); + } + + // Draw map + if (this.p.geo) { + const shouldShowMap = (this as any).os.isSignedIn ? (this as any).os.i.account.client_settings.showMaps : true; + if (shouldShowMap) { + (this as any).os.getGoogleMaps().then(maps => { + const uluru = new maps.LatLng(this.p.geo.latitude, this.p.geo.longitude); + const map = new maps.Map(this.$refs.map, { + center: uluru, + zoom: 15 + }); + new maps.Marker({ + position: uluru, + map: map + }); + }); + } + } + }, + beforeDestroy() { + this.decapture(true); + + if ((this as any).os.isSignedIn) { + this.connection.off('_connected_', this.onStreamConnected); + (this as any).os.stream.dispose(this.connectionId); + } + }, + methods: { + capture(withHandler = false) { + if ((this as any).os.isSignedIn) { + this.connection.send({ + type: 'capture', + id: this.p.id + }); + if (withHandler) this.connection.on('post-updated', this.onStreamPostUpdated); + } + }, + decapture(withHandler = false) { + if ((this as any).os.isSignedIn) { + this.connection.send({ + type: 'decapture', + id: this.p.id + }); + if (withHandler) this.connection.off('post-updated', this.onStreamPostUpdated); + } + }, + onStreamConnected() { + this.capture(); + }, + onStreamPostUpdated(data) { + const post = data.post; + if (post.id == this.post.id) { + this.$emit('update:post', post); + } else if (post.id == this.post.repost_id) { + this.post.repost = post; + } + }, + reply() { + (this as any).os.new(MkPostFormWindow, { + reply: this.p + }); + }, + repost() { + (this as any).os.new(MkRepostFormWindow, { + post: this.p + }); + }, + react() { + (this as any).os.new(MkReactionPicker, { + source: this.$refs.reactButton, + post: this.p + }); + }, + menu() { + (this as any).os.new(MkPostMenu, { + source: this.$refs.menuButton, + post: this.p + }); + }, + onKeydown(e) { + let shouldBeCancel = true; + + switch (true) { + case e.which == 38: // [↑] + case e.which == 74: // [j] + case e.which == 9 && e.shiftKey: // [Shift] + [Tab] + focus(this.$el, e => e.previousElementSibling); + break; + + case e.which == 40: // [↓] + case e.which == 75: // [k] + case e.which == 9: // [Tab] + focus(this.$el, e => e.nextElementSibling); + break; + + case e.which == 81: // [q] + case e.which == 69: // [e] + this.repost(); + break; + + case e.which == 70: // [f] + case e.which == 76: // [l] + //this.like(); + break; + + case e.which == 82: // [r] + this.reply(); + break; + + default: + shouldBeCancel = false; + } + + if (shouldBeCancel) e.preventDefault(); + } + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +.post + margin 0 + padding 0 + background #fff + border-bottom solid 1px #eaeaea + + &:first-child + border-top-left-radius 6px + border-top-right-radius 6px + + > .repost + border-top-left-radius 6px + border-top-right-radius 6px + + &:last-of-type + border-bottom none + + &:focus + z-index 1 + + &:after + content "" + pointer-events none + position absolute + top 2px + right 2px + bottom 2px + left 2px + border 2px solid rgba($theme-color, 0.3) + border-radius 4px + + > .repost + color #9dbb00 + background linear-gradient(to bottom, #edfde2 0%, #fff 100%) + + > p + margin 0 + padding 16px 32px + line-height 28px + + .avatar-anchor + display inline-block + + .avatar + vertical-align bottom + width 28px + height 28px + margin 0 8px 0 0 + border-radius 6px + + [data-fa] + margin-right 4px + + .name + font-weight bold + + > .mk-time + position absolute + top 16px + right 32px + font-size 0.9em + line-height 28px + + & + article + padding-top 8px + + > .reply-to + padding 0 16px + background rgba(0, 0, 0, 0.0125) + + > .mk-post-preview + background transparent + + > article + padding 28px 32px 18px 32px + + &:after + content "" + display block + clear both + + &:hover + > .main > footer > button + color #888 + + > .avatar-anchor + display block + float left + margin 0 16px 10px 0 + //position -webkit-sticky + //position sticky + //top 74px + + > .avatar + display block + width 58px + height 58px + margin 0 + border-radius 8px + vertical-align bottom + + > .main + float left + width calc(100% - 74px) + + > header + display flex + align-items center + margin-bottom 4px + white-space nowrap + + > .name + display block + margin 0 .5em 0 0 + padding 0 + overflow hidden + color #627079 + font-size 1em + font-weight bold + text-decoration none + text-overflow ellipsis + + &:hover + text-decoration underline + + > .is-bot + margin 0 .5em 0 0 + padding 1px 6px + font-size 12px + color #aaa + border solid 1px #ddd + border-radius 3px + + > .username + margin 0 .5em 0 0 + color #ccc + + > .info + margin-left auto + font-size 0.9em + + > .mobile + margin-right 8px + color #ccc + + > .app + margin-right 8px + padding-right 8px + color #ccc + border-right solid 1px #eaeaea + + > .created-at + color #c0c0c0 + + > .body + + > .text + cursor default + display block + margin 0 + padding 0 + overflow-wrap break-word + font-size 1.1em + color #717171 + + >>> .quote + margin 8px + padding 6px 12px + color #aaa + border-left solid 3px #eee + + > .reply + margin-right 8px + color #717171 + + > .rp + margin-left 4px + font-style oblique + color #a0bf46 + + > .location + margin 4px 0 + font-size 12px + color #ccc + + > .map + width 100% + height 300px + + &:empty + display none + + > .tags + margin 4px 0 0 0 + + > * + display inline-block + margin 0 8px 0 0 + padding 2px 8px 2px 16px + font-size 90% + color #8d969e + background #edf0f3 + border-radius 4px + + &:before + content "" + display block + position absolute + top 0 + bottom 0 + left 4px + width 8px + height 8px + margin auto 0 + background #fff + border-radius 100% + + &:hover + text-decoration none + background #e2e7ec + + .mk-url-preview + margin-top 8px + + > .channel + margin 0 + + > .mk-poll + font-size 80% + + > .repost + margin 8px 0 + + > .mk-post-preview + padding 16px + border dashed 1px #c0dac6 + border-radius 8px + + > footer + > button + margin 0 28px 0 0 + padding 0 8px + line-height 32px + font-size 1em + color #ddd + background transparent + border none + cursor pointer + + &:hover + color #666 + + > .count + display inline + margin 0 0 0 8px + color #999 + + &.reacted + color $theme-color + + &:last-child + position absolute + right 0 + margin 0 + + > .detail + padding-top 4px + background rgba(0, 0, 0, 0.0125) + +</style> + +<style lang="stylus" module> +.text + + code + padding 4px 8px + margin 0 0.5em + font-size 80% + color #525252 + background #f8f8f8 + border-radius 2px + + pre > code + padding 16px + margin 0 + + [data-is-me]:after + content "you" + padding 0 4px + margin-left 4px + font-size 80% + color $theme-color-foreground + background $theme-color + border-radius 4px +</style> diff --git a/src/server/web/app/desktop/views/components/posts.vue b/src/server/web/app/desktop/views/components/posts.vue new file mode 100644 index 0000000000..ffceff8762 --- /dev/null +++ b/src/server/web/app/desktop/views/components/posts.vue @@ -0,0 +1,89 @@ +<template> +<div class="mk-posts"> + <template v-for="(post, i) in _posts"> + <x-post :post="post" :key="post.id" @update:post="onPostUpdated(i, $event)"/> + <p class="date" v-if="i != posts.length - 1 && post._date != _posts[i + 1]._date"> + <span>%fa:angle-up%{{ post._datetext }}</span> + <span>%fa:angle-down%{{ _posts[i + 1]._datetext }}</span> + </p> + </template> + <footer> + <slot name="footer"></slot> + </footer> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import XPost from './posts.post.vue'; + +export default Vue.extend({ + components: { + XPost + }, + props: { + posts: { + type: Array, + default: () => [] + } + }, + computed: { + _posts(): any[] { + return (this.posts as any).map(post => { + const date = new Date(post.created_at).getDate(); + const month = new Date(post.created_at).getMonth() + 1; + post._date = date; + post._datetext = `${month}月 ${date}日`; + return post; + }); + } + }, + methods: { + focus() { + (this.$el as any).children[0].focus(); + }, + onPostUpdated(i, post) { + Vue.set((this as any).posts, i, post); + } + } +}); +</script> + +<style lang="stylus" scoped> +.mk-posts + + > .date + display block + margin 0 + line-height 32px + font-size 14px + text-align center + color #aaa + background #fdfdfd + border-bottom solid 1px #eaeaea + + span + margin 0 16px + + [data-fa] + margin-right 8px + + > footer + > * + display block + margin 0 + padding 16px + width 100% + text-align center + color #ccc + border-top solid 1px #eaeaea + border-bottom-left-radius 4px + border-bottom-right-radius 4px + + > button + &:hover + background #f5f5f5 + + &:active + background #eee +</style> diff --git a/src/server/web/app/desktop/views/components/progress-dialog.vue b/src/server/web/app/desktop/views/components/progress-dialog.vue new file mode 100644 index 0000000000..a4292e1aec --- /dev/null +++ b/src/server/web/app/desktop/views/components/progress-dialog.vue @@ -0,0 +1,95 @@ +<template> +<mk-window ref="window" :is-modal="false" :can-close="false" width="500px" @closed="$destroy"> + <span slot="header">{{ title }}<mk-ellipsis/></span> + <div :class="$style.body"> + <p :class="$style.init" v-if="isNaN(value)">待機中<mk-ellipsis/></p> + <p :class="$style.percentage" v-if="!isNaN(value)">{{ Math.floor((value / max) * 100) }}</p> + <progress :class="$style.progress" + v-if="!isNaN(value) && value < max" + :value="isNaN(value) ? 0 : value" + :max="max" + ></progress> + <div :class="[$style.progress, $style.waiting]" v-if="value >= max"></div> + </div> +</mk-window> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + props: ['title', 'initValue', 'initMax'], + data() { + return { + value: this.initValue, + max: this.initMax + }; + }, + methods: { + update(value, max) { + this.value = parseInt(value, 10); + this.max = parseInt(max, 10); + }, + close() { + (this.$refs.window as any).close(); + } + } +}); +</script> + +<style lang="stylus" module> +@import '~const.styl' + +.body + padding 18px 24px 24px 24px + +.init + display block + margin 0 + text-align center + color rgba(#000, 0.7) + +.percentage + display block + margin 0 0 4px 0 + text-align center + line-height 16px + color rgba($theme-color, 0.7) + + &:after + content '%' + +.progress + display block + margin 0 + width 100% + height 10px + background transparent + border none + border-radius 4px + overflow hidden + + &::-webkit-progress-value + background $theme-color + + &::-webkit-progress-bar + background rgba($theme-color, 0.1) + +.waiting + background linear-gradient( + 45deg, + lighten($theme-color, 30%) 25%, + $theme-color 25%, + $theme-color 50%, + lighten($theme-color, 30%) 50%, + lighten($theme-color, 30%) 75%, + $theme-color 75%, + $theme-color + ) + background-size 32px 32px + animation progress-dialog-tag-progress-waiting 1.5s linear infinite + + @keyframes progress-dialog-tag-progress-waiting + from {background-position: 0 0;} + to {background-position: -64px 32px;} + +</style> diff --git a/src/server/web/app/desktop/views/components/repost-form-window.vue b/src/server/web/app/desktop/views/components/repost-form-window.vue new file mode 100644 index 0000000000..7db5adbff3 --- /dev/null +++ b/src/server/web/app/desktop/views/components/repost-form-window.vue @@ -0,0 +1,42 @@ +<template> +<mk-window ref="window" is-modal @closed="$destroy"> + <span slot="header" :class="$style.header">%fa:retweet%%i18n:desktop.tags.mk-repost-form-window.title%</span> + <mk-repost-form ref="form" :post="post" @posted="onPosted" @canceled="onCanceled"/> +</mk-window> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +export default Vue.extend({ + props: ['post'], + mounted() { + document.addEventListener('keydown', this.onDocumentKeydown); + }, + beforeDestroy() { + document.removeEventListener('keydown', this.onDocumentKeydown); + }, + methods: { + onDocumentKeydown(e) { + if (e.target.tagName != 'INPUT' && e.target.tagName != 'TEXTAREA') { + if (e.which == 27) { // Esc + (this.$refs.window as any).close(); + } + } + }, + onPosted() { + (this.$refs.window as any).close(); + }, + onCanceled() { + (this.$refs.window as any).close(); + } + } +}); +</script> + +<style lang="stylus" module> +.header + > [data-fa] + margin-right 4px + +</style> diff --git a/src/server/web/app/desktop/views/components/repost-form.vue b/src/server/web/app/desktop/views/components/repost-form.vue new file mode 100644 index 0000000000..f2774b817c --- /dev/null +++ b/src/server/web/app/desktop/views/components/repost-form.vue @@ -0,0 +1,131 @@ +<template> +<div class="mk-repost-form"> + <mk-post-preview :post="post"/> + <template v-if="!quote"> + <footer> + <a class="quote" v-if="!quote" @click="onQuote">%i18n:desktop.tags.mk-repost-form.quote%</a> + <button class="cancel" @click="cancel">%i18n:desktop.tags.mk-repost-form.cancel%</button> + <button class="ok" @click="ok" :disabled="wait">{{ wait ? '%i18n:desktop.tags.mk-repost-form.reposting%' : '%i18n:desktop.tags.mk-repost-form.repost%' }}</button> + </footer> + </template> + <template v-if="quote"> + <mk-post-form ref="form" :repost="post" @posted="onChildFormPosted"/> + </template> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +export default Vue.extend({ + props: ['post'], + data() { + return { + wait: false, + quote: false + }; + }, + methods: { + ok() { + this.wait = true; + (this as any).api('posts/create', { + repost_id: this.post.id + }).then(data => { + this.$emit('posted'); + (this as any).apis.notify('%i18n:desktop.tags.mk-repost-form.success%'); + }).catch(err => { + (this as any).apis.notify('%i18n:desktop.tags.mk-repost-form.failure%'); + }).then(() => { + this.wait = false; + }); + }, + cancel() { + this.$emit('canceled'); + }, + onQuote() { + this.quote = true; + + this.$nextTick(() => { + (this.$refs.form as any).focus(); + }); + }, + onChildFormPosted() { + this.$emit('posted'); + } + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +.mk-repost-form + + > .mk-post-preview + margin 16px 22px + + > footer + height 72px + background lighten($theme-color, 95%) + + > .quote + position absolute + bottom 16px + left 28px + line-height 40px + + button + display block + position absolute + bottom 16px + cursor pointer + padding 0 + margin 0 + width 120px + height 40px + font-size 1em + outline none + border-radius 4px + + &:focus + &:after + content "" + pointer-events none + position absolute + top -5px + right -5px + bottom -5px + left -5px + border 2px solid rgba($theme-color, 0.3) + border-radius 8px + + > .cancel + right 148px + color #888 + background linear-gradient(to bottom, #ffffff 0%, #f5f5f5 100%) + border solid 1px #e2e2e2 + + &:hover + background linear-gradient(to bottom, #f9f9f9 0%, #ececec 100%) + border-color #dcdcdc + + &:active + background #ececec + border-color #dcdcdc + + > .ok + right 16px + font-weight bold + color $theme-color-foreground + background linear-gradient(to bottom, lighten($theme-color, 25%) 0%, lighten($theme-color, 10%) 100%) + border solid 1px lighten($theme-color, 15%) + + &:hover + background linear-gradient(to bottom, lighten($theme-color, 8%) 0%, darken($theme-color, 8%) 100%) + border-color $theme-color + + &:active + background $theme-color + border-color $theme-color + +</style> diff --git a/src/server/web/app/desktop/views/components/settings-window.vue b/src/server/web/app/desktop/views/components/settings-window.vue new file mode 100644 index 0000000000..d5be177dcc --- /dev/null +++ b/src/server/web/app/desktop/views/components/settings-window.vue @@ -0,0 +1,24 @@ +<template> +<mk-window ref="window" is-modal width="700px" height="550px" @closed="$destroy"> + <span slot="header" :class="$style.header">%fa:cog%設定</span> + <mk-settings @done="close"/> +</mk-window> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + methods: { + close() { + (this as any).$refs.window.close(); + } + } +}); +</script> + +<style lang="stylus" module> +.header + > [data-fa] + margin-right 4px + +</style> diff --git a/src/server/web/app/desktop/views/components/settings.2fa.vue b/src/server/web/app/desktop/views/components/settings.2fa.vue new file mode 100644 index 0000000000..85f2d6ba5e --- /dev/null +++ b/src/server/web/app/desktop/views/components/settings.2fa.vue @@ -0,0 +1,80 @@ +<template> +<div class="2fa"> + <p>%i18n:desktop.tags.mk-2fa-setting.intro%<a href="%i18n:desktop.tags.mk-2fa-setting.url%" target="_blank">%i18n:desktop.tags.mk-2fa-setting.detail%</a></p> + <div class="ui info warn"><p>%fa:exclamation-triangle%%i18n:desktop.tags.mk-2fa-setting.caution%</p></div> + <p v-if="!data && !os.i.account.two_factor_enabled"><button @click="register" class="ui primary">%i18n:desktop.tags.mk-2fa-setting.register%</button></p> + <template v-if="os.i.account.two_factor_enabled"> + <p>%i18n:desktop.tags.mk-2fa-setting.already-registered%</p> + <button @click="unregister" class="ui">%i18n:desktop.tags.mk-2fa-setting.unregister%</button> + </template> + <div v-if="data"> + <ol> + <li>%i18n:desktop.tags.mk-2fa-setting.authenticator% <a href="https://support.google.com/accounts/answer/1066447" target="_blank">%i18n:desktop.tags.mk-2fa-setting.howtoinstall%</a></li> + <li>%i18n:desktop.tags.mk-2fa-setting.scan%<br><img :src="data.qr"></li> + <li>%i18n:desktop.tags.mk-2fa-setting.done%<br> + <input type="number" v-model="token" class="ui"> + <button @click="submit" class="ui primary">%i18n:desktop.tags.mk-2fa-setting.submit%</button> + </li> + </ol> + <div class="ui info"><p>%fa:info-circle%%i18n:desktop.tags.mk-2fa-setting.info%</p></div> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +export default Vue.extend({ + data() { + return { + data: null, + token: null + }; + }, + methods: { + register() { + (this as any).apis.input({ + title: '%i18n:desktop.tags.mk-2fa-setting.enter-password%', + type: 'password' + }).then(password => { + (this as any).api('i/2fa/register', { + password: password + }).then(data => { + this.data = data; + }); + }); + }, + + unregister() { + (this as any).apis.input({ + title: '%i18n:desktop.tags.mk-2fa-setting.enter-password%', + type: 'password' + }).then(password => { + (this as any).api('i/2fa/unregister', { + password: password + }).then(() => { + (this as any).apis.notify('%i18n:desktop.tags.mk-2fa-setting.unregistered%'); + (this as any).os.i.account.two_factor_enabled = false; + }); + }); + }, + + submit() { + (this as any).api('i/2fa/done', { + token: this.token + }).then(() => { + (this as any).apis.notify('%i18n:desktop.tags.mk-2fa-setting.success%'); + (this as any).os.i.account.two_factor_enabled = true; + }).catch(() => { + (this as any).apis.notify('%i18n:desktop.tags.mk-2fa-setting.failed%'); + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +.2fa + color #4a535a + +</style> diff --git a/src/server/web/app/desktop/views/components/settings.api.vue b/src/server/web/app/desktop/views/components/settings.api.vue new file mode 100644 index 0000000000..0d5921ab7f --- /dev/null +++ b/src/server/web/app/desktop/views/components/settings.api.vue @@ -0,0 +1,40 @@ +<template> +<div class="root api"> + <p>Token: <code>{{ os.i.account.token }}</code></p> + <p>%i18n:desktop.tags.mk-api-info.intro%</p> + <div class="ui info warn"><p>%fa:exclamation-triangle%%i18n:desktop.tags.mk-api-info.caution%</p></div> + <p>%i18n:desktop.tags.mk-api-info.regeneration-of-token%</p> + <button class="ui" @click="regenerateToken">%i18n:desktop.tags.mk-api-info.regenerate-token%</button> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +export default Vue.extend({ + methods: { + regenerateToken() { + (this as any).apis.input({ + title: '%i18n:desktop.tags.mk-api-info.enter-password%', + type: 'password' + }).then(password => { + (this as any).api('i/regenerate_token', { + password: password + }); + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +.root.api + color #4a535a + + code + display inline-block + padding 4px 6px + color #555 + background #eee + border-radius 2px +</style> diff --git a/src/server/web/app/desktop/views/components/settings.apps.vue b/src/server/web/app/desktop/views/components/settings.apps.vue new file mode 100644 index 0000000000..0503b03abd --- /dev/null +++ b/src/server/web/app/desktop/views/components/settings.apps.vue @@ -0,0 +1,39 @@ +<template> +<div class="root"> + <div class="none ui info" v-if="!fetching && apps.length == 0"> + <p>%fa:info-circle%%i18n:common.tags.mk-authorized-apps.no-apps%</p> + </div> + <div class="apps" v-if="apps.length != 0"> + <div v-for="app in apps"> + <p><b>{{ app.name }}</b></p> + <p>{{ app.description }}</p> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + data() { + return { + fetching: true, + apps: [] + }; + }, + mounted() { + (this as any).api('i/authorized_apps').then(apps => { + this.apps = apps; + this.fetching = false; + }); + } +}); +</script> + +<style lang="stylus" scoped> +.root + > .apps + > div + padding 16px 0 0 0 + border-bottom solid 1px #eee +</style> diff --git a/src/server/web/app/desktop/views/components/settings.drive.vue b/src/server/web/app/desktop/views/components/settings.drive.vue new file mode 100644 index 0000000000..8bb0c760a7 --- /dev/null +++ b/src/server/web/app/desktop/views/components/settings.drive.vue @@ -0,0 +1,35 @@ +<template> +<div class="root"> + <template v-if="!fetching"> + <el-progress :text-inside="true" :stroke-width="18" :percentage="Math.floor((usage / capacity) * 100)"/> + <p><b>{{ capacity | bytes }}</b>中<b>{{ usage | bytes }}</b>使用中</p> + </template> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + data() { + return { + fetching: true, + usage: null, + capacity: null + }; + }, + mounted() { + (this as any).api('drive').then(info => { + this.capacity = info.capacity; + this.usage = info.usage; + this.fetching = false; + }); + } +}); +</script> + +<style lang="stylus" scoped> +.root + > p + > b + margin 0 8px +</style> diff --git a/src/server/web/app/desktop/views/components/settings.mute.vue b/src/server/web/app/desktop/views/components/settings.mute.vue new file mode 100644 index 0000000000..a8dfe10604 --- /dev/null +++ b/src/server/web/app/desktop/views/components/settings.mute.vue @@ -0,0 +1,35 @@ +<template> +<div> + <div class="none ui info" v-if="!fetching && users.length == 0"> + <p>%fa:info-circle%%i18n:desktop.tags.mk-mute-setting.no-users%</p> + </div> + <div class="users" v-if="users.length != 0"> + <div v-for="user in users" :key="user.id"> + <p><b>{{ user.name }}</b> @{{ getAcct(user) }}</p> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import getAcct from '../../../../../common/user/get-acct'; + +export default Vue.extend({ + data() { + return { + fetching: true, + users: [] + }; + }, + methods: { + getAcct + }, + mounted() { + (this as any).api('mute/list').then(x => { + this.users = x.users; + this.fetching = false; + }); + } +}); +</script> diff --git a/src/server/web/app/desktop/views/components/settings.password.vue b/src/server/web/app/desktop/views/components/settings.password.vue new file mode 100644 index 0000000000..be3f0370d6 --- /dev/null +++ b/src/server/web/app/desktop/views/components/settings.password.vue @@ -0,0 +1,47 @@ +<template> +<div> + <button @click="reset" class="ui primary">%i18n:desktop.tags.mk-password-setting.reset%</button> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +export default Vue.extend({ + methods: { + reset() { + (this as any).apis.input({ + title: '%i18n:desktop.tags.mk-password-setting.enter-current-password%', + type: 'password' + }).then(currentPassword => { + (this as any).apis.input({ + title: '%i18n:desktop.tags.mk-password-setting.enter-new-password%', + type: 'password' + }).then(newPassword => { + (this as any).apis.input({ + title: '%i18n:desktop.tags.mk-password-setting.enter-new-password-again%', + type: 'password' + }).then(newPassword2 => { + if (newPassword !== newPassword2) { + (this as any).apis.dialog({ + title: null, + text: '%i18n:desktop.tags.mk-password-setting.not-match%', + actions: [{ + text: 'OK' + }] + }); + return; + } + (this as any).api('i/change_password', { + current_password: currentPassword, + new_password: newPassword + }).then(() => { + (this as any).apis.notify('%i18n:desktop.tags.mk-password-setting.changed%'); + }); + }); + }); + }); + } + } +}); +</script> diff --git a/src/server/web/app/desktop/views/components/settings.profile.vue b/src/server/web/app/desktop/views/components/settings.profile.vue new file mode 100644 index 0000000000..67a211c792 --- /dev/null +++ b/src/server/web/app/desktop/views/components/settings.profile.vue @@ -0,0 +1,87 @@ +<template> +<div class="profile"> + <label class="avatar ui from group"> + <p>%i18n:desktop.tags.mk-profile-setting.avatar%</p> + <img class="avatar" :src="`${os.i.avatar_url}?thumbnail&size=64`" alt="avatar"/> + <button class="ui" @click="updateAvatar">%i18n:desktop.tags.mk-profile-setting.choice-avatar%</button> + </label> + <label class="ui from group"> + <p>%i18n:desktop.tags.mk-profile-setting.name%</p> + <input v-model="name" type="text" class="ui"/> + </label> + <label class="ui from group"> + <p>%i18n:desktop.tags.mk-profile-setting.location%</p> + <input v-model="location" type="text" class="ui"/> + </label> + <label class="ui from group"> + <p>%i18n:desktop.tags.mk-profile-setting.description%</p> + <textarea v-model="description" class="ui"></textarea> + </label> + <label class="ui from group"> + <p>%i18n:desktop.tags.mk-profile-setting.birthday%</p> + <el-date-picker v-model="birthday" type="date" value-format="yyyy-MM-dd"/> + </label> + <button class="ui primary" @click="save">%i18n:desktop.tags.mk-profile-setting.save%</button> + <section> + <h2>その他</h2> + <mk-switch v-model="os.i.account.is_bot" @change="onChangeIsBot" text="このアカウントはbotです"/> + </section> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +export default Vue.extend({ + data() { + return { + name: null, + location: null, + description: null, + birthday: null, + }; + }, + created() { + this.name = (this as any).os.i.name; + this.location = (this as any).os.i.account.profile.location; + this.description = (this as any).os.i.description; + this.birthday = (this as any).os.i.account.profile.birthday; + }, + methods: { + updateAvatar() { + (this as any).apis.updateAvatar(); + }, + save() { + (this as any).api('i/update', { + name: this.name, + location: this.location || null, + description: this.description || null, + birthday: this.birthday || null + }).then(() => { + (this as any).apis.notify('プロフィールを更新しました'); + }); + }, + onChangeIsBot() { + (this as any).api('i/update', { + is_bot: (this as any).os.i.account.is_bot + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +.profile + > .avatar + > img + display inline-block + vertical-align top + width 64px + height 64px + border-radius 4px + + > button + margin-left 8px + +</style> + diff --git a/src/server/web/app/desktop/views/components/settings.signins.vue b/src/server/web/app/desktop/views/components/settings.signins.vue new file mode 100644 index 0000000000..ddc567f06f --- /dev/null +++ b/src/server/web/app/desktop/views/components/settings.signins.vue @@ -0,0 +1,98 @@ +<template> +<div class="root"> +<div class="signins" v-if="signins.length != 0"> + <div v-for="signin in signins"> + <header @click="signin._show = !signin._show"> + <template v-if="signin.success">%fa:check%</template> + <template v-else>%fa:times%</template> + <span class="ip">{{ signin.ip }}</span> + <mk-time :time="signin.created_at"/> + </header> + <div class="headers" v-show="signin._show"> + <tree-view :data="signin.headers"/> + </div> + </div> +</div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + data() { + return { + fetching: true, + signins: [], + connection: null, + connectionId: null + }; + }, + mounted() { + (this as any).api('i/signin_history').then(signins => { + this.signins = signins; + this.fetching = false; + }); + + this.connection = (this as any).os.stream.getConnection(); + this.connectionId = (this as any).os.stream.use(); + + this.connection.on('signin', this.onSignin); + }, + beforeDestroy() { + this.connection.off('signin', this.onSignin); + (this as any).os.stream.dispose(this.connectionId); + }, + methods: { + onSignin(signin) { + this.signins.unshift(signin); + } + } +}); +</script> + +<style lang="stylus" scoped> +.root + > .signins + > div + border-bottom solid 1px #eee + + > header + display flex + padding 8px 0 + line-height 32px + cursor pointer + + > [data-fa] + margin-right 8px + text-align left + + &.check + color #0fda82 + + &.times + color #ff3100 + + > .ip + display inline-block + text-align left + padding 8px + line-height 16px + font-family monospace + font-size 14px + color #444 + background #f8f8f8 + border-radius 4px + + > .mk-time + margin-left auto + text-align right + color #777 + + > .headers + overflow auto + margin 0 0 16px 0 + max-height 100px + white-space pre-wrap + word-break break-all + +</style> diff --git a/src/server/web/app/desktop/views/components/settings.vue b/src/server/web/app/desktop/views/components/settings.vue new file mode 100644 index 0000000000..3e6a477ced --- /dev/null +++ b/src/server/web/app/desktop/views/components/settings.vue @@ -0,0 +1,419 @@ +<template> +<div class="mk-settings"> + <div class="nav"> + <p :class="{ active: page == 'profile' }" @mousedown="page = 'profile'">%fa:user .fw%%i18n:desktop.tags.mk-settings.profile%</p> + <p :class="{ active: page == 'web' }" @mousedown="page = 'web'">%fa:desktop .fw%Web</p> + <p :class="{ active: page == 'notification' }" @mousedown="page = 'notification'">%fa:R bell .fw%通知</p> + <p :class="{ active: page == 'drive' }" @mousedown="page = 'drive'">%fa:cloud .fw%%i18n:desktop.tags.mk-settings.drive%</p> + <p :class="{ active: page == 'mute' }" @mousedown="page = 'mute'">%fa:ban .fw%%i18n:desktop.tags.mk-settings.mute%</p> + <p :class="{ active: page == 'apps' }" @mousedown="page = 'apps'">%fa:puzzle-piece .fw%アプリ</p> + <p :class="{ active: page == 'twitter' }" @mousedown="page = 'twitter'">%fa:B twitter .fw%Twitter</p> + <p :class="{ active: page == 'security' }" @mousedown="page = 'security'">%fa:unlock-alt .fw%%i18n:desktop.tags.mk-settings.security%</p> + <p :class="{ active: page == 'api' }" @mousedown="page = 'api'">%fa:key .fw%API</p> + <p :class="{ active: page == 'other' }" @mousedown="page = 'other'">%fa:cogs .fw%%i18n:desktop.tags.mk-settings.other%</p> + </div> + <div class="pages"> + <section class="profile" v-show="page == 'profile'"> + <h1>%i18n:desktop.tags.mk-settings.profile%</h1> + <x-profile/> + </section> + + <section class="web" v-show="page == 'web'"> + <h1>動作</h1> + <mk-switch v-model="os.i.account.client_settings.fetchOnScroll" @change="onChangeFetchOnScroll" text="スクロールで自動読み込み"> + <span>ページを下までスクロールしたときに自動で追加のコンテンツを読み込みます。</span> + </mk-switch> + <mk-switch v-model="autoPopout" text="ウィンドウの自動ポップアウト"> + <span>ウィンドウが開かれるとき、ポップアウト(ブラウザ外に切り離す)可能なら自動でポップアウトします。この設定はブラウザに記憶されます。</span> + </mk-switch> + </section> + + <section class="web" v-show="page == 'web'"> + <h1>デザインと表示</h1> + <div class="div"> + <button class="ui button" @click="customizeHome">ホームをカスタマイズ</button> + </div> + <mk-switch v-model="os.i.account.client_settings.showPostFormOnTopOfTl" @change="onChangeShowPostFormOnTopOfTl" text="タイムライン上部に投稿フォームを表示する"/> + <mk-switch v-model="os.i.account.client_settings.showMaps" @change="onChangeShowMaps" text="マップの自動展開"> + <span>位置情報が添付された投稿のマップを自動的に展開します。</span> + </mk-switch> + <mk-switch v-model="os.i.account.client_settings.gradientWindowHeader" @change="onChangeGradientWindowHeader" text="ウィンドウのタイトルバーにグラデーションを使用"/> + </section> + + <section class="web" v-show="page == 'web'"> + <h1>サウンド</h1> + <mk-switch v-model="enableSounds" text="サウンドを有効にする"> + <span>投稿やメッセージを送受信したときなどにサウンドを再生します。この設定はブラウザに記憶されます。</span> + </mk-switch> + <label>ボリューム</label> + <el-slider + v-model="soundVolume" + :show-input="true" + :format-tooltip="v => `${v}%`" + :disabled="!enableSounds" + /> + <button class="ui button" @click="soundTest">%fa:volume-up% テスト</button> + </section> + + <section class="web" v-show="page == 'web'"> + <h1>モバイル</h1> + <mk-switch v-model="os.i.account.client_settings.disableViaMobile" @change="onChangeDisableViaMobile" text="「モバイルからの投稿」フラグを付けない"/> + </section> + + <section class="web" v-show="page == 'web'"> + <h1>言語</h1> + <el-select v-model="lang" placeholder="言語を選択"> + <el-option-group label="推奨"> + <el-option label="自動" value=""/> + </el-option-group> + <el-option-group label="言語を指定"> + <el-option label="ja-JP" value="ja"/> + <el-option label="en-US" value="en"/> + </el-option-group> + </el-select> + <div class="none ui info"> + <p>%fa:info-circle%変更はページの再度読み込み後に反映されます。</p> + </div> + </section> + + <section class="web" v-show="page == 'web'"> + <h1>キャッシュ</h1> + <button class="ui button" @click="clean">クリーンアップ</button> + <div class="none ui info warn"> + <p>%fa:exclamation-triangle%クリーンアップを行うと、ブラウザに記憶されたアカウント情報のキャッシュ、書きかけの投稿・返信・メッセージ、およびその他のデータ(設定情報含む)が削除されます。クリーンアップを行った後はページを再度読み込みする必要があります。</p> + </div> + </section> + + <section class="notification" v-show="page == 'notification'"> + <h1>通知</h1> + <mk-switch v-model="os.i.account.settings.auto_watch" @change="onChangeAutoWatch" text="投稿の自動ウォッチ"> + <span>リアクションしたり返信したりした投稿に関する通知を自動的に受け取るようにします。</span> + </mk-switch> + </section> + + <section class="drive" v-show="page == 'drive'"> + <h1>%i18n:desktop.tags.mk-settings.drive%</h1> + <x-drive/> + </section> + + <section class="mute" v-show="page == 'mute'"> + <h1>%i18n:desktop.tags.mk-settings.mute%</h1> + <x-mute/> + </section> + + <section class="apps" v-show="page == 'apps'"> + <h1>アプリケーション</h1> + <x-apps/> + </section> + + <section class="twitter" v-show="page == 'twitter'"> + <h1>Twitter</h1> + <mk-twitter-setting/> + </section> + + <section class="password" v-show="page == 'security'"> + <h1>%i18n:desktop.tags.mk-settings.password%</h1> + <x-password/> + </section> + + <section class="2fa" v-show="page == 'security'"> + <h1>%i18n:desktop.tags.mk-settings.2fa%</h1> + <x-2fa/> + </section> + + <section class="signin" v-show="page == 'security'"> + <h1>サインイン履歴</h1> + <x-signins/> + </section> + + <section class="api" v-show="page == 'api'"> + <h1>API</h1> + <x-api/> + </section> + + <section class="other" v-show="page == 'other'"> + <h1>Misskeyについて</h1> + <p v-if="meta">このサーバーの運営者: <i><a :href="meta.maintainer.url" target="_blank">{{ meta.maintainer.name }}</a></i></p> + </section> + + <section class="other" v-show="page == 'other'"> + <h1>Misskey Update</h1> + <p> + <span>バージョン: <i>{{ version }}</i></span> + <template v-if="latestVersion !== undefined"> + <br> + <span>最新のバージョン: <i>{{ latestVersion ? latestVersion : version }}</i></span> + </template> + </p> + <button class="ui button block" @click="checkForUpdate" :disabled="checkingForUpdate"> + <template v-if="checkingForUpdate">アップデートを確認中<mk-ellipsis/></template> + <template v-else>アップデートを確認</template> + </button> + <details> + <summary>詳細設定</summary> + <mk-switch v-model="preventUpdate" text="アップデートを延期する(非推奨)"> + <span>この設定をオンにしてもアップデートが反映される場合があります。この設定はこのデバイスのみ有効です。</span> + </mk-switch> + </details> + </section> + + <section class="other" v-show="page == 'other'"> + <h1>高度な設定</h1> + <mk-switch v-model="debug" text="デバッグモードを有効にする"> + <span>この設定はブラウザに記憶されます。</span> + </mk-switch> + <template v-if="debug"> + <mk-switch v-model="useRawScript" text="生のスクリプトを読み込む"> + <span>圧縮されていない「生の」スクリプトを使用します。サイズが大きいため、読み込みに時間がかかる場合があります。この設定はブラウザに記憶されます。</span> + </mk-switch> + <div class="none ui info"> + <p>%fa:info-circle%Misskeyはソースマップも提供しています。</p> + </div> + </template> + <mk-switch v-model="enableExperimental" text="実験的機能を有効にする"> + <span>実験的機能を有効にするとMisskeyの動作が不安定になる可能性があります。この設定はブラウザに記憶されます。</span> + </mk-switch> + <details v-if="debug"> + <summary>ツール</summary> + <button class="ui button block" @click="taskmngr">タスクマネージャ</button> + </details> + </section> + + <section class="other" v-show="page == 'other'"> + <h1>%i18n:desktop.tags.mk-settings.license%</h1> + <div v-html="license"></div> + <a :href="licenseUrl" target="_blank">サードパーティ</a> + </section> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import XProfile from './settings.profile.vue'; +import XMute from './settings.mute.vue'; +import XPassword from './settings.password.vue'; +import X2fa from './settings.2fa.vue'; +import XApi from './settings.api.vue'; +import XApps from './settings.apps.vue'; +import XSignins from './settings.signins.vue'; +import XDrive from './settings.drive.vue'; +import { url, docsUrl, license, lang, version } from '../../../config'; +import checkForUpdate from '../../../common/scripts/check-for-update'; +import MkTaskManager from './taskmanager.vue'; + +export default Vue.extend({ + components: { + XProfile, + XMute, + XPassword, + X2fa, + XApi, + XApps, + XSignins, + XDrive + }, + data() { + return { + page: 'profile', + meta: null, + license, + version, + latestVersion: undefined, + checkingForUpdate: false, + enableSounds: localStorage.getItem('enableSounds') == 'true', + autoPopout: localStorage.getItem('autoPopout') == 'true', + soundVolume: localStorage.getItem('soundVolume') ? parseInt(localStorage.getItem('soundVolume'), 10) : 100, + lang: localStorage.getItem('lang') || '', + preventUpdate: localStorage.getItem('preventUpdate') == 'true', + debug: localStorage.getItem('debug') == 'true', + useRawScript: localStorage.getItem('useRawScript') == 'true', + enableExperimental: localStorage.getItem('enableExperimental') == 'true' + }; + }, + computed: { + licenseUrl(): string { + return `${docsUrl}/${lang}/license`; + } + }, + watch: { + autoPopout() { + localStorage.setItem('autoPopout', this.autoPopout ? 'true' : 'false'); + }, + enableSounds() { + localStorage.setItem('enableSounds', this.enableSounds ? 'true' : 'false'); + }, + soundVolume() { + localStorage.setItem('soundVolume', this.soundVolume.toString()); + }, + lang() { + localStorage.setItem('lang', this.lang); + }, + preventUpdate() { + localStorage.setItem('preventUpdate', this.preventUpdate ? 'true' : 'false'); + }, + debug() { + localStorage.setItem('debug', this.debug ? 'true' : 'false'); + }, + useRawScript() { + localStorage.setItem('useRawScript', this.useRawScript ? 'true' : 'false'); + }, + enableExperimental() { + localStorage.setItem('enableExperimental', this.enableExperimental ? 'true' : 'false'); + } + }, + created() { + (this as any).os.getMeta().then(meta => { + this.meta = meta; + }); + }, + methods: { + taskmngr() { + (this as any).os.new(MkTaskManager); + }, + customizeHome() { + this.$router.push('/i/customize-home'); + this.$emit('done'); + }, + onChangeFetchOnScroll(v) { + (this as any).api('i/update_client_setting', { + name: 'fetchOnScroll', + value: v + }); + }, + onChangeAutoWatch(v) { + (this as any).api('i/update', { + auto_watch: v + }); + }, + onChangeShowPostFormOnTopOfTl(v) { + (this as any).api('i/update_client_setting', { + name: 'showPostFormOnTopOfTl', + value: v + }); + }, + onChangeShowMaps(v) { + (this as any).api('i/update_client_setting', { + name: 'showMaps', + value: v + }); + }, + onChangeGradientWindowHeader(v) { + (this as any).api('i/update_client_setting', { + name: 'gradientWindowHeader', + value: v + }); + }, + onChangeDisableViaMobile(v) { + (this as any).api('i/update_client_setting', { + name: 'disableViaMobile', + value: v + }); + }, + checkForUpdate() { + this.checkingForUpdate = true; + checkForUpdate((this as any).os, true, true).then(newer => { + this.checkingForUpdate = false; + this.latestVersion = newer; + if (newer == null) { + (this as any).apis.dialog({ + title: '利用可能な更新はありません', + text: 'お使いのMisskeyは最新です。' + }); + } else { + (this as any).apis.dialog({ + title: '新しいバージョンが利用可能です', + text: 'ページを再度読み込みすると更新が適用されます。' + }); + } + }); + }, + clean() { + localStorage.clear(); + (this as any).apis.dialog({ + title: 'キャッシュを削除しました', + text: 'ページを再度読み込みしてください。' + }); + }, + soundTest() { + const sound = new Audio(`${url}/assets/message.mp3`); + sound.volume = localStorage.getItem('soundVolume') ? parseInt(localStorage.getItem('soundVolume'), 10) / 100 : 1; + sound.play(); + } + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +.mk-settings + display flex + width 100% + height 100% + + > .nav + flex 0 0 200px + width 100% + height 100% + padding 16px 0 0 0 + overflow auto + border-right solid 1px #ddd + + > p + display block + padding 10px 16px + margin 0 + color #666 + cursor pointer + user-select none + transition margin-left 0.2s ease + + > [data-fa] + margin-right 4px + + &:hover + color #555 + + &.active + margin-left 8px + color $theme-color !important + + > .pages + width 100% + height 100% + flex auto + overflow auto + + > section + margin 32px + color #4a535a + + > h1 + margin 0 0 1em 0 + padding 0 0 8px 0 + font-size 1em + color #555 + border-bottom solid 1px #eee + + &, >>> * + .ui.button.block + margin 16px 0 + + > section + margin 32px 0 + + > h2 + margin 0 0 1em 0 + padding 0 0 8px 0 + font-size 1em + color #555 + border-bottom solid 1px #eee + + > .web + > .div + border-bottom solid 1px #eee + padding 0 0 16px 0 + margin 0 0 16px 0 + +</style> diff --git a/src/server/web/app/desktop/views/components/sub-post-content.vue b/src/server/web/app/desktop/views/components/sub-post-content.vue new file mode 100644 index 0000000000..8c8f42c801 --- /dev/null +++ b/src/server/web/app/desktop/views/components/sub-post-content.vue @@ -0,0 +1,56 @@ +<template> +<div class="mk-sub-post-content"> + <div class="body"> + <a class="reply" v-if="post.reply_id">%fa:reply%</a> + <mk-post-html :ast="post.ast" :i="os.i"/> + <a class="rp" v-if="post.repost_id" :href="`/post:${post.repost_id}`">RP: ...</a> + <mk-url-preview v-for="url in urls" :url="url" :key="url"/> + </div> + <details v-if="post.media"> + <summary>({{ post.media.length }}つのメディア)</summary> + <mk-media-list :media-list="post.media"/> + </details> + <details v-if="post.poll"> + <summary>投票</summary> + <mk-poll :post="post"/> + </details> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +export default Vue.extend({ + props: ['post'], + computed: { + urls(): string[] { + if (this.post.ast) { + return this.post.ast + .filter(t => (t.type == 'url' || t.type == 'link') && !t.silent) + .map(t => t.url); + } else { + return null; + } + } + } +}); +</script> + +<style lang="stylus" scoped> +.mk-sub-post-content + overflow-wrap break-word + + > .body + > .reply + margin-right 6px + color #717171 + + > .rp + margin-left 4px + font-style oblique + color #a0bf46 + + mk-poll + font-size 80% + +</style> diff --git a/src/server/web/app/desktop/views/components/taskmanager.vue b/src/server/web/app/desktop/views/components/taskmanager.vue new file mode 100644 index 0000000000..a00fabb047 --- /dev/null +++ b/src/server/web/app/desktop/views/components/taskmanager.vue @@ -0,0 +1,219 @@ +<template> +<mk-window ref="window" width="750px" height="500px" @closed="$destroy" name="TaskManager"> + <span slot="header" :class="$style.header">%fa:stethoscope%タスクマネージャ</span> + <el-tabs :class="$style.content"> + <el-tab-pane label="Requests"> + <el-table + :data="os.requests" + style="width: 100%" + :default-sort="{prop: 'date', order: 'descending'}" + > + <el-table-column type="expand"> + <template slot-scope="props"> + <pre>{{ props.row.data }}</pre> + <pre>{{ props.row.res }}</pre> + </template> + </el-table-column> + + <el-table-column + label="Requested at" + prop="date" + sortable + > + <template slot-scope="scope"> + <b style="margin-right: 8px">{{ scope.row.date.getTime() }}</b> + <span>(<mk-time :time="scope.row.date"/>)</span> + </template> + </el-table-column> + + <el-table-column + label="Name" + > + <template slot-scope="scope"> + <b>{{ scope.row.name }}</b> + </template> + </el-table-column> + + <el-table-column + label="Status" + > + <template slot-scope="scope"> + <span>{{ scope.row.status || '(pending)' }}</span> + </template> + </el-table-column> + </el-table> + </el-tab-pane> + + <el-tab-pane label="Streams"> + <el-table + :data="os.connections" + style="width: 100%" + > + <el-table-column + label="Uptime" + > + <template slot-scope="scope"> + <mk-timer v-if="scope.row.connectedAt" :time="scope.row.connectedAt"/> + <span v-else>-</span> + </template> + </el-table-column> + + <el-table-column + label="Name" + > + <template slot-scope="scope"> + <b>{{ scope.row.name == '' ? '[Home]' : scope.row.name }}</b> + </template> + </el-table-column> + + <el-table-column + label="User" + > + <template slot-scope="scope"> + <span>{{ scope.row.user || '(anonymous)' }}</span> + </template> + </el-table-column> + + <el-table-column + prop="state" + label="State" + /> + + <el-table-column + prop="in" + label="In" + /> + + <el-table-column + prop="out" + label="Out" + /> + </el-table> + </el-tab-pane> + + <el-tab-pane label="Streams (Inspect)"> + <el-tabs type="card" style="height:50%"> + <el-tab-pane v-for="c in os.connections" :label="c.name == '' ? '[Home]' : c.name" :key="c.id" :name="c.id" ref="connectionsTab"> + <div style="padding: 12px 0 0 12px"> + <el-button size="mini" @click="send(c)">Send</el-button> + <el-button size="mini" type="warning" @click="c.isSuspended = true" v-if="!c.isSuspended">Suspend</el-button> + <el-button size="mini" type="success" @click="c.isSuspended = false" v-else>Resume</el-button> + <el-button size="mini" type="danger" @click="c.close">Disconnect</el-button> + </div> + + <el-table + :data="c.inout" + style="width: 100%" + :default-sort="{prop: 'at', order: 'descending'}" + > + <el-table-column type="expand"> + <template slot-scope="props"> + <pre>{{ props.row.data }}</pre> + </template> + </el-table-column> + + <el-table-column + label="Date" + prop="at" + sortable + > + <template slot-scope="scope"> + <b style="margin-right: 8px">{{ scope.row.at.getTime() }}</b> + <span>(<mk-time :time="scope.row.at"/>)</span> + </template> + </el-table-column> + + <el-table-column + label="Type" + > + <template slot-scope="scope"> + <span>{{ getMessageType(scope.row.data) }}</span> + </template> + </el-table-column> + + <el-table-column + label="Incoming / Outgoing" + prop="type" + /> + </el-table> + </el-tab-pane> + </el-tabs> + </el-tab-pane> + + <el-tab-pane label="Windows"> + <el-table + :data="Array.from(os.windows.windows)" + style="width: 100%" + > + <el-table-column + label="Name" + > + <template slot-scope="scope"> + <b>{{ scope.row.name || '(unknown)' }}</b> + </template> + </el-table-column> + + <el-table-column + label="Operations" + > + <template slot-scope="scope"> + <el-button size="mini" type="danger" @click="scope.row.close">Close</el-button> + </template> + </el-table-column> + </el-table> + </el-tab-pane> + </el-tabs> +</mk-window> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +export default Vue.extend({ + mounted() { + (this as any).os.windows.on('added', this.onWindowsChanged); + (this as any).os.windows.on('removed', this.onWindowsChanged); + }, + beforeDestroy() { + (this as any).os.windows.off('added', this.onWindowsChanged); + (this as any).os.windows.off('removed', this.onWindowsChanged); + }, + methods: { + getMessageType(data): string { + return data.type ? data.type : '-'; + }, + onWindowsChanged() { + this.$forceUpdate(); + }, + send(c) { + (this as any).apis.input({ + title: 'Send a JSON message', + allowEmpty: false + }).then(json => { + c.send(JSON.parse(json)); + }); + } + } +}); +</script> + +<style lang="stylus" module> +.header + > [data-fa] + margin-right 4px + +.content + height 100% + overflow auto + +</style> + +<style> +.el-tabs__header { + margin-bottom: 0 !important; +} + +.el-tabs__item { + padding: 0 20px !important; +} +</style> diff --git a/src/server/web/app/desktop/views/components/timeline.vue b/src/server/web/app/desktop/views/components/timeline.vue new file mode 100644 index 0000000000..47a9688b6d --- /dev/null +++ b/src/server/web/app/desktop/views/components/timeline.vue @@ -0,0 +1,156 @@ +<template> +<div class="mk-timeline"> + <mk-friends-maker v-if="alone"/> + <div class="fetching" v-if="fetching"> + <mk-ellipsis-icon/> + </div> + <p class="empty" v-if="posts.length == 0 && !fetching"> + %fa:R comments%自分の投稿や、自分がフォローしているユーザーの投稿が表示されます。 + </p> + <mk-posts :posts="posts" ref="timeline"> + <button slot="footer" @click="more" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }"> + <template v-if="!moreFetching">もっと見る</template> + <template v-if="moreFetching">%fa:spinner .pulse .fw%</template> + </button> + </mk-posts> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { url } from '../../../config'; + +export default Vue.extend({ + data() { + return { + fetching: true, + moreFetching: false, + existMore: false, + posts: [], + connection: null, + connectionId: null, + date: null + }; + }, + computed: { + alone(): boolean { + return (this as any).os.i.following_count == 0; + } + }, + mounted() { + this.connection = (this as any).os.stream.getConnection(); + this.connectionId = (this as any).os.stream.use(); + + this.connection.on('post', this.onPost); + this.connection.on('follow', this.onChangeFollowing); + this.connection.on('unfollow', this.onChangeFollowing); + + document.addEventListener('keydown', this.onKeydown); + window.addEventListener('scroll', this.onScroll); + + this.fetch(); + }, + beforeDestroy() { + this.connection.off('post', this.onPost); + this.connection.off('follow', this.onChangeFollowing); + this.connection.off('unfollow', this.onChangeFollowing); + (this as any).os.stream.dispose(this.connectionId); + + document.removeEventListener('keydown', this.onKeydown); + window.removeEventListener('scroll', this.onScroll); + }, + methods: { + fetch(cb?) { + this.fetching = true; + + (this as any).api('posts/timeline', { + limit: 11, + until_date: this.date ? this.date.getTime() : undefined + }).then(posts => { + if (posts.length == 11) { + posts.pop(); + this.existMore = true; + } + this.posts = posts; + this.fetching = false; + this.$emit('loaded'); + if (cb) cb(); + }); + }, + more() { + if (this.moreFetching || this.fetching || this.posts.length == 0 || !this.existMore) return; + this.moreFetching = true; + (this as any).api('posts/timeline', { + limit: 11, + until_id: this.posts[this.posts.length - 1].id + }).then(posts => { + if (posts.length == 11) { + posts.pop(); + } else { + this.existMore = false; + } + this.posts = this.posts.concat(posts); + this.moreFetching = false; + }); + }, + onPost(post) { + // サウンドを再生する + if ((this as any).os.isEnableSounds) { + const sound = new Audio(`${url}/assets/post.mp3`); + sound.volume = localStorage.getItem('soundVolume') ? parseInt(localStorage.getItem('soundVolume'), 10) / 100 : 1; + sound.play(); + } + + this.posts.unshift(post); + }, + onChangeFollowing() { + this.fetch(); + }, + onScroll() { + if ((this as any).os.i.account.client_settings.fetchOnScroll !== false) { + const current = window.scrollY + window.innerHeight; + if (current > document.body.offsetHeight - 8) this.more(); + } + }, + onKeydown(e) { + if (e.target.tagName != 'INPUT' && e.target.tagName != 'TEXTAREA') { + if (e.which == 84) { // t + (this.$refs.timeline as any).focus(); + } + } + }, + warp(date) { + this.date = date; + this.fetch(); + } + } +}); +</script> + +<style lang="stylus" scoped> +.mk-timeline + background #fff + border solid 1px rgba(0, 0, 0, 0.075) + border-radius 6px + + > .mk-friends-maker + border-bottom solid 1px #eee + + > .fetching + padding 64px 0 + + > .empty + display block + margin 0 auto + padding 32px + max-width 400px + text-align center + color #999 + + > [data-fa] + display block + margin-bottom 16px + font-size 3em + color #ccc + +</style> diff --git a/src/server/web/app/desktop/views/components/ui-notification.vue b/src/server/web/app/desktop/views/components/ui-notification.vue new file mode 100644 index 0000000000..9983f02c5e --- /dev/null +++ b/src/server/web/app/desktop/views/components/ui-notification.vue @@ -0,0 +1,61 @@ +<template> +<div class="mk-ui-notification"> + <p>{{ message }}</p> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import * as anime from 'animejs'; + +export default Vue.extend({ + props: ['message'], + mounted() { + this.$nextTick(() => { + anime({ + targets: this.$el, + opacity: 1, + translateY: [-64, 0], + easing: 'easeOutElastic', + duration: 500 + }); + + setTimeout(() => { + anime({ + targets: this.$el, + opacity: 0, + translateY: -64, + duration: 500, + easing: 'easeInElastic', + complete: () => this.$destroy() + }); + }, 6000); + }); + } +}); +</script> + +<style lang="stylus" scoped> +.mk-ui-notification + display block + position fixed + z-index 10000 + top -128px + left 0 + right 0 + margin 0 auto + padding 128px 0 0 0 + width 500px + color rgba(#000, 0.6) + background rgba(#fff, 0.9) + border-radius 0 0 8px 8px + box-shadow 0 2px 4px rgba(#000, 0.2) + transform translateY(-64px) + opacity 0 + + > p + margin 0 + line-height 64px + text-align center + +</style> diff --git a/src/server/web/app/desktop/views/components/ui.header.account.vue b/src/server/web/app/desktop/views/components/ui.header.account.vue new file mode 100644 index 0000000000..19b9d77798 --- /dev/null +++ b/src/server/web/app/desktop/views/components/ui.header.account.vue @@ -0,0 +1,225 @@ +<template> +<div class="account"> + <button class="header" :data-active="isOpen" @click="toggle"> + <span class="username">{{ os.i.username }}<template v-if="!isOpen">%fa:angle-down%</template><template v-if="isOpen">%fa:angle-up%</template></span> + <img class="avatar" :src="`${ os.i.avatar_url }?thumbnail&size=64`" alt="avatar"/> + </button> + <transition name="zoom-in-top"> + <div class="menu" v-if="isOpen"> + <ul> + <li> + <router-link :to="`/@${ os.i.username }`">%fa:user%%i18n:desktop.tags.mk-ui-header-account.profile%%fa:angle-right%</router-link> + </li> + <li @click="drive"> + <p>%fa:cloud%%i18n:desktop.tags.mk-ui-header-account.drive%%fa:angle-right%</p> + </li> + <li> + <a href="/i/mentions">%fa:at%%i18n:desktop.tags.mk-ui-header-account.mentions%%fa:angle-right%</a> + </li> + </ul> + <ul> + <li @click="settings"> + <p>%fa:cog%%i18n:desktop.tags.mk-ui-header-account.settings%%fa:angle-right%</p> + </li> + </ul> + <ul> + <li @click="signout"> + <p>%fa:power-off%%i18n:desktop.tags.mk-ui-header-account.signout%%fa:angle-right%</p> + </li> + </ul> + </div> + </transition> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import MkSettingsWindow from './settings-window.vue'; +import MkDriveWindow from './drive-window.vue'; +import contains from '../../../common/scripts/contains'; + +export default Vue.extend({ + data() { + return { + isOpen: false + }; + }, + beforeDestroy() { + this.close(); + }, + methods: { + toggle() { + this.isOpen ? this.close() : this.open(); + }, + open() { + this.isOpen = true; + Array.from(document.querySelectorAll('body *')).forEach(el => { + el.addEventListener('mousedown', this.onMousedown); + }); + }, + close() { + this.isOpen = false; + Array.from(document.querySelectorAll('body *')).forEach(el => { + el.removeEventListener('mousedown', this.onMousedown); + }); + }, + onMousedown(e) { + e.preventDefault(); + if (!contains(this.$el, e.target) && this.$el != e.target) this.close(); + return false; + }, + drive() { + this.close(); + (this as any).os.new(MkDriveWindow); + }, + settings() { + this.close(); + (this as any).os.new(MkSettingsWindow); + }, + signout() { + (this as any).os.signout(); + } + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +.account + > .header + display block + margin 0 + padding 0 + color #9eaba8 + border none + background transparent + cursor pointer + + * + pointer-events none + + &:hover + &[data-active='true'] + color darken(#9eaba8, 20%) + + > .avatar + filter saturate(150%) + + &:active + color darken(#9eaba8, 30%) + + > .username + display block + float left + margin 0 12px 0 16px + max-width 16em + line-height 48px + font-weight bold + font-family Meiryo, sans-serif + text-decoration none + + [data-fa] + margin-left 8px + + > .avatar + display block + float left + min-width 32px + max-width 32px + min-height 32px + max-height 32px + margin 8px 8px 8px 0 + border-radius 4px + transition filter 100ms ease + + > .menu + display block + position absolute + top 56px + right -2px + width 230px + font-size 0.8em + background #fff + border-radius 4px + box-shadow 0 1px 4px rgba(0, 0, 0, 0.25) + + &:before + content "" + pointer-events none + display block + position absolute + top -28px + right 12px + border-top solid 14px transparent + border-right solid 14px transparent + border-bottom solid 14px rgba(0, 0, 0, 0.1) + border-left solid 14px transparent + + &:after + content "" + pointer-events none + display block + position absolute + top -27px + right 12px + border-top solid 14px transparent + border-right solid 14px transparent + border-bottom solid 14px #fff + border-left solid 14px transparent + + ul + display block + margin 10px 0 + padding 0 + list-style none + + & + ul + padding-top 10px + border-top solid 1px #eee + + > li + display block + margin 0 + padding 0 + + > a + > p + display block + z-index 1 + padding 0 28px + margin 0 + line-height 40px + color #868C8C + cursor pointer + + * + pointer-events none + + > [data-fa]:first-of-type + margin-right 6px + + > [data-fa]:last-of-type + display block + position absolute + top 0 + right 8px + z-index 1 + padding 0 20px + font-size 1.2em + line-height 40px + + &:hover, &:active + text-decoration none + background $theme-color + color $theme-color-foreground + + &:active + background darken($theme-color, 10%) + +.zoom-in-top-enter-active, +.zoom-in-top-leave-active { + transform-origin: center -16px; +} + +</style> diff --git a/src/server/web/app/desktop/views/components/ui.header.clock.vue b/src/server/web/app/desktop/views/components/ui.header.clock.vue new file mode 100644 index 0000000000..cd23a67506 --- /dev/null +++ b/src/server/web/app/desktop/views/components/ui.header.clock.vue @@ -0,0 +1,109 @@ +<template> +<div class="clock"> + <div class="header"> + <time ref="time"> + <span class="yyyymmdd">{{ yyyy }}/{{ mm }}/{{ dd }}</span> + <br> + <span class="hhnn">{{ hh }}<span :style="{ visibility: now.getSeconds() % 2 == 0 ? 'visible' : 'hidden' }">:</span>{{ nn }}</span> + </time> + </div> + <div class="content"> + <mk-analog-clock/> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +export default Vue.extend({ + data() { + return { + now: new Date(), + clock: null + }; + }, + computed: { + yyyy(): number { + return this.now.getFullYear(); + }, + mm(): string { + return ('0' + (this.now.getMonth() + 1)).slice(-2); + }, + dd(): string { + return ('0' + this.now.getDate()).slice(-2); + }, + hh(): string { + return ('0' + this.now.getHours()).slice(-2); + }, + nn(): string { + return ('0' + this.now.getMinutes()).slice(-2); + } + }, + mounted() { + this.tick(); + this.clock = setInterval(this.tick, 1000); + }, + beforeDestroy() { + clearInterval(this.clock); + }, + methods: { + tick() { + this.now = new Date(); + } + } +}); +</script> + +<style lang="stylus" scoped> +.clock + display inline-block + overflow visible + + > .header + padding 0 12px + text-align center + font-size 10px + + &, * + cursor: default + + &:hover + background #899492 + + & + .content + visibility visible + + > time + color #fff !important + + * + color #fff !important + + &:after + content "" + display block + clear both + + > time + display table-cell + vertical-align middle + height 48px + color #9eaba8 + + > .yyyymmdd + opacity 0.7 + + > .content + visibility hidden + display block + position absolute + top auto + right 0 + z-index 3 + margin 0 + padding 0 + width 256px + background #899492 + +</style> diff --git a/src/server/web/app/desktop/views/components/ui.header.nav.vue b/src/server/web/app/desktop/views/components/ui.header.nav.vue new file mode 100644 index 0000000000..7582e8afce --- /dev/null +++ b/src/server/web/app/desktop/views/components/ui.header.nav.vue @@ -0,0 +1,175 @@ +<template> +<div class="nav"> + <ul> + <template v-if="os.isSignedIn"> + <li class="home" :class="{ active: $route.name == 'index' }"> + <router-link to="/"> + %fa:home% + <p>%i18n:desktop.tags.mk-ui-header-nav.home%</p> + </router-link> + </li> + <li class="messaging"> + <a @click="messaging"> + %fa:comments% + <p>%i18n:desktop.tags.mk-ui-header-nav.messaging%</p> + <template v-if="hasUnreadMessagingMessages">%fa:circle%</template> + </a> + </li> + <li class="game"> + <a @click="game"> + %fa:gamepad% + <p>ゲーム</p> + <template v-if="hasGameInvitations">%fa:circle%</template> + </a> + </li> + </template> + <li class="ch"> + <a :href="chUrl" target="_blank"> + %fa:tv% + <p>%i18n:desktop.tags.mk-ui-header-nav.ch%</p> + </a> + </li> + </ul> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { chUrl } from '../../../config'; +import MkMessagingWindow from './messaging-window.vue'; +import MkGameWindow from './game-window.vue'; + +export default Vue.extend({ + data() { + return { + hasUnreadMessagingMessages: false, + hasGameInvitations: false, + connection: null, + connectionId: null, + chUrl + }; + }, + mounted() { + if ((this as any).os.isSignedIn) { + this.connection = (this as any).os.stream.getConnection(); + this.connectionId = (this as any).os.stream.use(); + + this.connection.on('read_all_messaging_messages', this.onReadAllMessagingMessages); + this.connection.on('unread_messaging_message', this.onUnreadMessagingMessage); + this.connection.on('othello_invited', this.onOthelloInvited); + this.connection.on('othello_no_invites', this.onOthelloNoInvites); + + // Fetch count of unread messaging messages + (this as any).api('messaging/unread').then(res => { + if (res.count > 0) { + this.hasUnreadMessagingMessages = true; + } + }); + } + }, + beforeDestroy() { + if ((this as any).os.isSignedIn) { + this.connection.off('read_all_messaging_messages', this.onReadAllMessagingMessages); + this.connection.off('unread_messaging_message', this.onUnreadMessagingMessage); + this.connection.off('othello_invited', this.onOthelloInvited); + this.connection.off('othello_no_invites', this.onOthelloNoInvites); + (this as any).os.stream.dispose(this.connectionId); + } + }, + methods: { + onUnreadMessagingMessage() { + this.hasUnreadMessagingMessages = true; + }, + + onReadAllMessagingMessages() { + this.hasUnreadMessagingMessages = false; + }, + + onOthelloInvited() { + this.hasGameInvitations = true; + }, + + onOthelloNoInvites() { + this.hasGameInvitations = false; + }, + + messaging() { + (this as any).os.new(MkMessagingWindow); + }, + + game() { + (this as any).os.new(MkGameWindow); + } + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +.nav + display inline-block + margin 0 + padding 0 + line-height 3rem + vertical-align top + + > ul + display inline-block + margin 0 + padding 0 + vertical-align top + line-height 3rem + list-style none + + > li + display inline-block + vertical-align top + height 48px + line-height 48px + + &.active + > a + border-bottom solid 3px $theme-color + + > a + display inline-block + z-index 1 + height 100% + padding 0 24px + font-size 13px + font-variant small-caps + color #9eaba8 + text-decoration none + transition none + cursor pointer + + * + pointer-events none + + &:hover + color darken(#9eaba8, 20%) + text-decoration none + + > [data-fa]:first-child + margin-right 8px + + > [data-fa]:last-child + margin-left 5px + font-size 10px + color $theme-color + + @media (max-width 1100px) + margin-left -5px + + > p + display inline + margin 0 + + @media (max-width 1100px) + display none + + @media (max-width 700px) + padding 0 12px + +</style> diff --git a/src/server/web/app/desktop/views/components/ui.header.notifications.vue b/src/server/web/app/desktop/views/components/ui.header.notifications.vue new file mode 100644 index 0000000000..e829418d18 --- /dev/null +++ b/src/server/web/app/desktop/views/components/ui.header.notifications.vue @@ -0,0 +1,158 @@ +<template> +<div class="notifications"> + <button :data-active="isOpen" @click="toggle" title="%i18n:desktop.tags.mk-ui-header-notifications.title%"> + %fa:R bell%<template v-if="hasUnreadNotifications">%fa:circle%</template> + </button> + <div class="pop" v-if="isOpen"> + <mk-notifications/> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import contains from '../../../common/scripts/contains'; + +export default Vue.extend({ + data() { + return { + isOpen: false, + hasUnreadNotifications: false, + connection: null, + connectionId: null + }; + }, + mounted() { + if ((this as any).os.isSignedIn) { + this.connection = (this as any).os.stream.getConnection(); + this.connectionId = (this as any).os.stream.use(); + + this.connection.on('read_all_notifications', this.onReadAllNotifications); + this.connection.on('unread_notification', this.onUnreadNotification); + + // Fetch count of unread notifications + (this as any).api('notifications/get_unread_count').then(res => { + if (res.count > 0) { + this.hasUnreadNotifications = true; + } + }); + } + }, + beforeDestroy() { + if ((this as any).os.isSignedIn) { + this.connection.off('read_all_notifications', this.onReadAllNotifications); + this.connection.off('unread_notification', this.onUnreadNotification); + (this as any).os.stream.dispose(this.connectionId); + } + }, + methods: { + onReadAllNotifications() { + this.hasUnreadNotifications = false; + }, + + onUnreadNotification() { + this.hasUnreadNotifications = true; + }, + + toggle() { + this.isOpen ? this.close() : this.open(); + }, + + open() { + this.isOpen = true; + Array.from(document.querySelectorAll('body *')).forEach(el => { + el.addEventListener('mousedown', this.onMousedown); + }); + }, + + close() { + this.isOpen = false; + Array.from(document.querySelectorAll('body *')).forEach(el => { + el.removeEventListener('mousedown', this.onMousedown); + }); + }, + + onMousedown(e) { + e.preventDefault(); + if (!contains(this.$el, e.target) && this.$el != e.target) this.close(); + return false; + } + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +.notifications + + > button + display block + margin 0 + padding 0 + width 32px + color #9eaba8 + border none + background transparent + cursor pointer + + * + pointer-events none + + &:hover + &[data-active='true'] + color darken(#9eaba8, 20%) + + &:active + color darken(#9eaba8, 30%) + + > [data-fa].bell + font-size 1.2em + line-height 48px + + > [data-fa].circle + margin-left -5px + vertical-align super + font-size 10px + color $theme-color + + > .pop + display block + position absolute + top 56px + right -72px + width 300px + background #fff + border-radius 4px + box-shadow 0 1px 4px rgba(0, 0, 0, 0.25) + + &:before + content "" + pointer-events none + display block + position absolute + top -28px + right 74px + border-top solid 14px transparent + border-right solid 14px transparent + border-bottom solid 14px rgba(0, 0, 0, 0.1) + border-left solid 14px transparent + + &:after + content "" + pointer-events none + display block + position absolute + top -27px + right 74px + border-top solid 14px transparent + border-right solid 14px transparent + border-bottom solid 14px #fff + border-left solid 14px transparent + + > .mk-notifications + max-height 350px + font-size 1rem + overflow auto + +</style> diff --git a/src/server/web/app/desktop/views/components/ui.header.post.vue b/src/server/web/app/desktop/views/components/ui.header.post.vue new file mode 100644 index 0000000000..c2f0e07dd3 --- /dev/null +++ b/src/server/web/app/desktop/views/components/ui.header.post.vue @@ -0,0 +1,54 @@ +<template> +<div class="post"> + <button @click="post" title="%i18n:desktop.tags.mk-ui-header-post-button.post%">%fa:pencil-alt%</button> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +export default Vue.extend({ + methods: { + post() { + (this as any).apis.post(); + } + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +.post + display inline-block + padding 8px + height 100% + vertical-align top + + > button + display inline-block + margin 0 + padding 0 10px + height 100% + font-size 1.2em + font-weight normal + text-decoration none + color $theme-color-foreground + background $theme-color !important + outline none + border none + border-radius 4px + transition background 0.1s ease + cursor pointer + + * + pointer-events none + + &:hover + background lighten($theme-color, 10%) !important + + &:active + background darken($theme-color, 10%) !important + transition background 0s ease + +</style> diff --git a/src/server/web/app/desktop/views/components/ui.header.search.vue b/src/server/web/app/desktop/views/components/ui.header.search.vue new file mode 100644 index 0000000000..86215556ad --- /dev/null +++ b/src/server/web/app/desktop/views/components/ui.header.search.vue @@ -0,0 +1,70 @@ +<template> +<form class="search" @submit.prevent="onSubmit"> + %fa:search% + <input v-model="q" type="search" placeholder="%i18n:desktop.tags.mk-ui-header-search.placeholder%"/> + <div class="result"></div> +</form> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +export default Vue.extend({ + data() { + return { + q: '' + }; + }, + methods: { + onSubmit() { + location.href = `/search?q=${encodeURIComponent(this.q)}`; + } + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +.search + + > [data-fa] + display block + position absolute + top 0 + left 0 + width 48px + text-align center + line-height 48px + color #9eaba8 + pointer-events none + + > * + vertical-align middle + + > input + user-select text + cursor auto + margin 8px 0 0 0 + padding 6px 18px 6px 36px + width 14em + height 32px + font-size 1em + background rgba(0, 0, 0, 0.05) + outline none + //border solid 1px #ddd + border none + border-radius 16px + transition color 0.5s ease, border 0.5s ease + font-family FontAwesome, sans-serif + + &::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> diff --git a/src/server/web/app/desktop/views/components/ui.header.vue b/src/server/web/app/desktop/views/components/ui.header.vue new file mode 100644 index 0000000000..8af0e2fbed --- /dev/null +++ b/src/server/web/app/desktop/views/components/ui.header.vue @@ -0,0 +1,172 @@ +<template> +<div class="header"> + <mk-special-message/> + <div class="main" ref="main"> + <div class="backdrop"></div> + <div class="main"> + <p ref="welcomeback" v-if="os.isSignedIn">おかえりなさい、<b>{{ os.i.name }}</b>さん</p> + <div class="container" ref="mainContainer"> + <div class="left"> + <x-nav/> + </div> + <div class="right"> + <x-search/> + <x-account v-if="os.isSignedIn"/> + <x-notifications v-if="os.isSignedIn"/> + <x-post v-if="os.isSignedIn"/> + <x-clock/> + </div> + </div> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import * as anime from 'animejs'; + +import XNav from './ui.header.nav.vue'; +import XSearch from './ui.header.search.vue'; +import XAccount from './ui.header.account.vue'; +import XNotifications from './ui.header.notifications.vue'; +import XPost from './ui.header.post.vue'; +import XClock from './ui.header.clock.vue'; + +export default Vue.extend({ + components: { + XNav, + XSearch, + XAccount, + XNotifications, + XPost, + XClock, + }, + mounted() { + if ((this as any).os.isSignedIn) { + const ago = (new Date().getTime() - new Date((this as any).os.i.account.last_used_at).getTime()) / 1000 + const isHisasiburi = ago >= 3600; + (this as any).os.i.account.last_used_at = new Date(); + if (isHisasiburi) { + (this.$refs.welcomeback as any).style.display = 'block'; + (this.$refs.main as any).style.overflow = 'hidden'; + + anime({ + targets: this.$refs.welcomeback, + top: '0', + opacity: 1, + delay: 1000, + duration: 500, + easing: 'easeOutQuad' + }); + + anime({ + targets: this.$refs.mainContainer, + opacity: 0, + delay: 1000, + duration: 500, + easing: 'easeOutQuad' + }); + + setTimeout(() => { + anime({ + targets: this.$refs.welcomeback, + top: '-48px', + opacity: 0, + duration: 500, + complete: () => { + (this.$refs.welcomeback as any).style.display = 'none'; + (this.$refs.main as any).style.overflow = 'initial'; + }, + easing: 'easeInQuad' + }); + + anime({ + targets: this.$refs.mainContainer, + opacity: 1, + duration: 500, + easing: 'easeInQuad' + }); + }, 2500); + } + } + } +}); +</script> + +<style lang="stylus" scoped> +.header + position -webkit-sticky + position sticky + top 0 + z-index 1000 + width 100% + box-shadow 0 1px 1px rgba(0, 0, 0, 0.075) + + > .main + height 48px + + > .backdrop + position absolute + top 0 + z-index 1000 + width 100% + height 48px + background #f7f7f7 + + > .main + z-index 1001 + margin 0 + padding 0 + background-clip content-box + font-size 0.9rem + user-select none + + > p + display none + position absolute + top 48px + width 100% + line-height 48px + margin 0 + text-align center + color #888 + opacity 0 + + > .container + display flex + width 100% + max-width 1300px + margin 0 auto + + &:before + content "" + position absolute + top 0 + left 0 + display block + width 100% + height 48px + background-image url(/assets/desktop/header-logo.svg) + background-size 46px + background-position center + background-repeat no-repeat + opacity 0.3 + + > .left + margin 0 auto 0 0 + height 48px + + > .right + margin 0 0 0 auto + height 48px + + > * + display inline-block + vertical-align top + + @media (max-width 1100px) + > .mk-ui-header-search + display none + +</style> diff --git a/src/server/web/app/desktop/views/components/ui.vue b/src/server/web/app/desktop/views/components/ui.vue new file mode 100644 index 0000000000..87f932ff14 --- /dev/null +++ b/src/server/web/app/desktop/views/components/ui.vue @@ -0,0 +1,37 @@ +<template> +<div> + <x-header/> + <div class="content"> + <slot></slot> + </div> + <mk-stream-indicator v-if="os.isSignedIn"/> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import XHeader from './ui.header.vue'; + +export default Vue.extend({ + components: { + XHeader + }, + mounted() { + document.addEventListener('keydown', this.onKeydown); + }, + beforeDestroy() { + document.removeEventListener('keydown', this.onKeydown); + }, + methods: { + onKeydown(e) { + if (e.target.tagName == 'INPUT' || e.target.tagName == 'TEXTAREA') return; + + if (e.which == 80 || e.which == 78) { // p or n + e.preventDefault(); + (this as any).apis.post(); + } + } + } +}); +</script> + diff --git a/src/server/web/app/desktop/views/components/user-preview.vue b/src/server/web/app/desktop/views/components/user-preview.vue new file mode 100644 index 0000000000..24d613f120 --- /dev/null +++ b/src/server/web/app/desktop/views/components/user-preview.vue @@ -0,0 +1,173 @@ +<template> +<div class="mk-user-preview"> + <template v-if="u != null"> + <div class="banner" :style="u.banner_url ? `background-image: url(${u.banner_url}?thumbnail&size=512)` : ''"></div> + <router-link class="avatar" :to="`/@${acct}`"> + <img :src="`${u.avatar_url}?thumbnail&size=64`" alt="avatar"/> + </router-link> + <div class="title"> + <router-link class="name" :to="`/@${acct}`">{{ u.name }}</router-link> + <p class="username">@{{ acct }}</p> + </div> + <div class="description">{{ u.description }}</div> + <div class="status"> + <div> + <p>投稿</p><a>{{ u.posts_count }}</a> + </div> + <div> + <p>フォロー</p><a>{{ u.following_count }}</a> + </div> + <div> + <p>フォロワー</p><a>{{ u.followers_count }}</a> + </div> + </div> + <mk-follow-button v-if="os.isSignedIn && user.id != os.i.id" :user="u"/> + </template> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import * as anime from 'animejs'; +import getAcct from '../../../../../common/user/get-acct'; +import parseAcct from '../../../../../common/user/parse-acct'; + +export default Vue.extend({ + props: { + user: { + type: [Object, String], + required: true + } + }, + computed: { + acct() { + return getAcct(this.u); + } + }, + data() { + return { + u: null + }; + }, + mounted() { + if (typeof this.user == 'object') { + this.u = this.user; + this.$nextTick(() => { + this.open(); + }); + } else { + const query = this.user[0] == '@' ? + parseAcct(this.user[0].substr(1)) : + { user_id: this.user[0] }; + + (this as any).api('users/show', query).then(user => { + this.u = user; + this.open(); + }); + } + }, + methods: { + open() { + anime({ + targets: this.$el, + opacity: 1, + 'margin-top': 0, + duration: 200, + easing: 'easeOutQuad' + }); + }, + close() { + anime({ + targets: this.$el, + opacity: 0, + 'margin-top': '-8px', + duration: 200, + easing: 'easeOutQuad', + complete: () => this.$destroy() + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +.mk-user-preview + position absolute + z-index 2048 + margin-top -8px + width 250px + background #fff + background-clip content-box + border solid 1px rgba(0, 0, 0, 0.1) + border-radius 4px + overflow hidden + opacity 0 + + > .banner + height 84px + background-color #f5f5f5 + background-size cover + background-position center + + > .avatar + display block + position absolute + top 62px + left 13px + z-index 2 + + > img + display block + width 58px + height 58px + margin 0 + border solid 3px #fff + border-radius 8px + + > .title + display block + padding 8px 0 8px 82px + + > .name + display inline-block + margin 0 + font-weight bold + line-height 16px + color #656565 + + > .username + display block + margin 0 + line-height 16px + font-size 0.8em + color #999 + + > .description + padding 0 16px + font-size 0.7em + color #555 + + > .status + padding 8px 16px + + > div + display inline-block + width 33% + + > p + margin 0 + font-size 0.7em + color #aaa + + > a + font-size 1em + color $theme-color + + > .mk-follow-button + position absolute + top 92px + right 8px + +</style> diff --git a/src/server/web/app/desktop/views/components/users-list.item.vue b/src/server/web/app/desktop/views/components/users-list.item.vue new file mode 100644 index 0000000000..e02d1311d2 --- /dev/null +++ b/src/server/web/app/desktop/views/components/users-list.item.vue @@ -0,0 +1,107 @@ +<template> +<div class="root item"> + <router-link class="avatar-anchor" :to="`/@${acct}`" v-user-preview="user.id"> + <img class="avatar" :src="`${user.avatar_url}?thumbnail&size=64`" alt="avatar"/> + </router-link> + <div class="main"> + <header> + <router-link class="name" :to="`/@${acct}`" v-user-preview="user.id">{{ user.name }}</router-link> + <span class="username">@{{ acct }}</span> + </header> + <div class="body"> + <p class="followed" v-if="user.is_followed">フォローされています</p> + <div class="description">{{ user.description }}</div> + </div> + </div> + <mk-follow-button :user="user"/> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import getAcct from '../../../../../common/user/get-acct'; + +export default Vue.extend({ + props: ['user'], + computed: { + acct() { + return getAcct(this.user); + } + } +}); +</script> + +<style lang="stylus" scoped> +.root.item + padding 16px + font-size 16px + + &:after + content "" + display block + clear both + + > .avatar-anchor + display block + float left + margin 0 16px 0 0 + + > .avatar + display block + width 58px + height 58px + margin 0 + border-radius 8px + vertical-align bottom + + > .main + float left + width calc(100% - 74px) + + > header + margin-bottom 2px + + > .name + display inline + margin 0 + padding 0 + color #777 + font-size 1em + font-weight 700 + text-align left + text-decoration none + + &:hover + text-decoration underline + + > .username + text-align left + margin 0 0 0 8px + color #ccc + + > .body + > .followed + display inline-block + margin 0 0 4px 0 + padding 2px 8px + vertical-align top + font-size 10px + color #71afc7 + background #eefaff + border-radius 4px + + > .description + cursor default + display block + margin 0 + padding 0 + overflow-wrap break-word + font-size 1.1em + color #717171 + + > .mk-follow-button + position absolute + top 16px + right 16px + +</style> diff --git a/src/server/web/app/desktop/views/components/users-list.vue b/src/server/web/app/desktop/views/components/users-list.vue new file mode 100644 index 0000000000..a08e76f573 --- /dev/null +++ b/src/server/web/app/desktop/views/components/users-list.vue @@ -0,0 +1,143 @@ +<template> +<div class="mk-users-list"> + <nav> + <div> + <span :data-is-active="mode == 'all'" @click="mode = 'all'">すべて<span>{{ count }}</span></span> + <span v-if="os.isSignedIn && youKnowCount" :data-is-active="mode == 'iknow'" @click="mode = 'iknow'">知り合い<span>{{ youKnowCount }}</span></span> + </div> + </nav> + <div class="users" v-if="!fetching && users.length != 0"> + <div v-for="u in users" :key="u.id"> + <x-item :user="u"/> + </div> + </div> + <button class="more" v-if="!fetching && next != null" @click="more" :disabled="moreFetching"> + <span v-if="!moreFetching">もっと</span> + <span v-if="moreFetching">読み込み中<mk-ellipsis/></span> + </button> + <p class="no" v-if="!fetching && users.length == 0"> + <slot></slot> + </p> + <p class="fetching" v-if="fetching">%fa:spinner .pulse .fw%読み込んでいます<mk-ellipsis/></p> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import XItem from './users-list.item.vue'; + +export default Vue.extend({ + components: { + XItem + }, + props: ['fetch', 'count', 'youKnowCount'], + data() { + return { + limit: 30, + mode: 'all', + fetching: true, + moreFetching: false, + users: [], + next: null + }; + }, + mounted() { + this._fetch(() => { + this.$emit('loaded'); + }); + }, + methods: { + _fetch(cb) { + this.fetching = true; + this.fetch(this.mode == 'iknow', this.limit, null, obj => { + this.users = obj.users; + this.next = obj.next; + this.fetching = false; + if (cb) cb(); + }); + }, + more() { + this.moreFetching = true; + this.fetch(this.mode == 'iknow', this.limit, this.next, obj => { + this.moreFetching = false; + this.users = this.users.concat(obj.users); + this.next = obj.next; + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +.mk-users-list + height 100% + background #fff + + > nav + z-index 1 + box-shadow 0 1px 0 rgba(#000, 0.1) + + > div + display flex + justify-content center + margin 0 auto + max-width 600px + + > span + display block + flex 1 1 + text-align center + line-height 52px + font-size 14px + color #657786 + border-bottom solid 2px transparent + cursor pointer + + * + pointer-events none + + &[data-is-active] + font-weight bold + color $theme-color + border-color $theme-color + cursor default + + > span + display inline-block + margin-left 4px + padding 2px 5px + font-size 12px + line-height 1 + color #888 + background #eee + border-radius 20px + + > .users + height calc(100% - 54px) + overflow auto + + > * + border-bottom solid 1px rgba(0, 0, 0, 0.05) + + > * + max-width 600px + margin 0 auto + + > .no + margin 0 + padding 16px + text-align center + color #aaa + + > .fetching + margin 0 + padding 16px + text-align center + color #aaa + + > [data-fa] + margin-right 4px + +</style> diff --git a/src/server/web/app/desktop/views/components/widget-container.vue b/src/server/web/app/desktop/views/components/widget-container.vue new file mode 100644 index 0000000000..dd42be63bb --- /dev/null +++ b/src/server/web/app/desktop/views/components/widget-container.vue @@ -0,0 +1,85 @@ +<template> +<div class="mk-widget-container" :class="{ naked }"> + <header :class="{ withGradient }" v-if="showHeader"> + <div class="title"><slot name="header"></slot></div> + <slot name="func"></slot> + </header> + <slot></slot> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + props: { + showHeader: { + type: Boolean, + default: true + }, + naked: { + type: Boolean, + default: false + } + }, + computed: { + withGradient(): boolean { + return (this as any).os.isSignedIn + ? (this as any).os.i.account.client_settings.gradientWindowHeader != null + ? (this as any).os.i.account.client_settings.gradientWindowHeader + : false + : false; + } + } +}); +</script> + +<style lang="stylus" scoped> +.mk-widget-container + background #fff + border solid 1px rgba(0, 0, 0, 0.075) + border-radius 6px + overflow hidden + + &.naked + background transparent !important + border none !important + + > header + > .title + z-index 1 + margin 0 + padding 0 16px + line-height 42px + font-size 0.9em + font-weight bold + color #888 + box-shadow 0 1px rgba(0, 0, 0, 0.07) + + > [data-fa] + margin-right 4px + + &:empty + display none + + > 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 + + &.withGradient + > .title + background linear-gradient(to bottom, #fff, #ececec) + box-shadow 0 1px rgba(#000, 0.11) +</style> diff --git a/src/server/web/app/desktop/views/components/window.vue b/src/server/web/app/desktop/views/components/window.vue new file mode 100644 index 0000000000..75f725d4b0 --- /dev/null +++ b/src/server/web/app/desktop/views/components/window.vue @@ -0,0 +1,635 @@ +<template> +<div class="mk-window" :data-flexible="isFlexible" @dragover="onDragover"> + <div class="bg" ref="bg" v-show="isModal" @click="onBgClick"></div> + <div class="main" ref="main" tabindex="-1" :data-is-modal="isModal" @mousedown="onBodyMousedown" @keydown="onKeydown" :style="{ width, height }"> + <div class="body"> + <header ref="header" + :class="{ withGradient }" + @contextmenu.prevent="() => {}" @mousedown.prevent="onHeaderMousedown" + > + <h1><slot name="header"></slot></h1> + <div> + <button class="popout" v-if="popoutUrl" @mousedown.stop="() => {}" @click="popout" title="ポップアウト">%fa:R window-restore%</button> + <button class="close" v-if="canClose" @mousedown.stop="() => {}" @click="close" title="閉じる">%fa:times%</button> + </div> + </header> + <div class="content"> + <slot></slot> + </div> + </div> + <div class="handle top" v-if="canResize" @mousedown.prevent="onTopHandleMousedown"></div> + <div class="handle right" v-if="canResize" @mousedown.prevent="onRightHandleMousedown"></div> + <div class="handle bottom" v-if="canResize" @mousedown.prevent="onBottomHandleMousedown"></div> + <div class="handle left" v-if="canResize" @mousedown.prevent="onLeftHandleMousedown"></div> + <div class="handle top-left" v-if="canResize" @mousedown.prevent="onTopLeftHandleMousedown"></div> + <div class="handle top-right" v-if="canResize" @mousedown.prevent="onTopRightHandleMousedown"></div> + <div class="handle bottom-right" v-if="canResize" @mousedown.prevent="onBottomRightHandleMousedown"></div> + <div class="handle bottom-left" v-if="canResize" @mousedown.prevent="onBottomLeftHandleMousedown"></div> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import * as anime from 'animejs'; +import contains from '../../../common/scripts/contains'; + +const minHeight = 40; +const minWidth = 200; + +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); +} + +export default Vue.extend({ + props: { + isModal: { + type: Boolean, + default: false + }, + canClose: { + type: Boolean, + default: true + }, + width: { + type: String, + default: '530px' + }, + height: { + type: String, + default: 'auto' + }, + popoutUrl: { + type: [String, Function], + default: null + }, + name: { + type: String, + default: null + } + }, + + data() { + return { + preventMount: false + }; + }, + + computed: { + isFlexible(): boolean { + return this.height == null; + }, + canResize(): boolean { + return !this.isFlexible; + }, + withGradient(): boolean { + return (this as any).os.isSignedIn + ? (this as any).os.i.account.client_settings.gradientWindowHeader != null + ? (this as any).os.i.account.client_settings.gradientWindowHeader + : false + : false; + } + }, + + created() { + if (localStorage.getItem('autoPopout') == 'true' && this.popoutUrl) { + this.popout(); + this.preventMount = true; + } else { + // ウィンドウをウィンドウシステムに登録 + (this as any).os.windows.add(this); + } + }, + + mounted() { + if (this.preventMount) { + this.$destroy(); + return; + } + + this.$nextTick(() => { + const main = this.$refs.main as any; + main.style.top = '15%'; + main.style.left = (window.innerWidth / 2) - (main.offsetWidth / 2) + 'px'; + + window.addEventListener('resize', this.onBrowserResize); + + this.open(); + }); + }, + + destroyed() { + // ウィンドウをウィンドウシステムから削除 + (this as any).os.windows.remove(this); + + window.removeEventListener('resize', this.onBrowserResize); + }, + + methods: { + open() { + this.$emit('opening'); + + this.top(); + + const bg = this.$refs.bg as any; + const main = this.$refs.main as any; + + if (this.isModal) { + bg.style.pointerEvents = 'auto'; + anime({ + targets: bg, + opacity: 1, + duration: 100, + easing: 'linear' + }); + } + + main.style.pointerEvents = 'auto'; + anime({ + targets: main, + opacity: 1, + scale: [1.1, 1], + duration: 200, + easing: 'easeOutQuad' + }); + + if (focus) main.focus(); + + setTimeout(() => { + this.$emit('opened'); + }, 300); + }, + + close() { + this.$emit('before-close'); + + const bg = this.$refs.bg as any; + const main = this.$refs.main as any; + + if (this.isModal) { + bg.style.pointerEvents = 'none'; + anime({ + targets: bg, + opacity: 0, + duration: 300, + easing: 'linear' + }); + } + + main.style.pointerEvents = 'none'; + + anime({ + targets: main, + opacity: 0, + scale: 0.8, + duration: 300, + easing: [0.5, -0.5, 1, 0.5] + }); + + setTimeout(() => { + this.$destroy(); + this.$emit('closed'); + }, 300); + }, + + popout() { + const url = typeof this.popoutUrl == 'function' ? this.popoutUrl() : this.popoutUrl; + + const main = this.$refs.main as any; + + if (main) { + const position = main.getBoundingClientRect(); + + const width = parseInt(getComputedStyle(main, '').width, 10); + const height = parseInt(getComputedStyle(main, '').height, 10); + const x = window.screenX + position.left; + const y = window.screenY + position.top; + + window.open(url, url, + `width=${width}, height=${height}, top=${y}, left=${x}`); + + this.close(); + } else { + const x = window.top.outerHeight / 2 + window.top.screenY - (parseInt(this.height, 10) / 2); + const y = window.top.outerWidth / 2 + window.top.screenX - (parseInt(this.width, 10) / 2); + window.open(url, url, + `width=${this.width}, height=${this.height}, top=${x}, left=${y}`); + } + }, + + // 最前面へ移動 + top() { + let z = 0; + + (this as any).os.windows.getAll().forEach(w => { + if (w == this) return; + const m = w.$refs.main; + const mz = Number(document.defaultView.getComputedStyle(m, null).zIndex); + if (mz > z) z = mz; + }); + + if (z > 0) { + (this.$refs.main as any).style.zIndex = z + 1; + if (this.isModal) (this.$refs.bg as any).style.zIndex = z + 1; + } + }, + + onBgClick() { + if (this.canClose) this.close(); + }, + + onBodyMousedown() { + this.top(); + }, + + onHeaderMousedown(e) { + const main = this.$refs.main as any; + + if (!contains(main, document.activeElement)) main.focus(); + + const position = main.getBoundingClientRect(); + + const clickX = e.clientX; + const clickY = e.clientY; + const moveBaseX = clickX - position.left; + const moveBaseY = clickY - position.top; + const browserWidth = window.innerWidth; + const browserHeight = window.innerHeight; + const windowWidth = main.offsetWidth; + const windowHeight = main.offsetHeight; + + // 動かした時 + dragListen(me => { + let moveLeft = me.clientX - moveBaseX; + let moveTop = me.clientY - moveBaseY; + + // 上はみ出し + if (moveTop < 0) moveTop = 0; + + // 左はみ出し + if (moveLeft < 0) moveLeft = 0; + + // 下はみ出し + if (moveTop + windowHeight > browserHeight) moveTop = browserHeight - windowHeight; + + // 右はみ出し + if (moveLeft + windowWidth > browserWidth) moveLeft = browserWidth - windowWidth; + + main.style.left = moveLeft + 'px'; + main.style.top = moveTop + 'px'; + }); + }, + + // 上ハンドル掴み時 + onTopHandleMousedown(e) { + const main = this.$refs.main as any; + + const base = e.clientY; + const height = parseInt(getComputedStyle(main, '').height, 10); + const top = parseInt(getComputedStyle(main, '').top, 10); + + // 動かした時 + dragListen(me => { + const move = me.clientY - base; + if (top + move > 0) { + if (height + -move > minHeight) { + this.applyTransformHeight(height + -move); + this.applyTransformTop(top + move); + } else { // 最小の高さより小さくなろうとした時 + this.applyTransformHeight(minHeight); + this.applyTransformTop(top + (height - minHeight)); + } + } else { // 上のはみ出し時 + this.applyTransformHeight(top + height); + this.applyTransformTop(0); + } + }); + }, + + // 右ハンドル掴み時 + onRightHandleMousedown(e) { + const main = this.$refs.main as any; + + const base = e.clientX; + const width = parseInt(getComputedStyle(main, '').width, 10); + const left = parseInt(getComputedStyle(main, '').left, 10); + const browserWidth = window.innerWidth; + + // 動かした時 + dragListen(me => { + const move = me.clientX - base; + if (left + width + move < browserWidth) { + if (width + move > minWidth) { + this.applyTransformWidth(width + move); + } else { // 最小の幅より小さくなろうとした時 + this.applyTransformWidth(minWidth); + } + } else { // 右のはみ出し時 + this.applyTransformWidth(browserWidth - left); + } + }); + }, + + // 下ハンドル掴み時 + onBottomHandleMousedown(e) { + const main = this.$refs.main as any; + + const base = e.clientY; + const height = parseInt(getComputedStyle(main, '').height, 10); + const top = parseInt(getComputedStyle(main, '').top, 10); + const browserHeight = window.innerHeight; + + // 動かした時 + dragListen(me => { + const move = me.clientY - base; + if (top + height + move < browserHeight) { + if (height + move > minHeight) { + this.applyTransformHeight(height + move); + } else { // 最小の高さより小さくなろうとした時 + this.applyTransformHeight(minHeight); + } + } else { // 下のはみ出し時 + this.applyTransformHeight(browserHeight - top); + } + }); + }, + + // 左ハンドル掴み時 + onLeftHandleMousedown(e) { + const main = this.$refs.main as any; + + const base = e.clientX; + const width = parseInt(getComputedStyle(main, '').width, 10); + const left = parseInt(getComputedStyle(main, '').left, 10); + + // 動かした時 + dragListen(me => { + const move = me.clientX - base; + if (left + move > 0) { + if (width + -move > minWidth) { + this.applyTransformWidth(width + -move); + this.applyTransformLeft(left + move); + } else { // 最小の幅より小さくなろうとした時 + this.applyTransformWidth(minWidth); + this.applyTransformLeft(left + (width - minWidth)); + } + } else { // 左のはみ出し時 + this.applyTransformWidth(left + width); + this.applyTransformLeft(0); + } + }); + }, + + // 左上ハンドル掴み時 + onTopLeftHandleMousedown(e) { + this.onTopHandleMousedown(e); + this.onLeftHandleMousedown(e); + }, + + // 右上ハンドル掴み時 + onTopRightHandleMousedown(e) { + this.onTopHandleMousedown(e); + this.onRightHandleMousedown(e); + }, + + // 右下ハンドル掴み時 + onBottomRightHandleMousedown(e) { + this.onBottomHandleMousedown(e); + this.onRightHandleMousedown(e); + }, + + // 左下ハンドル掴み時 + onBottomLeftHandleMousedown(e) { + this.onBottomHandleMousedown(e); + this.onLeftHandleMousedown(e); + }, + + // 高さを適用 + applyTransformHeight(height) { + (this.$refs.main as any).style.height = height + 'px'; + }, + + // 幅を適用 + applyTransformWidth(width) { + (this.$refs.main as any).style.width = width + 'px'; + }, + + // Y座標を適用 + applyTransformTop(top) { + (this.$refs.main as any).style.top = top + 'px'; + }, + + // X座標を適用 + applyTransformLeft(left) { + (this.$refs.main as any).style.left = left + 'px'; + }, + + onDragover(e) { + e.dataTransfer.dropEffect = 'none'; + }, + + onKeydown(e) { + if (e.which == 27) { // Esc + if (this.canClose) { + e.preventDefault(); + e.stopPropagation(); + this.close(); + } + } + }, + + onBrowserResize() { + const main = this.$refs.main as any; + const position = main.getBoundingClientRect(); + const browserWidth = window.innerWidth; + const browserHeight = window.innerHeight; + const windowWidth = main.offsetWidth; + const windowHeight = main.offsetHeight; + if (position.left < 0) main.style.left = 0; + if (position.top < 0) main.style.top = 0; + if (position.left + windowWidth > browserWidth) main.style.left = browserWidth - windowWidth + 'px'; + if (position.top + windowHeight > browserHeight) main.style.top = browserHeight - windowHeight + 'px'; + } + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +.mk-window + display block + + > .bg + display block + position fixed + z-index 2000 + top 0 + left 0 + width 100% + height 100% + background rgba(0, 0, 0, 0.7) + opacity 0 + pointer-events none + + > .main + display block + position fixed + z-index 2000 + top 15% + left 0 + margin 0 + opacity 0 + pointer-events none + + &:focus + &:not([data-is-modal]) + > .body + box-shadow 0 0 0px 1px rgba($theme-color, 0.5), 0 2px 6px 0 rgba(0, 0, 0, 0.2) + + > .handle + $size = 8px + + position absolute + + &.top + top -($size) + left 0 + width 100% + height $size + cursor ns-resize + + &.right + top 0 + right -($size) + width $size + height 100% + cursor ew-resize + + &.bottom + bottom -($size) + left 0 + width 100% + height $size + cursor ns-resize + + &.left + top 0 + left -($size) + width $size + height 100% + cursor ew-resize + + &.top-left + top -($size) + left -($size) + width $size * 2 + height $size * 2 + cursor nwse-resize + + &.top-right + top -($size) + right -($size) + width $size * 2 + height $size * 2 + cursor nesw-resize + + &.bottom-right + bottom -($size) + right -($size) + width $size * 2 + height $size * 2 + cursor nwse-resize + + &.bottom-left + bottom -($size) + left -($size) + width $size * 2 + height $size * 2 + cursor nesw-resize + + > .body + height 100% + overflow hidden + background #fff + border-radius 6px + box-shadow 0 2px 6px 0 rgba(0, 0, 0, 0.2) + + > header + $header-height = 40px + + z-index 1001 + height $header-height + overflow hidden + white-space nowrap + cursor move + background #fff + border-radius 6px 6px 0 0 + box-shadow 0 1px 0 rgba(#000, 0.1) + + &.withGradient + background linear-gradient(to bottom, #fff, #ececec) + box-shadow 0 1px 0 rgba(#000, 0.15) + + &, * + user-select none + + > h1 + pointer-events none + display block + margin 0 auto + overflow hidden + height $header-height + text-overflow ellipsis + text-align center + font-size 1em + line-height $header-height + font-weight normal + color #666 + + > div:last-child + position absolute + top 0 + right 0 + display block + z-index 1 + + > * + display inline-block + margin 0 + padding 0 + cursor pointer + font-size 1em + color rgba(#000, 0.4) + border none + outline none + background transparent + + &:hover + color rgba(#000, 0.6) + + &:active + color darken(#000, 30%) + + > [data-fa] + padding 0 + width $header-height + line-height $header-height + text-align center + + > .content + height 100% + + &:not([flexible]) + > .main > .body > .content + height calc(100% - 40px) + +</style> diff --git a/src/server/web/app/desktop/views/directives/index.ts b/src/server/web/app/desktop/views/directives/index.ts new file mode 100644 index 0000000000..324e07596d --- /dev/null +++ b/src/server/web/app/desktop/views/directives/index.ts @@ -0,0 +1,6 @@ +import Vue from 'vue'; + +import userPreview from './user-preview'; + +Vue.directive('userPreview', userPreview); +Vue.directive('user-preview', userPreview); diff --git a/src/server/web/app/desktop/views/directives/user-preview.ts b/src/server/web/app/desktop/views/directives/user-preview.ts new file mode 100644 index 0000000000..8a4035881a --- /dev/null +++ b/src/server/web/app/desktop/views/directives/user-preview.ts @@ -0,0 +1,72 @@ +/** + * マウスオーバーするとユーザーがプレビューされる要素を設定します + */ + +import MkUserPreview from '../components/user-preview.vue'; + +export default { + bind(el, binding, vn) { + const self = el._userPreviewDirective_ = {} as any; + + self.user = binding.value; + self.tag = null; + self.showTimer = null; + self.hideTimer = null; + + self.close = () => { + if (self.tag) { + self.tag.close(); + self.tag = null; + } + }; + + const show = () => { + if (self.tag) return; + + self.tag = new MkUserPreview({ + parent: vn.context, + propsData: { + user: self.user + } + }).$mount(); + + const preview = self.tag.$el; + const rect = el.getBoundingClientRect(); + const x = rect.left + el.offsetWidth + window.pageXOffset; + const y = rect.top + window.pageYOffset; + + preview.style.top = y + 'px'; + preview.style.left = x + 'px'; + + preview.addEventListener('mouseover', () => { + clearTimeout(self.hideTimer); + }); + + preview.addEventListener('mouseleave', () => { + clearTimeout(self.showTimer); + self.hideTimer = setTimeout(self.close, 500); + }); + + document.body.appendChild(preview); + }; + + el.addEventListener('mouseover', () => { + clearTimeout(self.showTimer); + clearTimeout(self.hideTimer); + self.showTimer = setTimeout(show, 500); + }); + + el.addEventListener('mouseleave', () => { + clearTimeout(self.showTimer); + clearTimeout(self.hideTimer); + self.hideTimer = setTimeout(self.close, 500); + }); + }, + + unbind(el, binding, vn) { + const self = el._userPreviewDirective_; + clearTimeout(self.showTimer); + clearTimeout(self.hideTimer); + self.close(); + } +}; diff --git a/src/server/web/app/desktop/views/pages/drive.vue b/src/server/web/app/desktop/views/pages/drive.vue new file mode 100644 index 0000000000..353f59b703 --- /dev/null +++ b/src/server/web/app/desktop/views/pages/drive.vue @@ -0,0 +1,52 @@ +<template> +<div class="mk-drive-page"> + <mk-drive :init-folder="folder" @move-root="onMoveRoot" @open-folder="onOpenFolder"/> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + data() { + return { + folder: null + }; + }, + created() { + this.folder = this.$route.params.folder; + }, + mounted() { + document.title = 'Misskey Drive'; + }, + methods: { + onMoveRoot() { + const title = 'Misskey Drive'; + + // Rewrite URL + history.pushState(null, title, '/i/drive'); + + document.title = title; + }, + onOpenFolder(folder) { + const title = folder.name + ' | Misskey Drive'; + + // Rewrite URL + history.pushState(null, title, '/i/drive/folder/' + folder.id); + + document.title = title; + } + } +}); +</script> + +<style lang="stylus" scoped> +.mk-drive-page + position fixed + width 100% + height 100% + background #fff + + > .mk-drive + height 100% +</style> + diff --git a/src/server/web/app/desktop/views/pages/home-customize.vue b/src/server/web/app/desktop/views/pages/home-customize.vue new file mode 100644 index 0000000000..8aa06be57f --- /dev/null +++ b/src/server/web/app/desktop/views/pages/home-customize.vue @@ -0,0 +1,12 @@ +<template> +<mk-home customize/> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + mounted() { + document.title = 'Misskey - ホームのカスタマイズ'; + } +}); +</script> diff --git a/src/server/web/app/desktop/views/pages/home.vue b/src/server/web/app/desktop/views/pages/home.vue new file mode 100644 index 0000000000..e1464bab1d --- /dev/null +++ b/src/server/web/app/desktop/views/pages/home.vue @@ -0,0 +1,62 @@ +<template> +<mk-ui> + <mk-home :mode="mode" @loaded="loaded"/> +</mk-ui> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import Progress from '../../../common/scripts/loading'; +import getPostSummary from '../../../../../common/get-post-summary'; + +export default Vue.extend({ + props: { + mode: { + type: String, + default: 'timeline' + } + }, + data() { + return { + connection: null, + connectionId: null, + unreadCount: 0 + }; + }, + mounted() { + document.title = 'Misskey'; + + this.connection = (this as any).os.stream.getConnection(); + this.connectionId = (this as any).os.stream.use(); + + this.connection.on('post', this.onStreamPost); + document.addEventListener('visibilitychange', this.onVisibilitychange, false); + + Progress.start(); + }, + beforeDestroy() { + this.connection.off('post', this.onStreamPost); + (this as any).os.stream.dispose(this.connectionId); + document.removeEventListener('visibilitychange', this.onVisibilitychange); + }, + methods: { + loaded() { + Progress.done(); + }, + + onStreamPost(post) { + if (document.hidden && post.user_id != (this as any).os.i.id) { + this.unreadCount++; + document.title = `(${this.unreadCount}) ${getPostSummary(post)}`; + } + }, + + onVisibilitychange() { + if (!document.hidden) { + this.unreadCount = 0; + document.title = 'Misskey'; + } + } + } +}); +</script> diff --git a/src/server/web/app/desktop/views/pages/index.vue b/src/server/web/app/desktop/views/pages/index.vue new file mode 100644 index 0000000000..0ea47d913b --- /dev/null +++ b/src/server/web/app/desktop/views/pages/index.vue @@ -0,0 +1,16 @@ +<template> +<component :is="os.isSignedIn ? 'home' : 'welcome'"></component> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import Home from './home.vue'; +import Welcome from './welcome.vue'; + +export default Vue.extend({ + components: { + Home, + Welcome + } +}); +</script> diff --git a/src/server/web/app/desktop/views/pages/messaging-room.vue b/src/server/web/app/desktop/views/pages/messaging-room.vue new file mode 100644 index 0000000000..0cab1e0d10 --- /dev/null +++ b/src/server/web/app/desktop/views/pages/messaging-room.vue @@ -0,0 +1,54 @@ +<template> +<div class="mk-messaging-room-page"> + <mk-messaging-room v-if="user" :user="user" :is-naked="true"/> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import Progress from '../../../common/scripts/loading'; +import parseAcct from '../../../../../common/user/parse-acct'; + +export default Vue.extend({ + data() { + return { + fetching: true, + user: null + }; + }, + watch: { + $route: 'fetch' + }, + created() { + this.fetch(); + }, + mounted() { + document.documentElement.style.background = '#fff'; + }, + methods: { + fetch() { + Progress.start(); + this.fetching = true; + + (this as any).api('users/show', parseAcct(this.$route.params.user)).then(user => { + this.user = user; + this.fetching = false; + + document.title = 'メッセージ: ' + this.user.name; + + Progress.done(); + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +.mk-messaging-room-page + display flex + flex 1 + flex-direction column + min-height 100% + background #fff + +</style> diff --git a/src/server/web/app/desktop/views/pages/othello.vue b/src/server/web/app/desktop/views/pages/othello.vue new file mode 100644 index 0000000000..160dd9a354 --- /dev/null +++ b/src/server/web/app/desktop/views/pages/othello.vue @@ -0,0 +1,50 @@ +<template> +<component :is="ui ? 'mk-ui' : 'div'"> + <mk-othello v-if="!fetching" :init-game="game" @gamed="onGamed"/> +</component> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import Progress from '../../../common/scripts/loading'; + +export default Vue.extend({ + props: { + ui: { + default: false + } + }, + data() { + return { + fetching: false, + game: null + }; + }, + watch: { + $route: 'fetch' + }, + created() { + this.fetch(); + }, + methods: { + fetch() { + if (this.$route.params.game == null) return; + + Progress.start(); + this.fetching = true; + + (this as any).api('othello/games/show', { + game_id: this.$route.params.game + }).then(game => { + this.game = game; + this.fetching = false; + + Progress.done(); + }); + }, + onGamed(game) { + history.pushState(null, null, '/othello/' + game.id); + } + } +}); +</script> diff --git a/src/server/web/app/desktop/views/pages/post.vue b/src/server/web/app/desktop/views/pages/post.vue new file mode 100644 index 0000000000..c7b8729b72 --- /dev/null +++ b/src/server/web/app/desktop/views/pages/post.vue @@ -0,0 +1,67 @@ +<template> +<mk-ui> + <main v-if="!fetching"> + <a v-if="post.next" :href="post.next">%fa:angle-up%%i18n:desktop.tags.mk-post-page.next%</a> + <mk-post-detail :post="post"/> + <a v-if="post.prev" :href="post.prev">%fa:angle-down%%i18n:desktop.tags.mk-post-page.prev%</a> + </main> +</mk-ui> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import Progress from '../../../common/scripts/loading'; + +export default Vue.extend({ + data() { + return { + fetching: true, + post: null + }; + }, + watch: { + $route: 'fetch' + }, + created() { + this.fetch(); + }, + methods: { + fetch() { + Progress.start(); + this.fetching = true; + + (this as any).api('posts/show', { + post_id: this.$route.params.post + }).then(post => { + this.post = post; + this.fetching = false; + + Progress.done(); + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +main + padding 16px + text-align center + + > a + display inline-block + + &:first-child + margin-bottom 4px + + &:last-child + margin-top 4px + + > [data-fa] + margin-right 4px + + > .mk-post-detail + margin 0 auto + width 640px + +</style> diff --git a/src/server/web/app/desktop/views/pages/search.vue b/src/server/web/app/desktop/views/pages/search.vue new file mode 100644 index 0000000000..afd37c8cee --- /dev/null +++ b/src/server/web/app/desktop/views/pages/search.vue @@ -0,0 +1,138 @@ +<template> +<mk-ui> + <header :class="$style.header"> + <h1>{{ q }}</h1> + </header> + <div :class="$style.loading" v-if="fetching"> + <mk-ellipsis-icon/> + </div> + <p :class="$style.empty" v-if="!fetching && empty">%fa:search%「{{ q }}」に関する投稿は見つかりませんでした。</p> + <mk-posts ref="timeline" :class="$style.posts" :posts="posts"> + <div slot="footer"> + <template v-if="!moreFetching">%fa:search%</template> + <template v-if="moreFetching">%fa:spinner .pulse .fw%</template> + </div> + </mk-posts> +</mk-ui> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import Progress from '../../../common/scripts/loading'; +import parse from '../../../common/scripts/parse-search-query'; + +const limit = 20; + +export default Vue.extend({ + data() { + return { + fetching: true, + moreFetching: false, + existMore: false, + offset: 0, + posts: [] + }; + }, + watch: { + $route: 'fetch' + }, + computed: { + empty(): boolean { + return this.posts.length == 0; + }, + q(): string { + return this.$route.query.q; + } + }, + mounted() { + document.addEventListener('keydown', this.onDocumentKeydown); + window.addEventListener('scroll', this.onScroll); + + this.fetch(); + }, + beforeDestroy() { + document.removeEventListener('keydown', this.onDocumentKeydown); + window.removeEventListener('scroll', this.onScroll); + }, + methods: { + onDocumentKeydown(e) { + if (e.target.tagName != 'INPUT' && e.target.tagName != 'TEXTAREA') { + if (e.which == 84) { // t + (this.$refs.timeline as any).focus(); + } + } + }, + fetch() { + this.fetching = true; + Progress.start(); + + (this as any).api('posts/search', Object.assign({ + limit: limit + 1, + offset: this.offset + }, parse(this.q))).then(posts => { + if (posts.length == limit + 1) { + posts.pop(); + this.existMore = true; + } + this.posts = posts; + this.fetching = false; + Progress.done(); + }); + }, + more() { + if (this.moreFetching || this.fetching || this.posts.length == 0 || !this.existMore) return; + this.offset += limit; + this.moreFetching = true; + return (this as any).api('posts/search', Object.assign({ + limit: limit + 1, + offset: this.offset + }, parse(this.q))).then(posts => { + if (posts.length == limit + 1) { + posts.pop(); + } else { + this.existMore = false; + } + this.posts = this.posts.concat(posts); + this.moreFetching = false; + }); + }, + onScroll() { + const current = window.scrollY + window.innerHeight; + if (current > document.body.offsetHeight - 16) this.more(); + } + } +}); +</script> + +<style lang="stylus" module> +.header + width 100% + max-width 600px + margin 0 auto + color #555 + +.posts + max-width 600px + margin 0 auto + border solid 1px rgba(0, 0, 0, 0.075) + border-radius 6px + overflow hidden + +.loading + padding 64px 0 + +.empty + display block + margin 0 auto + padding 32px + max-width 400px + text-align center + color #999 + + > [data-fa] + display block + margin-bottom 16px + font-size 3em + color #ccc + +</style> diff --git a/src/server/web/app/desktop/views/pages/selectdrive.vue b/src/server/web/app/desktop/views/pages/selectdrive.vue new file mode 100644 index 0000000000..4f0b86014b --- /dev/null +++ b/src/server/web/app/desktop/views/pages/selectdrive.vue @@ -0,0 +1,177 @@ +<template> +<div class="mkp-selectdrive"> + <mk-drive ref="browser" + :multiple="multiple" + @selected="onSelected" + @change-selection="onChangeSelection" + /> + <footer> + <button class="upload" title="%i18n:desktop.tags.mk-selectdrive-page.upload%" @click="upload">%fa:upload%</button> + <button class="cancel" @click="close">%i18n:desktop.tags.mk-selectdrive-page.cancel%</button> + <button class="ok" @click="ok">%i18n:desktop.tags.mk-selectdrive-page.ok%</button> + </footer> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +export default Vue.extend({ + data() { + return { + files: [] + }; + }, + computed: { + multiple(): boolean { + const q = (new URL(location.toString())).searchParams; + return q.get('multiple') == 'true'; + } + }, + mounted() { + document.title = '%i18n:desktop.tags.mk-selectdrive-page.title%'; + }, + methods: { + onSelected(file) { + this.files = [file]; + this.ok(); + }, + onChangeSelection(files) { + this.files = files; + }, + upload() { + (this.$refs.browser as any).selectLocalFile(); + }, + close() { + window.close(); + }, + ok() { + window.opener.cb(this.multiple ? this.files : this.files[0]); + this.close(); + } + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +.mkp-selectdrive + display block + position fixed + width 100% + height 100% + background #fff + + > .mk-drive + height calc(100% - 72px) + + > footer + position fixed + bottom 0 + left 0 + width 100% + height 72px + background lighten($theme-color, 95%) + + .upload + display inline-block + position absolute + top 8px + left 16px + cursor pointer + padding 0 + margin 8px 4px 0 0 + width 40px + height 40px + font-size 1em + color rgba($theme-color, 0.5) + background transparent + outline none + border solid 1px transparent + border-radius 4px + + &:hover + background transparent + border-color rgba($theme-color, 0.3) + + &:active + color rgba($theme-color, 0.6) + background transparent + border-color rgba($theme-color, 0.5) + box-shadow 0 2px 4px rgba(darken($theme-color, 50%), 0.15) inset + + &:focus + &:after + content "" + pointer-events none + position absolute + top -5px + right -5px + bottom -5px + left -5px + border 2px solid rgba($theme-color, 0.3) + border-radius 8px + + .ok + .cancel + display block + position absolute + bottom 16px + cursor pointer + padding 0 + margin 0 + width 120px + height 40px + font-size 1em + outline none + border-radius 4px + + &:focus + &:after + content "" + pointer-events none + position absolute + top -5px + right -5px + bottom -5px + left -5px + border 2px solid rgba($theme-color, 0.3) + border-radius 8px + + &:disabled + opacity 0.7 + cursor default + + .ok + right 16px + color $theme-color-foreground + background linear-gradient(to bottom, lighten($theme-color, 25%) 0%, lighten($theme-color, 10%) 100%) + border solid 1px lighten($theme-color, 15%) + + &:not(:disabled) + font-weight bold + + &:hover:not(:disabled) + background linear-gradient(to bottom, lighten($theme-color, 8%) 0%, darken($theme-color, 8%) 100%) + border-color $theme-color + + &:active:not(:disabled) + background $theme-color + border-color $theme-color + + .cancel + right 148px + color #888 + background linear-gradient(to bottom, #ffffff 0%, #f5f5f5 100%) + border solid 1px #e2e2e2 + + &:hover + background linear-gradient(to bottom, #f9f9f9 0%, #ececec 100%) + border-color #dcdcdc + + &:active + background #ececec + border-color #dcdcdc + +</style> diff --git a/src/server/web/app/desktop/views/pages/user/user.followers-you-know.vue b/src/server/web/app/desktop/views/pages/user/user.followers-you-know.vue new file mode 100644 index 0000000000..80b38e8acc --- /dev/null +++ b/src/server/web/app/desktop/views/pages/user/user.followers-you-know.vue @@ -0,0 +1,84 @@ +<template> +<div class="followers-you-know"> + <p class="title">%fa:users%%i18n:desktop.tags.mk-user.followers-you-know.title%</p> + <p class="initializing" v-if="fetching">%fa:spinner .pulse .fw%%i18n:desktop.tags.mk-user.followers-you-know.loading%<mk-ellipsis/></p> + <div v-if="!fetching && users.length > 0"> + <router-link v-for="user in users" :to="`/@${getAcct(user)}`" :key="user.id"> + <img :src="`${user.avatar_url}?thumbnail&size=64`" :alt="user.name" v-user-preview="user.id"/> + </router-link> + </div> + <p class="empty" v-if="!fetching && users.length == 0">%i18n:desktop.tags.mk-user.followers-you-know.no-users%</p> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import getAcct from '../../../../../../common/user/get-acct'; + +export default Vue.extend({ + props: ['user'], + data() { + return { + users: [], + fetching: true + }; + }, + method() { + getAcct + }, + mounted() { + (this as any).api('users/followers', { + user_id: this.user.id, + iknow: true, + limit: 16 + }).then(x => { + this.users = x.users; + this.fetching = false; + }); + } +}); +</script> + +<style lang="stylus" scoped> +.followers-you-know + 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> diff --git a/src/server/web/app/desktop/views/pages/user/user.friends.vue b/src/server/web/app/desktop/views/pages/user/user.friends.vue new file mode 100644 index 0000000000..57e6def27b --- /dev/null +++ b/src/server/web/app/desktop/views/pages/user/user.friends.vue @@ -0,0 +1,124 @@ +<template> +<div class="friends"> + <p class="title">%fa:users%%i18n:desktop.tags.mk-user.frequently-replied-users.title%</p> + <p class="initializing" v-if="fetching">%fa:spinner .pulse .fw%%i18n:desktop.tags.mk-user.frequently-replied-users.loading%<mk-ellipsis/></p> + <template v-if="!fetching && users.length != 0"> + <div class="user" v-for="friend in users"> + <router-link class="avatar-anchor" :to="`/@${getAcct(friend)}`"> + <img class="avatar" :src="`${friend.avatar_url}?thumbnail&size=42`" alt="" v-user-preview="friend.id"/> + </router-link> + <div class="body"> + <router-link class="name" :to="`/@${getAcct(friend)}`" v-user-preview="friend.id">{{ friend.name }}</router-link> + <p class="username">@{{ getAcct(friend) }}</p> + </div> + <mk-follow-button :user="friend"/> + </div> + </template> + <p class="empty" v-if="!fetching && users.length == 0">%i18n:desktop.tags.mk-user.frequently-replied-users.no-users%</p> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import getAcct from '../../../../../../common/user/get-acct'; + +export default Vue.extend({ + props: ['user'], + data() { + return { + users: [], + fetching: true + }; + }, + method() { + getAcct + }, + mounted() { + (this as any).api('users/get_frequently_replied_users', { + user_id: this.user.id, + limit: 4 + }).then(docs => { + this.users = docs.map(doc => doc.user); + this.fetching = false; + }); + } +}); +</script> + +<style lang="stylus" scoped> +.friends + 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> diff --git a/src/server/web/app/desktop/views/pages/user/user.header.vue b/src/server/web/app/desktop/views/pages/user/user.header.vue new file mode 100644 index 0000000000..3522e76bdb --- /dev/null +++ b/src/server/web/app/desktop/views/pages/user/user.header.vue @@ -0,0 +1,196 @@ +<template> +<div class="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)` : ''" @click="onBannerClick"></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">{{ user.name }}</p> + <p class="username">@{{ acct }}</p> + <p class="location" v-if="user.host === null && user.account.profile.location">%fa:map-marker%{{ user.account.profile.location }}</p> + </div> + <footer> + <router-link :to="`/@${acct}`" :data-active="$parent.page == 'home'">%fa:home%概要</router-link> + <router-link :to="`/@${acct}/media`" :data-active="$parent.page == 'media'">%fa:image%メディア</router-link> + <router-link :to="`/@${acct}/graphs`" :data-active="$parent.page == 'graphs'">%fa:chart-bar%グラフ</router-link> + </footer> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import getAcct from '../../../../../../common/user/get-acct'; + +export default Vue.extend({ + props: ['user'], + computed: { + acct() { + return getAcct(this.user); + } + }, + mounted() { + window.addEventListener('load', this.onScroll); + window.addEventListener('scroll', this.onScroll); + window.addEventListener('resize', this.onScroll); + }, + beforeDestroy() { + window.removeEventListener('load', this.onScroll); + window.removeEventListener('scroll', this.onScroll); + window.removeEventListener('resize', this.onScroll); + }, + methods: { + onScroll() { + const banner = this.$refs.banner as any; + + const top = window.scrollY; + + const z = 1.25; // 奥行き(小さいほど奥) + const pos = -(top / z); + banner.style.backgroundPosition = `center calc(50% - ${pos}px)`; + + const blur = top / 32 + if (blur <= 10) banner.style.filter = `blur(${blur}px)`; + }, + + onBannerClick() { + if (!(this as any).os.isSignedIn || (this as any).os.i.id != this.user.id) return; + + (this as any).apis.updateBanner((this as any).os.i, i => { + this.user.banner_url = i.banner_url; + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +.header + $banner-height = 320px + $footer-height = 58px + + overflow hidden + background #f7f7f7 + box-shadow 0 1px 1px rgba(0, 0, 0, 0.075) + + &[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> diff --git a/src/server/web/app/desktop/views/pages/user/user.home.vue b/src/server/web/app/desktop/views/pages/user/user.home.vue new file mode 100644 index 0000000000..2483a6c726 --- /dev/null +++ b/src/server/web/app/desktop/views/pages/user/user.home.vue @@ -0,0 +1,103 @@ +<template> +<div class="home"> + <div> + <div ref="left"> + <x-profile :user="user"/> + <x-photos :user="user"/> + <x-followers-you-know v-if="os.isSignedIn && os.i.id != user.id" :user="user"/> + <p v-if="user.host === null">%i18n:desktop.tags.mk-user.last-used-at%: <b><mk-time :time="user.account.last_used_at"/></b></p> + </div> + </div> + <main> + <mk-post-detail v-if="user.pinned_post" :post="user.pinned_post" :compact="true"/> + <x-timeline class="timeline" ref="tl" :user="user"/> + </main> + <div> + <div ref="right"> + <mk-calendar @chosen="warp" :start="new Date(user.created_at)"/> + <mk-activity :user="user"/> + <x-friends :user="user"/> + <div class="nav"><mk-nav/></div> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import XTimeline from './user.timeline.vue'; +import XProfile from './user.profile.vue'; +import XPhotos from './user.photos.vue'; +import XFollowersYouKnow from './user.followers-you-know.vue'; +import XFriends from './user.friends.vue'; + +export default Vue.extend({ + components: { + XTimeline, + XProfile, + XPhotos, + XFollowersYouKnow, + XFriends + }, + props: ['user'], + methods: { + warp(date) { + (this.$refs.tl as any).warp(date); + } + } +}); +</script> + +<style lang="stylus" scoped> +.home + 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) + + > .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> diff --git a/src/server/web/app/desktop/views/pages/user/user.photos.vue b/src/server/web/app/desktop/views/pages/user/user.photos.vue new file mode 100644 index 0000000000..db29a9945a --- /dev/null +++ b/src/server/web/app/desktop/views/pages/user/user.photos.vue @@ -0,0 +1,88 @@ +<template> +<div class="photos"> + <p class="title">%fa:camera%%i18n:desktop.tags.mk-user.photos.title%</p> + <p class="initializing" v-if="fetching">%fa:spinner .pulse .fw%%i18n:desktop.tags.mk-user.photos.loading%<mk-ellipsis/></p> + <div class="stream" v-if="!fetching && images.length > 0"> + <div v-for="image in images" class="img" + :style="`background-image: url(${image.url}?thumbnail&size=256)`" + ></div> + </div> + <p class="empty" v-if="!fetching && images.length == 0">%i18n:desktop.tags.mk-user.photos.no-photos%</p> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + props: ['user'], + data() { + return { + images: [], + fetching: true + }; + }, + mounted() { + (this as any).api('users/posts', { + user_id: this.user.id, + with_media: true, + limit: 9 + }).then(posts => { + posts.forEach(post => { + post.media.forEach(media => { + if (this.images.length < 9) this.images.push(media); + }); + }); + this.fetching = false; + }); + } +}); +</script> + +<style lang="stylus" scoped> +.photos + 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> diff --git a/src/server/web/app/desktop/views/pages/user/user.profile.vue b/src/server/web/app/desktop/views/pages/user/user.profile.vue new file mode 100644 index 0000000000..b51aae18fa --- /dev/null +++ b/src/server/web/app/desktop/views/pages/user/user.profile.vue @@ -0,0 +1,138 @@ +<template> +<div class="profile"> + <div class="friend-form" v-if="os.isSignedIn && os.i.id != user.id"> + <mk-follow-button :user="user" size="big"/> + <p class="followed" v-if="user.is_followed">%i18n:desktop.tags.mk-user.follows-you%</p> + <p v-if="user.is_muted">%i18n:desktop.tags.mk-user.muted% <a @click="unmute">%i18n:desktop.tags.mk-user.unmute%</a></p> + <p v-if="!user.is_muted"><a @click="mute">%i18n:desktop.tags.mk-user.mute%</a></p> + </div> + <div class="description" v-if="user.description">{{ user.description }}</div> + <div class="birthday" v-if="user.host === null && user.account.profile.birthday"> + <p>%fa:birthday-cake%{{ user.account.profile.birthday.replace('-', '年').replace('-', '月') + '日' }} ({{ age }}歳)</p> + </div> + <div class="twitter" v-if="user.host === null && user.account.twitter"> + <p>%fa:B twitter%<a :href="`https://twitter.com/${user.account.twitter.screen_name}`" target="_blank">@{{ user.account.twitter.screen_name }}</a></p> + </div> + <div class="status"> + <p class="posts-count">%fa:angle-right%<a>{{ user.posts_count }}</a><b>投稿</b></p> + <p class="following">%fa:angle-right%<a @click="showFollowing">{{ user.following_count }}</a>人を<b>フォロー</b></p> + <p class="followers">%fa:angle-right%<a @click="showFollowers">{{ user.followers_count }}</a>人の<b>フォロワー</b></p> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import * as age from 's-age'; +import MkFollowingWindow from '../../components/following-window.vue'; +import MkFollowersWindow from '../../components/followers-window.vue'; + +export default Vue.extend({ + props: ['user'], + computed: { + age(): number { + return age(this.user.account.profile.birthday); + } + }, + methods: { + showFollowing() { + (this as any).os.new(MkFollowingWindow, { + user: this.user + }); + }, + + showFollowers() { + (this as any).os.new(MkFollowersWindow, { + user: this.user + }); + }, + + mute() { + (this as any).api('mute/create', { + user_id: this.user.id + }).then(() => { + this.user.is_muted = true; + }, () => { + alert('error'); + }); + }, + + unmute() { + (this as any).api('mute/delete', { + user_id: this.user.id + }).then(() => { + this.user.is_muted = false; + }, () => { + alert('error'); + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +.profile + 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> diff --git a/src/server/web/app/desktop/views/pages/user/user.timeline.vue b/src/server/web/app/desktop/views/pages/user/user.timeline.vue new file mode 100644 index 0000000000..60eef8951b --- /dev/null +++ b/src/server/web/app/desktop/views/pages/user/user.timeline.vue @@ -0,0 +1,139 @@ +<template> +<div class="timeline"> + <header> + <span :data-is-active="mode == 'default'" @click="mode = 'default'">投稿</span> + <span :data-is-active="mode == 'with-replies'" @click="mode = 'with-replies'">投稿と返信</span> + </header> + <div class="loading" v-if="fetching"> + <mk-ellipsis-icon/> + </div> + <p class="empty" v-if="empty">%fa:R comments%このユーザーはまだ何も投稿していないようです。</p> + <mk-posts ref="timeline" :posts="posts"> + <div slot="footer"> + <template v-if="!moreFetching">%fa:moon%</template> + <template v-if="moreFetching">%fa:spinner .pulse .fw%</template> + </div> + </mk-posts> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + props: ['user'], + data() { + return { + fetching: true, + moreFetching: false, + mode: 'default', + unreadCount: 0, + posts: [], + date: null + }; + }, + watch: { + mode() { + this.fetch(); + } + }, + computed: { + empty(): boolean { + return this.posts.length == 0; + } + }, + mounted() { + document.addEventListener('keydown', this.onDocumentKeydown); + window.addEventListener('scroll', this.onScroll); + + this.fetch(() => this.$emit('loaded')); + }, + beforeDestroy() { + document.removeEventListener('keydown', this.onDocumentKeydown); + window.removeEventListener('scroll', this.onScroll); + }, + methods: { + onDocumentKeydown(e) { + if (e.target.tagName !== 'INPUT' && e.target.tagName !== 'TEXTAREA') { + if (e.which == 84) { // [t] + (this.$refs.timeline as any).focus(); + } + } + }, + fetch(cb?) { + (this as any).api('users/posts', { + user_id: this.user.id, + until_date: this.date ? this.date.getTime() : undefined, + with_replies: this.mode == 'with-replies' + }).then(posts => { + this.posts = posts; + this.fetching = false; + if (cb) cb(); + }); + }, + more() { + if (this.moreFetching || this.fetching || this.posts.length == 0) return; + this.moreFetching = true; + (this as any).api('users/posts', { + user_id: this.user.id, + with_replies: this.mode == 'with-replies', + until_id: this.posts[this.posts.length - 1].id + }).then(posts => { + this.moreFetching = false; + this.posts = this.posts.concat(posts); + }); + }, + onScroll() { + const current = window.scrollY + window.innerHeight; + if (current > document.body.offsetHeight - 16/*遊び*/) { + this.more(); + } + }, + warp(date) { + this.date = date; + this.fetch(); + } + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +.timeline + background #fff + + > header + padding 8px 16px + border-bottom solid 1px #eee + + > span + margin-right 16px + line-height 27px + font-size 18px + color #555 + + &:not([data-is-active]) + color $theme-color + cursor pointer + + &:hover + text-decoration underline + + > .loading + padding 64px 0 + + > .empty + display block + margin 0 auto + padding 32px + max-width 400px + text-align center + color #999 + + > [data-fa] + display block + margin-bottom 16px + font-size 3em + color #ccc + +</style> diff --git a/src/server/web/app/desktop/views/pages/user/user.vue b/src/server/web/app/desktop/views/pages/user/user.vue new file mode 100644 index 0000000000..67cef93269 --- /dev/null +++ b/src/server/web/app/desktop/views/pages/user/user.vue @@ -0,0 +1,53 @@ +<template> +<mk-ui> + <div class="user" v-if="!fetching"> + <x-header :user="user"/> + <x-home v-if="page == 'home'" :user="user"/> + </div> +</mk-ui> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import parseAcct from '../../../../../../common/user/parse-acct'; +import Progress from '../../../../common/scripts/loading'; +import XHeader from './user.header.vue'; +import XHome from './user.home.vue'; + +export default Vue.extend({ + components: { + XHeader, + XHome + }, + props: { + page: { + default: 'home' + } + }, + data() { + return { + fetching: true, + user: null + }; + }, + watch: { + $route: 'fetch' + }, + created() { + this.fetch(); + }, + methods: { + fetch() { + this.fetching = true; + Progress.start(); + (this as any).api('users/show', parseAcct(this.$route.params.user)).then(user => { + this.user = user; + this.fetching = false; + Progress.done(); + document.title = user.name + ' | Misskey'; + }); + } + } +}); +</script> + diff --git a/src/server/web/app/desktop/views/pages/welcome.vue b/src/server/web/app/desktop/views/pages/welcome.vue new file mode 100644 index 0000000000..927ddf575b --- /dev/null +++ b/src/server/web/app/desktop/views/pages/welcome.vue @@ -0,0 +1,319 @@ +<template> +<div class="mk-welcome"> + <main> + <div class="top"> + <div> + <div> + <h1>Share<br><span ref="share">Everything!</span><span class="cursor">_</span></h1> + <p>ようこそ! <b>Misskey</b>はTwitter風ミニブログSNSです。思ったことや皆と共有したいことを投稿しましょう。タイムラインを見れば、皆の関心事をすぐにチェックすることもできます。<a :href="aboutUrl">詳しく...</a></p> + <p><button class="signup" @click="signup">はじめる</button><button class="signin" @click="signin">ログイン</button></p> + <div class="users"> + <router-link v-for="user in users" :key="user.id" class="avatar-anchor" :to="`/@${getAcct(user)}`" v-user-preview="user.id"> + <img class="avatar" :src="`${user.avatar_url}?thumbnail&size=64`" alt="avatar"/> + </router-link> + </div> + </div> + <div> + <div> + <header>%fa:comments R% タイムライン<div><span></span><span></span><span></span></div></header> + <mk-welcome-timeline/> + </div> + </div> + </div> + </div> + </main> + <mk-forkit/> + <footer> + <div> + <mk-nav :class="$style.nav"/> + <p class="c">{{ copyright }}</p> + </div> + </footer> + <modal name="signup" width="500px" height="auto" scrollable> + <header :class="$style.signupFormHeader">新規登録</header> + <mk-signup :class="$style.signupForm"/> + </modal> + <modal name="signin" width="500px" height="auto" scrollable> + <header :class="$style.signinFormHeader">ログイン</header> + <mk-signin :class="$style.signinForm"/> + </modal> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { docsUrl, copyright, lang } from '../../../config'; +import getAcct from '../../../../../common/user/get-acct'; + +const shares = [ + 'Everything!', + 'Webpages', + 'Photos', + 'Interests', + 'Favorites' +]; + +export default Vue.extend({ + data() { + return { + aboutUrl: `${docsUrl}/${lang}/about`, + copyright, + users: [], + clock: null, + i: 0 + }; + }, + mounted() { + (this as any).api('users', { + sort: '+follower', + limit: 20 + }).then(users => { + this.users = users; + }); + + this.clock = setInterval(() => { + if (++this.i == shares.length) this.i = 0; + const speed = 70; + const text = (this.$refs.share as any).innerText; + for (let i = 0; i < text.length; i++) { + setTimeout(() => { + if (this.$refs.share) { + (this.$refs.share as any).innerText = text.substr(0, text.length - i); + } + }, i * speed) + } + setTimeout(() => { + const newText = shares[this.i]; + for (let i = 0; i <= newText.length; i++) { + setTimeout(() => { + if (this.$refs.share) { + (this.$refs.share as any).innerText = newText.substr(0, i); + } + }, i * speed) + } + }, text.length * speed); + }, 4000); + }, + beforeDestroy() { + clearInterval(this.clock); + }, + methods: { + getAcct, + signup() { + this.$modal.show('signup'); + }, + signin() { + this.$modal.show('signin'); + } + } +}); +</script> + +<style> +#wait { + right: auto; + left: 15px; +} +</style> + +<style lang="stylus" scoped> +@import '~const.styl' + +@import url('https://fonts.googleapis.com/css?family=Sarpanch:700') + +.mk-welcome + display flex + flex-direction column + flex 1 + $width = 1000px + + background-image url('/assets/welcome-bg.svg') + background-size cover + background-position top center + + &:before + content "" + display block + position fixed + bottom 0 + left 0 + width 100% + height 100% + background-image url('/assets/welcome-fg.svg') + background-size cover + background-position bottom center + + > main + display flex + flex 1 + + > .top + display flex + width 100% + + > div + display flex + max-width $width + 64px + margin 0 auto + padding 80px 32px 0 32px + + > * + margin-bottom 48px + + > div:first-child + margin-right 48px + color #fff + text-shadow 0 0 12px #172062 + + > h1 + margin 0 + font-weight bold + //font-variant small-caps + letter-spacing 12px + font-family 'Sarpanch', sans-serif + font-size 42px + line-height 48px + + > .cursor + animation cursor 1s infinite linear both + + @keyframes cursor + 0% + opacity 1 + 50% + opacity 0 + + > p + margin 1em 0 + line-height 2em + + button + padding 8px 16px + font-size inherit + + .signup + color $theme-color + border solid 2px $theme-color + border-radius 4px + + &:focus + box-shadow 0 0 0 3px rgba($theme-color, 0.2) + + &:hover + color $theme-color-foreground + background $theme-color + + &:active + color $theme-color-foreground + background darken($theme-color, 10%) + border-color darken($theme-color, 10%) + + .signin + &:hover + color #fff + + > .users + margin 16px 0 0 0 + + > * + display inline-block + margin 4px + + > * + display inline-block + width 38px + height 38px + vertical-align top + border-radius 6px + + > div:last-child + + > div + width 410px + background #fff + border-radius 8px + box-shadow 0 0 0 12px rgba(0, 0, 0, 0.1) + overflow hidden + + > header + z-index 1 + padding 12px 16px + color #888d94 + box-shadow 0 1px 0px rgba(0, 0, 0, 0.1) + + > div + position absolute + top 0 + right 0 + padding inherit + + > span + display inline-block + height 11px + width 11px + margin-left 6px + background #ccc + border-radius 100% + vertical-align middle + + &:nth-child(1) + background #5BCC8B + + &:nth-child(2) + background #E6BB46 + + &:nth-child(3) + background #DF7065 + + > .mk-welcome-timeline + max-height 350px + overflow auto + + > footer + font-size 12px + color #949ea5 + + > div + max-width $width + margin 0 auto + padding 0 0 42px 0 + text-align center + + > .c + margin 16px 0 0 0 + font-size 10px + opacity 0.7 + +</style> + +<style lang="stylus" module> +.signupForm + padding 24px 48px 48px 48px + +.signupFormHeader + padding 48px 0 12px 0 + margin: 0 48px + font-size 1.5em + color #777 + border-bottom solid 1px #eee + +.signinForm + padding 24px 48px 48px 48px + +.signinFormHeader + padding 48px 0 12px 0 + margin: 0 48px + font-size 1.5em + color #777 + border-bottom solid 1px #eee + +.nav + a + color #666 +</style> + +<style lang="stylus"> +html +body + background linear-gradient(to bottom, #1e1d65, #bd6659) +</style> diff --git a/src/server/web/app/desktop/views/widgets/activity.vue b/src/server/web/app/desktop/views/widgets/activity.vue new file mode 100644 index 0000000000..0bdf4622af --- /dev/null +++ b/src/server/web/app/desktop/views/widgets/activity.vue @@ -0,0 +1,31 @@ +<template> +<mk-activity + :design="props.design" + :init-view="props.view" + :user="os.i" + @view-changed="viewChanged"/> +</template> + +<script lang="ts"> +import define from '../../../common/define-widget'; +export default define({ + name: 'activity', + props: () => ({ + design: 0, + view: 0 + }) +}).extend({ + methods: { + func() { + if (this.props.design == 2) { + this.props.design = 0; + } else { + this.props.design++; + } + }, + viewChanged(view) { + this.props.view = view; + } + } +}); +</script> diff --git a/src/server/web/app/desktop/views/widgets/channel.channel.form.vue b/src/server/web/app/desktop/views/widgets/channel.channel.form.vue new file mode 100644 index 0000000000..392ba5924b --- /dev/null +++ b/src/server/web/app/desktop/views/widgets/channel.channel.form.vue @@ -0,0 +1,67 @@ +<template> +<div class="form"> + <input v-model="text" :disabled="wait" @keydown="onKeydown" placeholder="書いて"> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + data() { + return { + text: '', + wait: false + }; + }, + methods: { + onKeydown(e) { + if (e.which == 10 || e.which == 13) this.post(); + }, + post() { + this.wait = true; + + let reply = null; + + if (/^>>([0-9]+) /.test(this.text)) { + const index = this.text.match(/^>>([0-9]+) /)[1]; + reply = (this.$parent as any).posts.find(p => p.index.toString() == index); + this.text = this.text.replace(/^>>([0-9]+) /, ''); + } + + (this as any).api('posts/create', { + text: this.text, + reply_id: reply ? reply.id : undefined, + channel_id: (this.$parent as any).channel.id + }).then(data => { + this.text = ''; + }).catch(err => { + alert('失敗した'); + }).then(() => { + this.wait = false; + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +.form + 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> diff --git a/src/server/web/app/desktop/views/widgets/channel.channel.post.vue b/src/server/web/app/desktop/views/widgets/channel.channel.post.vue new file mode 100644 index 0000000000..433f9a00aa --- /dev/null +++ b/src/server/web/app/desktop/views/widgets/channel.channel.post.vue @@ -0,0 +1,71 @@ +<template> +<div class="post"> + <header> + <a class="index" @click="reply">{{ post.index }}:</a> + <router-link class="name" :to="`/@${acct}`" v-user-preview="post.user.id"><b>{{ post.user.name }}</b></router-link> + <span>ID:<i>{{ acct }}</i></span> + </header> + <div> + <a v-if="post.reply">>>{{ post.reply.index }}</a> + {{ post.text }} + <div class="media" v-if="post.media"> + <a v-for="file in post.media" :href="file.url" target="_blank"> + <img :src="`${file.url}?thumbnail&size=512`" :alt="file.name" :title="file.name"/> + </a> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import getAcct from '../../../../../common/user/get-acct'; + +export default Vue.extend({ + props: ['post'], + computed: { + acct() { + return getAcct(this.post.user); + } + }, + methods: { + reply() { + this.$emit('reply', this.post); + } + } +}); +</script> + +<style lang="stylus" scoped> +.post + 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> diff --git a/src/server/web/app/desktop/views/widgets/channel.channel.vue b/src/server/web/app/desktop/views/widgets/channel.channel.vue new file mode 100644 index 0000000000..de5885bfc1 --- /dev/null +++ b/src/server/web/app/desktop/views/widgets/channel.channel.vue @@ -0,0 +1,106 @@ +<template> +<div class="channel"> + <p v-if="fetching">読み込み中<mk-ellipsis/></p> + <div v-if="!fetching" ref="posts" class="posts"> + <p v-if="posts.length == 0">まだ投稿がありません</p> + <x-post class="post" v-for="post in posts.slice().reverse()" :post="post" :key="post.id" @reply="reply"/> + </div> + <x-form class="form" ref="form"/> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import ChannelStream from '../../../common/scripts/streaming/channel'; +import XForm from './channel.channel.form.vue'; +import XPost from './channel.channel.post.vue'; + +export default Vue.extend({ + components: { + XForm, + XPost + }, + props: ['channel'], + data() { + return { + fetching: true, + posts: [], + connection: null + }; + }, + watch: { + channel() { + this.zap(); + } + }, + mounted() { + this.zap(); + }, + beforeDestroy() { + this.disconnect(); + }, + methods: { + zap() { + this.fetching = true; + + (this as any).api('channels/posts', { + channel_id: this.channel.id + }).then(posts => { + this.posts = posts; + this.fetching = false; + + this.$nextTick(() => { + this.scrollToBottom(); + }); + + this.disconnect(); + this.connection = new ChannelStream((this as any).os, this.channel.id); + this.connection.on('post', this.onPost); + }); + }, + disconnect() { + if (this.connection) { + this.connection.off('post', this.onPost); + this.connection.close(); + } + }, + onPost(post) { + this.posts.unshift(post); + this.scrollToBottom(); + }, + scrollToBottom() { + (this.$refs.posts as any).scrollTop = (this.$refs.posts as any).scrollHeight; + }, + reply(post) { + (this.$refs.form as any).text = `>>${ post.index } `; + } + } +}); +</script> + +<style lang="stylus" scoped> +.channel + + > p + margin 0 + padding 16px + text-align center + color #aaa + + > .posts + height calc(100% - 38px) + overflow auto + font-size 0.9em + + > .post + border-bottom solid 1px #eee + + &:last-child + border-bottom none + + > .form + position absolute + left 0 + bottom 0 + +</style> diff --git a/src/server/web/app/desktop/views/widgets/channel.vue b/src/server/web/app/desktop/views/widgets/channel.vue new file mode 100644 index 0000000000..fc143bb1df --- /dev/null +++ b/src/server/web/app/desktop/views/widgets/channel.vue @@ -0,0 +1,107 @@ +<template> +<div class="mkw-channel"> + <template v-if="!props.compact"> + <p class="title">%fa:tv%{{ channel ? channel.title : '%i18n:desktop.tags.mk-channel-home-widget.title%' }}</p> + <button @click="settings" title="%i18n:desktop.tags.mk-channel-home-widget.settings%">%fa:cog%</button> + </template> + <p class="get-started" v-if="props.channel == null">%i18n:desktop.tags.mk-channel-home-widget.get-started%</p> + <x-channel class="channel" :channel="channel" v-if="channel != null"/> +</div> +</template> + +<script lang="ts"> +import define from '../../../common/define-widget'; +import XChannel from './channel.channel.vue'; + +export default define({ + name: 'server', + props: () => ({ + channel: null, + compact: false + }) +}).extend({ + components: { + XChannel + }, + data() { + return { + fetching: true, + channel: null + }; + }, + mounted() { + if (this.props.channel) { + this.zap(); + } + }, + methods: { + func() { + this.props.compact = !this.props.compact; + }, + settings() { + const id = window.prompt('チャンネルID'); + if (!id) return; + this.props.channel = id; + this.zap(); + }, + zap() { + this.fetching = true; + + (this as any).api('channels/show', { + channel_id: this.props.channel + }).then(channel => { + this.channel = channel; + this.fetching = false; + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +.mkw-channel + 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) + + > [data-fa] + margin-right 4px + + > button + position absolute + z-index 2 + top 0 + right 0 + padding 0 + width 42px + font-size 0.9em + line-height 42px + color #ccc + + &:hover + color #aaa + + &:active + color #999 + + > .get-started + margin 0 + padding 16px + text-align center + color #aaa + + > .channel + height 200px + +</style> diff --git a/src/server/web/app/desktop/views/widgets/index.ts b/src/server/web/app/desktop/views/widgets/index.ts new file mode 100644 index 0000000000..77d771d6b3 --- /dev/null +++ b/src/server/web/app/desktop/views/widgets/index.ts @@ -0,0 +1,23 @@ +import Vue from 'vue'; + +import wNotifications from './notifications.vue'; +import wTimemachine from './timemachine.vue'; +import wActivity from './activity.vue'; +import wTrends from './trends.vue'; +import wUsers from './users.vue'; +import wPolls from './polls.vue'; +import wPostForm from './post-form.vue'; +import wMessaging from './messaging.vue'; +import wChannel from './channel.vue'; +import wProfile from './profile.vue'; + +Vue.component('mkw-notifications', wNotifications); +Vue.component('mkw-timemachine', wTimemachine); +Vue.component('mkw-activity', wActivity); +Vue.component('mkw-trends', wTrends); +Vue.component('mkw-users', wUsers); +Vue.component('mkw-polls', wPolls); +Vue.component('mkw-post-form', wPostForm); +Vue.component('mkw-messaging', wMessaging); +Vue.component('mkw-channel', wChannel); +Vue.component('mkw-profile', wProfile); diff --git a/src/server/web/app/desktop/views/widgets/messaging.vue b/src/server/web/app/desktop/views/widgets/messaging.vue new file mode 100644 index 0000000000..2c9f473bd1 --- /dev/null +++ b/src/server/web/app/desktop/views/widgets/messaging.vue @@ -0,0 +1,59 @@ +<template> +<div class="mkw-messaging"> + <p class="title" v-if="props.design == 0">%fa:comments%%i18n:desktop.tags.mk-messaging-home-widget.title%</p> + <mk-messaging ref="index" compact @navigate="navigate"/> +</div> +</template> + +<script lang="ts"> +import define from '../../../common/define-widget'; +import MkMessagingRoomWindow from '../components/messaging-room-window.vue'; + +export default define({ + name: 'messaging', + props: () => ({ + design: 0 + }) +}).extend({ + methods: { + navigate(user) { + (this as any).os.new(MkMessagingRoomWindow, { + user: user + }); + }, + func() { + if (this.props.design == 1) { + this.props.design = 0; + } else { + this.props.design++; + } + } + } +}); +</script> + +<style lang="stylus" scoped> +.mkw-messaging + overflow hidden + background #fff + border solid 1px rgba(0, 0, 0, 0.075) + border-radius 6px + + > .title + z-index 2 + margin 0 + padding 0 16px + line-height 42px + font-size 0.9em + font-weight bold + color #888 + box-shadow 0 1px rgba(0, 0, 0, 0.07) + + > [data-fa] + margin-right 4px + + > .mk-messaging + max-height 250px + overflow auto + +</style> diff --git a/src/server/web/app/desktop/views/widgets/notifications.vue b/src/server/web/app/desktop/views/widgets/notifications.vue new file mode 100644 index 0000000000..1a2b3d3f89 --- /dev/null +++ b/src/server/web/app/desktop/views/widgets/notifications.vue @@ -0,0 +1,70 @@ +<template> +<div class="mkw-notifications"> + <template v-if="!props.compact"> + <p class="title">%fa:R bell%%i18n:desktop.tags.mk-notifications-home-widget.title%</p> + <button @click="settings" title="%i18n:desktop.tags.mk-notifications-home-widget.settings%">%fa:cog%</button> + </template> + <mk-notifications/> +</div> +</template> + +<script lang="ts"> +import define from '../../../common/define-widget'; +export default define({ + name: 'notifications', + props: () => ({ + compact: false + }) +}).extend({ + methods: { + settings() { + alert('not implemented yet'); + }, + func() { + this.props.compact = !this.props.compact; + } + } +}); +</script> + +<style lang="stylus" scoped> +.mkw-notifications + background #fff + border solid 1px rgba(0, 0, 0, 0.075) + border-radius 6px + + > .title + z-index 1 + margin 0 + padding 0 16px + line-height 42px + font-size 0.9em + font-weight bold + color #888 + box-shadow 0 1px rgba(0, 0, 0, 0.07) + + > [data-fa] + margin-right 4px + + > button + position absolute + z-index 2 + top 0 + right 0 + padding 0 + width 42px + font-size 0.9em + line-height 42px + color #ccc + + &:hover + color #aaa + + &:active + color #999 + + > .mk-notifications + max-height 300px + overflow auto + +</style> diff --git a/src/server/web/app/desktop/views/widgets/polls.vue b/src/server/web/app/desktop/views/widgets/polls.vue new file mode 100644 index 0000000000..e5db34fc7a --- /dev/null +++ b/src/server/web/app/desktop/views/widgets/polls.vue @@ -0,0 +1,129 @@ +<template> +<div class="mkw-polls"> + <template v-if="!props.compact"> + <p class="title">%fa:chart-pie%%i18n:desktop.tags.mk-recommended-polls-home-widget.title%</p> + <button @click="fetch" title="%i18n:desktop.tags.mk-recommended-polls-home-widget.refresh%">%fa:sync%</button> + </template> + <div class="poll" v-if="!fetching && poll != null"> + <p v-if="poll.text"><router-link to="`/@${ acct }/${ poll.id }`">{{ poll.text }}</router-link></p> + <p v-if="!poll.text"><router-link to="`/@${ acct }/${ poll.id }`">%fa:link%</router-link></p> + <mk-poll :post="poll"/> + </div> + <p class="empty" v-if="!fetching && poll == null">%i18n:desktop.tags.mk-recommended-polls-home-widget.nothing%</p> + <p class="fetching" v-if="fetching">%fa:spinner .pulse .fw%%i18n:common.loading%<mk-ellipsis/></p> +</div> +</template> + +<script lang="ts"> +import define from '../../../common/define-widget'; +import getAcct from '../../../../../common/user/get-acct'; + +export default define({ + name: 'polls', + props: () => ({ + compact: false + }) +}).extend({ + computed: { + acct() { + return getAcct(this.poll.user); + }, + }, + data() { + return { + poll: null, + fetching: true, + offset: 0 + }; + }, + mounted() { + this.fetch(); + }, + methods: { + func() { + this.props.compact = !this.props.compact; + }, + fetch() { + this.fetching = true; + this.poll = null; + + (this as any).api('posts/polls/recommendation', { + limit: 1, + offset: this.offset + }).then(posts => { + const poll = posts ? posts[0] : null; + if (poll == null) { + this.offset = 0; + } else { + this.offset++; + } + this.poll = poll; + this.fetching = false; + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +.mkw-polls + background #fff + border solid 1px rgba(0, 0, 0, 0.075) + border-radius 6px + + > .title + margin 0 + padding 0 16px + line-height 42px + font-size 0.9em + font-weight bold + color #888 + border-bottom solid 1px #eee + + > [data-fa] + margin-right 4px + + > button + position absolute + z-index 2 + top 0 + right 0 + padding 0 + width 42px + font-size 0.9em + line-height 42px + color #ccc + + &:hover + color #aaa + + &:active + color #999 + + > .poll + padding 16px + font-size 12px + color #555 + + > p + margin 0 0 8px 0 + + > a + color inherit + + > .empty + margin 0 + padding 16px + text-align center + color #aaa + + > .fetching + margin 0 + padding 16px + text-align center + color #aaa + + > [data-fa] + margin-right 4px + +</style> diff --git a/src/server/web/app/desktop/views/widgets/post-form.vue b/src/server/web/app/desktop/views/widgets/post-form.vue new file mode 100644 index 0000000000..cf7fd1f2b2 --- /dev/null +++ b/src/server/web/app/desktop/views/widgets/post-form.vue @@ -0,0 +1,111 @@ +<template> +<div class="mkw-post-form"> + <template v-if="props.design == 0"> + <p class="title">%fa:pencil-alt%%i18n:desktop.tags.mk-post-form-home-widget.title%</p> + </template> + <textarea :disabled="posting" v-model="text" @keydown="onKeydown" placeholder="%i18n:desktop.tags.mk-post-form-home-widget.placeholder%"></textarea> + <button @click="post" :disabled="posting">%i18n:desktop.tags.mk-post-form-home-widget.post%</button> +</div> +</template> + +<script lang="ts"> +import define from '../../../common/define-widget'; +export default define({ + name: 'post-form', + props: () => ({ + design: 0 + }) +}).extend({ + data() { + return { + posting: false, + text: '' + }; + }, + methods: { + func() { + if (this.props.design == 1) { + this.props.design = 0; + } else { + this.props.design++; + } + }, + onKeydown(e) { + if ((e.which == 10 || e.which == 13) && (e.ctrlKey || e.metaKey)) this.post(); + }, + post() { + this.posting = true; + + (this as any).api('posts/create', { + text: this.text + }).then(data => { + this.clear(); + }).catch(err => { + alert('失敗した'); + }).then(() => { + this.posting = false; + }); + }, + clear() { + this.text = ''; + } + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +.mkw-post-form + 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) + + > [data-fa] + 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> diff --git a/src/server/web/app/desktop/views/widgets/profile.vue b/src/server/web/app/desktop/views/widgets/profile.vue new file mode 100644 index 0000000000..3940106197 --- /dev/null +++ b/src/server/web/app/desktop/views/widgets/profile.vue @@ -0,0 +1,125 @@ +<template> +<div class="mkw-profile" + :data-compact="props.design == 1 || props.design == 2" + :data-melt="props.design == 2" +> + <div class="banner" + :style="os.i.banner_url ? `background-image: url(${os.i.banner_url}?thumbnail&size=256)` : ''" + title="クリックでバナー編集" + @click="os.apis.updateBanner" + ></div> + <img class="avatar" + :src="`${os.i.avatar_url}?thumbnail&size=96`" + @click="os.apis.updateAvatar" + alt="avatar" + title="クリックでアバター編集" + v-user-preview="os.i.id" + /> + <router-link class="name" :to="`/@${os.i.username}`">{{ os.i.name }}</router-link> + <p class="username">@{{ os.i.username }}</p> +</div> +</template> + +<script lang="ts"> +import define from '../../../common/define-widget'; +export default define({ + name: 'profile', + props: () => ({ + design: 0 + }) +}).extend({ + methods: { + func() { + if (this.props.design == 2) { + this.props.design = 0; + } else { + this.props.design++; + } + } + } +}); +</script> + +<style lang="stylus" scoped> +.mkw-profile + 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 + background-color #f5f5f5 + background-size cover + background-position center + cursor pointer + + > .avatar + display block + position absolute + top 76px + left 16px + width 58px + height 58px + margin 0 + border solid 3px #fff + border-radius 8px + vertical-align bottom + cursor pointer + + > .name + display block + margin 10px 0 0 84px + line-height 16px + font-weight bold + color #555 + + > .username + display block + margin 4px 0 8px 84px + line-height 16px + font-size 0.9em + color #999 + +</style> diff --git a/src/server/web/app/desktop/views/widgets/timemachine.vue b/src/server/web/app/desktop/views/widgets/timemachine.vue new file mode 100644 index 0000000000..6db3b14c62 --- /dev/null +++ b/src/server/web/app/desktop/views/widgets/timemachine.vue @@ -0,0 +1,28 @@ +<template> +<div class="mkw-timemachine"> + <mk-calendar :design="props.design" @chosen="chosen"/> +</div> +</template> + +<script lang="ts"> +import define from '../../../common/define-widget'; +export default define({ + name: 'timemachine', + props: () => ({ + design: 0 + }) +}).extend({ + methods: { + chosen(date) { + this.$emit('chosen', date); + }, + func() { + if (this.props.design == 5) { + this.props.design = 0; + } else { + this.props.design++; + } + } + } +}); +</script> diff --git a/src/server/web/app/desktop/views/widgets/trends.vue b/src/server/web/app/desktop/views/widgets/trends.vue new file mode 100644 index 0000000000..77779787ee --- /dev/null +++ b/src/server/web/app/desktop/views/widgets/trends.vue @@ -0,0 +1,135 @@ +<template> +<div class="mkw-trends"> + <template v-if="!props.compact"> + <p class="title">%fa:fire%%i18n:desktop.tags.mk-trends-home-widget.title%</p> + <button @click="fetch" title="%i18n:desktop.tags.mk-trends-home-widget.refresh%">%fa:sync%</button> + </template> + <p class="fetching" v-if="fetching">%fa:spinner .pulse .fw%%i18n:common.loading%<mk-ellipsis/></p> + <div class="post" v-else-if="post != null"> + <p class="text"><router-link :to="`/@${ acct }/${ post.id }`">{{ post.text }}</router-link></p> + <p class="author">―<router-link :to="`/@${ acct }`">@{{ acct }}</router-link></p> + </div> + <p class="empty" v-else>%i18n:desktop.tags.mk-trends-home-widget.nothing%</p> +</div> +</template> + +<script lang="ts"> +import define from '../../../common/define-widget'; +import getAcct from '../../../../../common/user/get-acct'; + +export default define({ + name: 'trends', + props: () => ({ + compact: false + }) +}).extend({ + computed: { + acct() { + return getAcct(this.post.user); + }, + }, + data() { + return { + post: null, + fetching: true, + offset: 0 + }; + }, + mounted() { + this.fetch(); + }, + methods: { + func() { + this.props.compact = !this.props.compact; + }, + fetch() { + this.fetching = true; + this.post = null; + + (this as any).api('posts/trend', { + limit: 1, + offset: this.offset, + repost: false, + reply: false, + media: false, + poll: false + }).then(posts => { + const post = posts ? posts[0] : null; + if (post == null) { + this.offset = 0; + } else { + this.offset++; + } + this.post = post; + this.fetching = false; + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +.mkw-trends + background #fff + border solid 1px rgba(0, 0, 0, 0.075) + border-radius 6px + + > .title + margin 0 + padding 0 16px + line-height 42px + font-size 0.9em + font-weight bold + color #888 + border-bottom solid 1px #eee + + > [data-fa] + margin-right 4px + + > button + position absolute + z-index 2 + top 0 + right 0 + padding 0 + width 42px + font-size 0.9em + line-height 42px + color #ccc + + &:hover + color #aaa + + &:active + color #999 + + > .post + padding 16px + font-size 12px + font-style oblique + color #555 + + > p + margin 0 + + > .text, + > .author + > a + color inherit + + > .empty + margin 0 + padding 16px + text-align center + color #aaa + + > .fetching + margin 0 + padding 16px + text-align center + color #aaa + + > [data-fa] + margin-right 4px + +</style> diff --git a/src/server/web/app/desktop/views/widgets/users.vue b/src/server/web/app/desktop/views/widgets/users.vue new file mode 100644 index 0000000000..10e3c529ee --- /dev/null +++ b/src/server/web/app/desktop/views/widgets/users.vue @@ -0,0 +1,172 @@ +<template> +<div class="mkw-users"> + <template v-if="!props.compact"> + <p class="title">%fa:users%%i18n:desktop.tags.mk-user-recommendation-home-widget.title%</p> + <button @click="refresh" title="%i18n:desktop.tags.mk-user-recommendation-home-widget.refresh%">%fa:sync%</button> + </template> + <p class="fetching" v-if="fetching">%fa:spinner .pulse .fw%%i18n:common.loading%<mk-ellipsis/></p> + <template v-else-if="users.length != 0"> + <div class="user" v-for="_user in users"> + <router-link class="avatar-anchor" :to="`/@${getAcct(_user)}`"> + <img class="avatar" :src="`${_user.avatar_url}?thumbnail&size=42`" alt="" v-user-preview="_user.id"/> + </router-link> + <div class="body"> + <router-link class="name" :to="`/@${getAcct(_user)}`" v-user-preview="_user.id">{{ _user.name }}</router-link> + <p class="username">@{{ getAcct(_user) }}</p> + </div> + <mk-follow-button :user="_user"/> + </div> + </template> + <p class="empty" v-else>%i18n:desktop.tags.mk-user-recommendation-home-widget.no-one%</p> +</div> +</template> + +<script lang="ts"> +import define from '../../../common/define-widget'; +import getAcct from '../../../../../common/user/get-acct'; + +const limit = 3; + +export default define({ + name: 'users', + props: () => ({ + compact: false + }) +}).extend({ + data() { + return { + users: [], + fetching: true, + page: 0 + }; + }, + mounted() { + this.fetch(); + }, + methods: { + getAcct, + func() { + this.props.compact = !this.props.compact; + }, + fetch() { + this.fetching = true; + this.users = []; + + (this as any).api('users/recommendation', { + limit: limit, + offset: limit * this.page + }).then(users => { + this.users = users; + this.fetching = false; + }); + }, + refresh() { + if (this.users.length < limit) { + this.page = 0; + } else { + this.page++; + } + this.fetch(); + } + } +}); +</script> + +<style lang="stylus" scoped> +.mkw-users + background #fff + border solid 1px rgba(0, 0, 0, 0.075) + border-radius 6px + + > .title + margin 0 + padding 0 16px + line-height 42px + font-size 0.9em + font-weight bold + color #888 + border-bottom solid 1px #eee + + > [data-fa] + margin-right 4px + + > button + position absolute + z-index 2 + top 0 + right 0 + padding 0 + width 42px + font-size 0.9em + line-height 42px + color #ccc + + &:hover + color #aaa + + &:active + color #999 + + > .user + padding 16px + border-bottom solid 1px #eee + + &: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 + + > .empty + margin 0 + padding 16px + text-align center + color #aaa + + > .fetching + margin 0 + padding 16px + text-align center + color #aaa + + > [data-fa] + margin-right 4px + +</style> diff --git a/src/server/web/app/dev/script.ts b/src/server/web/app/dev/script.ts new file mode 100644 index 0000000000..c043813b40 --- /dev/null +++ b/src/server/web/app/dev/script.ts @@ -0,0 +1,44 @@ +/** + * Developer Center + */ + +import Vue from 'vue'; +import VueRouter from 'vue-router'; +import BootstrapVue from 'bootstrap-vue'; +import 'bootstrap/dist/css/bootstrap.css'; +import 'bootstrap-vue/dist/bootstrap-vue.css'; + +// Style +import './style.styl'; + +import init from '../init'; + +import Index from './views/index.vue'; +import Apps from './views/apps.vue'; +import AppNew from './views/new-app.vue'; +import App from './views/app.vue'; +import ui from './views/ui.vue'; + +Vue.use(BootstrapVue); + +Vue.component('mk-ui', ui); + +/** + * init + */ +init(launch => { + // Init router + const router = new VueRouter({ + mode: 'history', + base: '/dev/', + routes: [ + { path: '/', component: Index }, + { path: '/apps', component: Apps }, + { path: '/app/new', component: AppNew }, + { path: '/app/:id', component: App }, + ] + }); + + // Launch the app + launch(router); +}); diff --git a/src/server/web/app/dev/style.styl b/src/server/web/app/dev/style.styl new file mode 100644 index 0000000000..e635897b17 --- /dev/null +++ b/src/server/web/app/dev/style.styl @@ -0,0 +1,10 @@ +@import "../app" +@import "../reset" + +// Bootstrapのデザインを崩すので: +* + position initial + background-clip initial !important + +html + background-color #fff diff --git a/src/server/web/app/dev/views/app.vue b/src/server/web/app/dev/views/app.vue new file mode 100644 index 0000000000..2c2a3c83ce --- /dev/null +++ b/src/server/web/app/dev/views/app.vue @@ -0,0 +1,39 @@ +<template> +<mk-ui> + <p v-if="fetching">読み込み中</p> + <b-card v-if="!fetching" :header="app.name"> + <b-form-group label="App Secret"> + <b-input :value="app.secret" readonly/> + </b-form-group> + </b-card> +</mk-ui> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + data() { + return { + fetching: true, + app: null + }; + }, + watch: { + $route: 'fetch' + }, + mounted() { + this.fetch(); + }, + methods: { + fetch() { + this.fetching = true; + (this as any).api('app/show', { + app_id: this.$route.params.id + }).then(app => { + this.app = app; + this.fetching = false; + }); + } + } +}); +</script> diff --git a/src/server/web/app/dev/views/apps.vue b/src/server/web/app/dev/views/apps.vue new file mode 100644 index 0000000000..7e0b107a30 --- /dev/null +++ b/src/server/web/app/dev/views/apps.vue @@ -0,0 +1,37 @@ +<template> +<mk-ui> + <b-card header="アプリを管理"> + <b-button to="/app/new" variant="primary">アプリ作成</b-button> + <hr> + <div class="apps"> + <p v-if="fetching">読み込み中</p> + <template v-if="!fetching"> + <b-alert v-if="apps.length == 0">アプリなし</b-alert> + <b-list-group v-else> + <b-list-group-item v-for="app in apps" :key="app.id" :to="`/app/${app.id}`"> + {{ app.name }} + </b-list-group-item> + </b-list-group> + </template> + </div> + </b-card> +</mk-ui> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + data() { + return { + fetching: true, + apps: [] + }; + }, + mounted() { + (this as any).api('my/apps').then(apps => { + this.apps = apps; + this.fetching = false; + }); + } +}); +</script> diff --git a/src/server/web/app/dev/views/index.vue b/src/server/web/app/dev/views/index.vue new file mode 100644 index 0000000000..3f572b3907 --- /dev/null +++ b/src/server/web/app/dev/views/index.vue @@ -0,0 +1,10 @@ +<template> +<mk-ui> + <b-button to="/apps" variant="primary">アプリの管理</b-button> +</mk-ui> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend(); +</script> diff --git a/src/server/web/app/dev/views/new-app.vue b/src/server/web/app/dev/views/new-app.vue new file mode 100644 index 0000000000..344e8468f9 --- /dev/null +++ b/src/server/web/app/dev/views/new-app.vue @@ -0,0 +1,105 @@ +<template> +<mk-ui> + <b-card header="アプリケーションの作成"> + <b-form @submit.prevent="onSubmit" autocomplete="off"> + <b-form-group label="アプリケーション名" description="あなたのアプリの名称。"> + <b-form-input v-model="name" type="text" placeholder="ex) Misskey for iOS" autocomplete="off" required/> + </b-form-group> + <b-form-group label="ID" description="あなたのアプリのID。"> + <b-input v-model="nid" type="text" pattern="^[a-zA-Z0-9-]{3,30}$" placeholder="ex) misskey-for-ios" autocomplete="off" required/> + <p class="info" v-if="nidState == 'wait'" style="color:#999">%fa:spinner .pulse .fw%確認しています...</p> + <p class="info" v-if="nidState == 'ok'" style="color:#3CB7B5">%fa:fw check%利用できます</p> + <p class="info" v-if="nidState == 'unavailable'" style="color:#FF1161">%fa:fw exclamation-triangle%既に利用されています</p> + <p class="info" v-if="nidState == 'error'" style="color:#FF1161">%fa:fw exclamation-triangle%通信エラー</p> + <p class="info" v-if="nidState == 'invalid-format'" style="color:#FF1161">%fa:fw exclamation-triangle%a~z、A~Z、0~9、-(ハイフン)が使えます</p> + <p class="info" v-if="nidState == 'min-range'" style="color:#FF1161">%fa:fw exclamation-triangle%3文字以上でお願いします!</p> + <p class="info" v-if="nidState == 'max-range'" style="color:#FF1161">%fa:fw exclamation-triangle%30文字以内でお願いします</p> + </b-form-group> + <b-form-group label="アプリの概要" description="あなたのアプリの簡単な説明や紹介。"> + <b-textarea v-model="description" placeholder="ex) Misskey iOSクライアント。" autocomplete="off" required></b-textarea> + </b-form-group> + <b-form-group label="コールバックURL (オプション)" description="ユーザーが認証フォームで認証した際にリダイレクトするURLを設定できます。"> + <b-input v-model="cb" type="url" placeholder="ex) https://your.app.example.com/callback.php" autocomplete="off"/> + </b-form-group> + <b-card header="権限"> + <b-form-group description="ここで要求した機能だけがAPIからアクセスできます。"> + <b-alert show variant="warning">%fa:exclamation-triangle%アプリ作成後も変更できますが、新たな権限を付与する場合、その時点で関連付けられているユーザーキーはすべて無効になります。</b-alert> + <b-form-checkbox-group v-model="permission" stacked> + <b-form-checkbox value="account-read">アカウントの情報を見る。</b-form-checkbox> + <b-form-checkbox value="account-write">アカウントの情報を操作する。</b-form-checkbox> + <b-form-checkbox value="post-write">投稿する。</b-form-checkbox> + <b-form-checkbox value="reaction-write">リアクションしたりリアクションをキャンセルする。</b-form-checkbox> + <b-form-checkbox value="following-write">フォローしたりフォロー解除する。</b-form-checkbox> + <b-form-checkbox value="drive-read">ドライブを見る。</b-form-checkbox> + <b-form-checkbox value="drive-write">ドライブを操作する。</b-form-checkbox> + <b-form-checkbox value="notification-read">通知を見る。</b-form-checkbox> + <b-form-checkbox value="notification-write">通知を操作する。</b-form-checkbox> + </b-form-checkbox-group> + </b-form-group> + </b-card> + <hr> + <b-button type="submit" variant="primary">アプリ作成</b-button> + </b-form> + </b-card> +</mk-ui> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + data() { + return { + name: '', + nid: '', + description: '', + cb: '', + nidState: null, + permission: [] + }; + }, + watch: { + nid() { + if (this.nid == null || this.nid == '') { + this.nidState = null; + return; + } + + const err = + !this.nid.match(/^[a-zA-Z0-9\-]+$/) ? 'invalid-format' : + this.nid.length < 3 ? 'min-range' : + this.nid.length > 30 ? 'max-range' : + null; + + if (err) { + this.nidState = err; + return; + } + + this.nidState = 'wait'; + + (this as any).api('app/name_id/available', { + name_id: this.nid + }).then(result => { + this.nidState = result.available ? 'ok' : 'unavailable'; + }).catch(err => { + this.nidState = 'error'; + }); + } + }, + methods: { + onSubmit() { + (this as any).api('app/create', { + name: this.name, + name_id: this.nid, + description: this.description, + callback_url: this.cb, + permission: this.permission + }).then(() => { + location.href = '/apps'; + }).catch(() => { + alert('アプリの作成に失敗しました。再度お試しください。'); + }); + } + } +}); +</script> diff --git a/src/server/web/app/dev/views/ui.vue b/src/server/web/app/dev/views/ui.vue new file mode 100644 index 0000000000..4a0fcee635 --- /dev/null +++ b/src/server/web/app/dev/views/ui.vue @@ -0,0 +1,20 @@ +<template> +<div> + <b-navbar toggleable="md" type="dark" variant="info"> + <b-navbar-brand>Misskey Developers</b-navbar-brand> + <b-navbar-nav> + <b-nav-item to="/">Home</b-nav-item> + <b-nav-item to="/apps">Apps</b-nav-item> + </b-navbar-nav> + </b-navbar> + <main> + <slot></slot> + </main> +</div> +</template> + +<style lang="stylus" scoped> +main + padding 32px + max-width 700px +</style> diff --git a/src/server/web/app/init.css b/src/server/web/app/init.css new file mode 100644 index 0000000000..2587f63943 --- /dev/null +++ b/src/server/web/app/init.css @@ -0,0 +1,66 @@ +/** + * Boot screen style + */ + +@charset 'utf-8'; + +html { + font-family: sans-serif; +} + +body > noscript { + position: fixed; + z-index: 2; + top: 0; + left: 0; + width: 100%; + height: 100%; + text-align: center; + background: #fff; +} + body > noscript > p { + display: block; + margin: 32px; + font-size: 2em; + color: #555; + } + +#ini { + position: fixed; + z-index: 1; + top: 0; + left: 0; + width: 100%; + height: 100%; + text-align: center; + background: #fff; + cursor: wait; +} + #ini > p { + display: block; + user-select: none; + margin: 32px; + font-size: 4em; + color: #555; + } + #ini > p > span { + animation: ini 1.4s infinite ease-in-out both; + } + #ini > p > span:nth-child(1) { + animation-delay: 0s; + } + #ini > p > span:nth-child(2) { + animation-delay: 0.16s; + } + #ini > p > span:nth-child(3) { + animation-delay: 0.32s; + } + +@keyframes ini { + 0%, 80%, 100% { + opacity: 1; + } + 40% { + opacity: 0; + } +} diff --git a/src/server/web/app/init.ts b/src/server/web/app/init.ts new file mode 100644 index 0000000000..521dade866 --- /dev/null +++ b/src/server/web/app/init.ts @@ -0,0 +1,172 @@ +/** + * App initializer + */ + +import Vue from 'vue'; +import VueRouter from 'vue-router'; +import VModal from 'vue-js-modal'; +import * as TreeView from 'vue-json-tree-view'; +import VAnimateCss from 'v-animate-css'; +import Element from 'element-ui'; +import ElementLocaleEn from 'element-ui/lib/locale/lang/en'; +import ElementLocaleJa from 'element-ui/lib/locale/lang/ja'; + +import App from './app.vue'; +import checkForUpdate from './common/scripts/check-for-update'; +import MiOS, { API } from './common/mios'; +import { version, hostname, lang } from './config'; + +let elementLocale; +switch (lang) { + case 'ja': elementLocale = ElementLocaleJa; break; + case 'en': elementLocale = ElementLocaleEn; break; + default: elementLocale = ElementLocaleEn; break; +} + +Vue.use(VueRouter); +Vue.use(VModal); +Vue.use(TreeView); +Vue.use(VAnimateCss); +Vue.use(Element, { locale: elementLocale }); + +// Register global directives +require('./common/views/directives'); + +// Register global components +require('./common/views/components'); +require('./common/views/widgets'); + +// Register global filters +require('./common/views/filters'); + +Vue.mixin({ + destroyed(this: any) { + if (this.$el.parentNode) { + this.$el.parentNode.removeChild(this.$el); + } + } +}); + +/** + * APP ENTRY POINT! + */ + +console.info(`Misskey v${version} (葵 aoi)`); +console.info( + '%cここにコードを入力したり張り付けたりしないでください。アカウントが不正利用される可能性があります。', + 'color: red; background: yellow; font-size: 16px; font-weight: bold;'); + +// BootTimer解除 +window.clearTimeout((window as any).mkBootTimer); +delete (window as any).mkBootTimer; + +if (hostname != 'localhost') { + document.domain = hostname; +} + +//#region Set lang attr +const html = document.documentElement; +html.setAttribute('lang', lang); +//#endregion + +//#region 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); +//#endregion + +// 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: (launch: (router: VueRouter, api?: (os: MiOS) => API) => [Vue, MiOS]) => void, sw = false) => { + const os = new MiOS(sw); + + os.init(() => { + // アプリ基底要素マウント + document.body.innerHTML = '<div id="app"></div>'; + + const launch = (router: VueRouter, api?: (os: MiOS) => API) => { + os.apis = api ? api(os) : null; + + Vue.mixin({ + data() { + return { + os, + api: os.api, + apis: os.apis + }; + } + }); + + const app = new Vue({ + router, + created() { + this.$watch('os.i', i => { + // キャッシュ更新 + localStorage.setItem('me', JSON.stringify(i)); + }, { + deep: true + }); + }, + render: createEl => createEl(App) + }); + + os.app = app; + + // マウント + app.$mount('#app'); + + return [app, os] as [Vue, MiOS]; + }; + + try { + callback(launch); + } catch (e) { + panic(e); + } + + //#region 更新チェック + const preventUpdate = localStorage.getItem('preventUpdate') == 'true'; + if (!preventUpdate) { + setTimeout(() => { + checkForUpdate(os); + }, 3000); + } + //#endregion + }); +}; + +// 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/server/web/app/mobile/api/choose-drive-file.ts b/src/server/web/app/mobile/api/choose-drive-file.ts new file mode 100644 index 0000000000..b1a78f2364 --- /dev/null +++ b/src/server/web/app/mobile/api/choose-drive-file.ts @@ -0,0 +1,18 @@ +import Chooser from '../views/components/drive-file-chooser.vue'; + +export default function(opts) { + return new Promise((res, rej) => { + const o = opts || {}; + const w = new Chooser({ + propsData: { + title: o.title, + multiple: o.multiple, + initFolder: o.currentFolder + } + }).$mount(); + w.$once('selected', file => { + res(file); + }); + document.body.appendChild(w.$el); + }); +} diff --git a/src/server/web/app/mobile/api/choose-drive-folder.ts b/src/server/web/app/mobile/api/choose-drive-folder.ts new file mode 100644 index 0000000000..d1f97d1487 --- /dev/null +++ b/src/server/web/app/mobile/api/choose-drive-folder.ts @@ -0,0 +1,17 @@ +import Chooser from '../views/components/drive-folder-chooser.vue'; + +export default function(opts) { + return new Promise((res, rej) => { + const o = opts || {}; + const w = new Chooser({ + propsData: { + title: o.title, + initFolder: o.currentFolder + } + }).$mount(); + w.$once('selected', folder => { + res(folder); + }); + document.body.appendChild(w.$el); + }); +} diff --git a/src/server/web/app/mobile/api/dialog.ts b/src/server/web/app/mobile/api/dialog.ts new file mode 100644 index 0000000000..a2378767be --- /dev/null +++ b/src/server/web/app/mobile/api/dialog.ts @@ -0,0 +1,5 @@ +export default function(opts) { + return new Promise<string>((res, rej) => { + alert('dialog not implemented yet'); + }); +} diff --git a/src/server/web/app/mobile/api/input.ts b/src/server/web/app/mobile/api/input.ts new file mode 100644 index 0000000000..38d0fb61eb --- /dev/null +++ b/src/server/web/app/mobile/api/input.ts @@ -0,0 +1,8 @@ +export default function(opts) { + return new Promise<string>((res, rej) => { + const x = window.prompt(opts.title); + if (x) { + res(x); + } + }); +} diff --git a/src/server/web/app/mobile/api/notify.ts b/src/server/web/app/mobile/api/notify.ts new file mode 100644 index 0000000000..82780d196f --- /dev/null +++ b/src/server/web/app/mobile/api/notify.ts @@ -0,0 +1,3 @@ +export default function(message) { + alert(message); +} diff --git a/src/server/web/app/mobile/api/post.ts b/src/server/web/app/mobile/api/post.ts new file mode 100644 index 0000000000..9b78ce10c2 --- /dev/null +++ b/src/server/web/app/mobile/api/post.ts @@ -0,0 +1,43 @@ +import PostForm from '../views/components/post-form.vue'; +//import RepostForm from '../views/components/repost-form.vue'; +import getPostSummary from '../../../../common/get-post-summary'; + +export default (os) => (opts) => { + const o = opts || {}; + + if (o.repost) { + /*const vm = new RepostForm({ + propsData: { + repost: o.repost + } + }).$mount(); + vm.$once('cancel', recover); + vm.$once('post', recover); + document.body.appendChild(vm.$el);*/ + + const text = window.prompt(`「${getPostSummary(o.repost)}」をRepost`); + if (text == null) return; + os.api('posts/create', { + repost_id: o.repost.id, + text: text == '' ? undefined : text + }); + } else { + const app = document.getElementById('app'); + app.style.display = 'none'; + + function recover() { + app.style.display = 'block'; + } + + const vm = new PostForm({ + parent: os.app, + propsData: { + reply: o.reply + } + }).$mount(); + vm.$once('cancel', recover); + vm.$once('post', recover); + document.body.appendChild(vm.$el); + (vm as any).focus(); + } +}; diff --git a/src/server/web/app/mobile/script.ts b/src/server/web/app/mobile/script.ts new file mode 100644 index 0000000000..4776fccddb --- /dev/null +++ b/src/server/web/app/mobile/script.ts @@ -0,0 +1,84 @@ +/** + * Mobile Client + */ + +import VueRouter from 'vue-router'; + +// Style +import './style.styl'; +import '../../element.scss'; + +import init from '../init'; + +import chooseDriveFolder from './api/choose-drive-folder'; +import chooseDriveFile from './api/choose-drive-file'; +import dialog from './api/dialog'; +import input from './api/input'; +import post from './api/post'; +import notify from './api/notify'; + +import MkIndex from './views/pages/index.vue'; +import MkSignup from './views/pages/signup.vue'; +import MkUser from './views/pages/user.vue'; +import MkSelectDrive from './views/pages/selectdrive.vue'; +import MkDrive from './views/pages/drive.vue'; +import MkNotifications from './views/pages/notifications.vue'; +import MkMessaging from './views/pages/messaging.vue'; +import MkMessagingRoom from './views/pages/messaging-room.vue'; +import MkPost from './views/pages/post.vue'; +import MkSearch from './views/pages/search.vue'; +import MkFollowers from './views/pages/followers.vue'; +import MkFollowing from './views/pages/following.vue'; +import MkSettings from './views/pages/settings.vue'; +import MkProfileSetting from './views/pages/profile-setting.vue'; +import MkOthello from './views/pages/othello.vue'; + +/** + * init + */ +init((launch) => { + // Register directives + require('./views/directives'); + + // Register components + require('./views/components'); + require('./views/widgets'); + + // http://qiita.com/junya/items/3ff380878f26ca447f85 + document.body.setAttribute('ontouchstart', ''); + + // Init router + const router = new VueRouter({ + mode: 'history', + routes: [ + { path: '/', name: 'index', component: MkIndex }, + { path: '/signup', name: 'signup', component: MkSignup }, + { path: '/i/settings', component: MkSettings }, + { path: '/i/settings/profile', component: MkProfileSetting }, + { path: '/i/notifications', component: MkNotifications }, + { path: '/i/messaging', component: MkMessaging }, + { path: '/i/messaging/:user', component: MkMessagingRoom }, + { path: '/i/drive', component: MkDrive }, + { path: '/i/drive/folder/:folder', component: MkDrive }, + { path: '/i/drive/file/:file', component: MkDrive }, + { path: '/selectdrive', component: MkSelectDrive }, + { path: '/search', component: MkSearch }, + { path: '/othello', component: MkOthello }, + { path: '/othello/:game', component: MkOthello }, + { path: '/@:user', component: MkUser }, + { path: '/@:user/followers', component: MkFollowers }, + { path: '/@:user/following', component: MkFollowing }, + { path: '/@:user/:post', component: MkPost } + ] + }); + + // Launch the app + launch(router, os => ({ + chooseDriveFolder, + chooseDriveFile, + dialog, + input, + post: post(os), + notify + })); +}, true); diff --git a/src/server/web/app/mobile/style.styl b/src/server/web/app/mobile/style.styl new file mode 100644 index 0000000000..81912a2483 --- /dev/null +++ b/src/server/web/app/mobile/style.styl @@ -0,0 +1,15 @@ +@import "../app" +@import "../reset" + +#wait + top auto + bottom 15px + left 15px + +html + height 100% + +body + display flex + flex-direction column + min-height 100% diff --git a/src/server/web/app/mobile/views/components/activity.vue b/src/server/web/app/mobile/views/components/activity.vue new file mode 100644 index 0000000000..b50044b3de --- /dev/null +++ b/src/server/web/app/mobile/views/components/activity.vue @@ -0,0 +1,62 @@ +<template> +<div class="mk-activity"> + <svg v-if="data" ref="canvas" viewBox="0 0 30 1" preserveAspectRatio="none"> + <g v-for="(d, i) in data"> + <rect width="0.8" :height="d.postsH" + :x="i + 0.1" :y="1 - d.postsH - d.repliesH - d.repostsH" + fill="#41ddde"/> + <rect width="0.8" :height="d.repliesH" + :x="i + 0.1" :y="1 - d.repliesH - d.repostsH" + fill="#f7796c"/> + <rect width="0.8" :height="d.repostsH" + :x="i + 0.1" :y="1 - d.repostsH" + fill="#a1de41"/> + </g> + </svg> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + props: ['user'], + data() { + return { + fetching: true, + data: [], + peak: null + }; + }, + mounted() { + (this as any).api('aggregation/users/activity', { + user_id: this.user.id, + limit: 30 + }).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; + }); + data.reverse(); + this.data = data; + }); + } +}); +</script> + +<style lang="stylus" scoped> +.mk-activity + max-width 600px + margin 0 auto + + > svg + display block + width 100% + height 80px + + > rect + transform-origin center + +</style> diff --git a/src/server/web/app/mobile/views/components/drive-file-chooser.vue b/src/server/web/app/mobile/views/components/drive-file-chooser.vue new file mode 100644 index 0000000000..6806af0f1e --- /dev/null +++ b/src/server/web/app/mobile/views/components/drive-file-chooser.vue @@ -0,0 +1,98 @@ +<template> +<div class="mk-drive-file-chooser"> + <div class="body"> + <header> + <h1>%i18n:mobile.tags.mk-drive-selector.select-file%<span class="count" v-if="files.length > 0">({{ files.length }})</span></h1> + <button class="close" @click="cancel">%fa:times%</button> + <button v-if="multiple" class="ok" @click="ok">%fa:check%</button> + </header> + <mk-drive ref="browser" + :select-file="true" + :multiple="multiple" + @change-selection="onChangeSelection" + @selected="onSelected" + /> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + props: ['multiple'], + data() { + return { + files: [] + }; + }, + methods: { + onChangeSelection(files) { + this.files = files; + }, + onSelected(file) { + this.$emit('selected', file); + this.$destroy(); + }, + cancel() { + this.$emit('canceled'); + this.$destroy(); + }, + ok() { + this.$emit('selected', this.files); + this.$destroy(); + } + } +}); +</script> + +<style lang="stylus" scoped> +.mk-drive-file-chooser + position fixed + z-index 2048 + top 0 + left 0 + width 100% + height 100% + padding 8px + background rgba(0, 0, 0, 0.2) + + > .body + width 100% + height 100% + background #fff + + > header + border-bottom solid 1px #eee + + > h1 + margin 0 + padding 0 + text-align center + line-height 42px + font-size 1em + font-weight normal + + > .count + margin-left 4px + opacity 0.5 + + > .close + position absolute + top 0 + left 0 + line-height 42px + width 42px + + > .ok + position absolute + top 0 + right 0 + line-height 42px + width 42px + + > .mk-drive + height calc(100% - 42px) + overflow scroll + -webkit-overflow-scrolling touch + +</style> diff --git a/src/server/web/app/mobile/views/components/drive-folder-chooser.vue b/src/server/web/app/mobile/views/components/drive-folder-chooser.vue new file mode 100644 index 0000000000..853078664f --- /dev/null +++ b/src/server/web/app/mobile/views/components/drive-folder-chooser.vue @@ -0,0 +1,78 @@ +<template> +<div class="mk-drive-folder-chooser"> + <div class="body"> + <header> + <h1>%i18n:mobile.tags.mk-drive-folder-selector.select-folder%</h1> + <button class="close" @click="cancel">%fa:times%</button> + <button class="ok" @click="ok">%fa:check%</button> + </header> + <mk-drive ref="browser" + select-folder + /> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + methods: { + cancel() { + this.$emit('canceled'); + this.$destroy(); + }, + ok() { + this.$emit('selected', (this.$refs.browser as any).folder); + this.$destroy(); + } + } +}); +</script> + +<style lang="stylus" scoped> +.mk-drive-folder-chooser + position fixed + z-index 2048 + top 0 + left 0 + width 100% + height 100% + padding 8px + background rgba(0, 0, 0, 0.2) + + > .body + width 100% + height 100% + background #fff + + > header + border-bottom solid 1px #eee + + > h1 + margin 0 + padding 0 + text-align center + line-height 42px + font-size 1em + font-weight normal + + > .close + position absolute + top 0 + left 0 + line-height 42px + width 42px + + > .ok + position absolute + top 0 + right 0 + line-height 42px + width 42px + + > .mk-drive + height calc(100% - 42px) + overflow scroll + -webkit-overflow-scrolling touch + +</style> diff --git a/src/server/web/app/mobile/views/components/drive.file-detail.vue b/src/server/web/app/mobile/views/components/drive.file-detail.vue new file mode 100644 index 0000000000..e41ebbb451 --- /dev/null +++ b/src/server/web/app/mobile/views/components/drive.file-detail.vue @@ -0,0 +1,295 @@ +<template> +<div class="file-detail"> + <div class="preview"> + <img v-if="kind == 'image'" ref="img" + :src="file.url" + :alt="file.name" + :title="file.name" + @load="onImageLoaded" + :style="style"> + <template v-if="kind != 'image'">%fa:file%</template> + <footer v-if="kind == 'image' && file.properties && file.properties.width && file.properties.height"> + <span class="size"> + <span class="width">{{ file.properties.width }}</span> + <span class="time">×</span> + <span class="height">{{ file.properties.height }}</span> + <span class="px">px</span> + </span> + <span class="separator"></span> + <span class="aspect-ratio"> + <span class="width">{{ file.properties.width / gcd(file.properties.width, file.properties.height) }}</span> + <span class="colon">:</span> + <span class="height">{{ file.properties.height / gcd(file.properties.width, file.properties.height) }}</span> + </span> + </footer> + </div> + <div class="info"> + <div> + <span class="type"><mk-file-type-icon :type="file.type"/>{{ file.type }}</span> + <span class="separator"></span> + <span class="data-size">{{ file.datasize | bytes }}</span> + <span class="separator"></span> + <span class="created-at" @click="showCreatedAt">%fa:R clock%<mk-time :time="file.created_at"/></span> + </div> + </div> + <div class="menu"> + <div> + <a :href="`${file.url}?download`" :download="file.name"> + %fa:download%%i18n:mobile.tags.mk-drive-file-viewer.download% + </a> + <button @click="rename"> + %fa:pencil-alt%%i18n:mobile.tags.mk-drive-file-viewer.rename% + </button> + <button @click="move"> + %fa:R folder-open%%i18n:mobile.tags.mk-drive-file-viewer.move% + </button> + </div> + </div> + <div class="exif" v-show="exif"> + <div> + <p> + %fa:camera%%i18n:mobile.tags.mk-drive-file-viewer.exif% + </p> + <pre ref="exif" class="json">{{ exif ? JSON.stringify(exif, null, 2) : '' }}</pre> + </div> + </div> + <div class="hash"> + <div> + <p> + %fa:hashtag%%i18n:mobile.tags.mk-drive-file-viewer.hash% + </p> + <code>{{ file.md5 }}</code> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import * as EXIF from 'exif-js'; +import * as hljs from 'highlight.js'; +import gcd from '../../../common/scripts/gcd'; + +export default Vue.extend({ + props: ['file'], + data() { + return { + gcd, + exif: null + }; + }, + computed: { + browser(): any { + return this.$parent; + }, + kind(): string { + return this.file.type.split('/')[0]; + }, + style(): any { + return this.file.properties.average_color ? { + 'background-color': `rgb(${ this.file.properties.average_color.join(',') })` + } : {}; + } + }, + methods: { + rename() { + const name = window.prompt('名前を変更', this.file.name); + if (name == null || name == '' || name == this.file.name) return; + (this as any).api('drive/files/update', { + file_id: this.file.id, + name: name + }).then(() => { + this.browser.cf(this.file, true); + }); + }, + move() { + (this as any).apis.chooseDriveFolder().then(folder => { + (this as any).api('drive/files/update', { + file_id: this.file.id, + folder_id: folder == null ? null : folder.id + }).then(() => { + this.browser.cf(this.file, true); + }); + }); + }, + showCreatedAt() { + alert(new Date(this.file.created_at).toLocaleString()); + }, + onImageLoaded() { + const self = this; + EXIF.getData(this.$refs.img, function(this: any) { + const allMetaData = EXIF.getAllTags(this); + self.exif = allMetaData; + hljs.highlightBlock(self.$refs.exif); + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +.file-detail + + > .preview + padding 8px + background #f0f0f0 + + > img + display block + max-width 100% + max-height 300px + margin 0 auto + box-shadow 1px 1px 4px rgba(0, 0, 0, 0.2) + + > footer + padding 8px 8px 0 8px + font-size 0.8em + color #888 + text-align center + + > .separator + display inline + padding 0 4px + + > .size + display inline + + .time + margin 0 2px + + .px + margin-left 4px + + > .aspect-ratio + display inline + opacity 0.7 + + &:before + content "(" + + &:after + content ")" + + > .info + padding 14px + font-size 0.8em + border-top solid 1px #dfdfdf + + > div + max-width 500px + margin 0 auto + + > .separator + padding 0 4px + color #cdcdcd + + > .type + > .data-size + color #9d9d9d + + > mk-file-type-icon + margin-right 4px + + > .created-at + color #bdbdbd + + > [data-fa] + margin-right 2px + + > .menu + padding 14px + border-top solid 1px #dfdfdf + + > div + max-width 500px + margin 0 auto + + > * + display block + width 100% + padding 10px 16px + margin 0 0 12px 0 + color #333 + font-size 0.9em + text-align center + text-decoration none + text-shadow 0 1px 0 rgba(255, 255, 255, 0.9) + background-image linear-gradient(#fafafa, #eaeaea) + border 1px solid #ddd + border-bottom-color #cecece + border-radius 3px + + &:last-child + margin-bottom 0 + + &:active + background-color #767676 + background-image none + border-color #444 + box-shadow 0 1px 3px rgba(0, 0, 0, 0.075), inset 0 0 5px rgba(0, 0, 0, 0.2) + + > [data-fa] + margin-right 4px + + > .hash + padding 14px + border-top solid 1px #dfdfdf + + > div + max-width 500px + margin 0 auto + + > p + display block + margin 0 + padding 0 + color #555 + font-size 0.9em + + > [data-fa] + margin-right 4px + + > code + display block + width 100% + margin 6px 0 0 0 + padding 8px + white-space nowrap + overflow auto + font-size 0.8em + color #222 + border solid 1px #dfdfdf + border-radius 2px + background #f5f5f5 + + > .exif + padding 14px + border-top solid 1px #dfdfdf + + > div + max-width 500px + margin 0 auto + + > p + display block + margin 0 + padding 0 + color #555 + font-size 0.9em + + > [data-fa] + margin-right 4px + + > pre + display block + width 100% + margin 6px 0 0 0 + padding 8px + height 128px + overflow auto + font-size 0.9em + border solid 1px #dfdfdf + border-radius 2px + background #f5f5f5 + +</style> diff --git a/src/server/web/app/mobile/views/components/drive.file.vue b/src/server/web/app/mobile/views/components/drive.file.vue new file mode 100644 index 0000000000..db73816282 --- /dev/null +++ b/src/server/web/app/mobile/views/components/drive.file.vue @@ -0,0 +1,171 @@ +<template> +<a class="file" @click.prevent="onClick" :href="`/i/drive/file/${ file.id }`" :data-is-selected="isSelected"> + <div class="container"> + <div class="thumbnail" :style="thumbnail"></div> + <div class="body"> + <p class="name"> + <span>{{ file.name.lastIndexOf('.') != -1 ? file.name.substr(0, file.name.lastIndexOf('.')) : file.name }}</span> + <span class="ext" v-if="file.name.lastIndexOf('.') != -1">{{ file.name.substr(file.name.lastIndexOf('.')) }}</span> + </p> + <!-- + if file.tags.length > 0 + ul.tags + each tag in file.tags + li.tag(style={background: tag.color, color: contrast(tag.color)})= tag.name + --> + <footer> + <p class="type"><mk-file-type-icon :type="file.type"/>{{ file.type }}</p> + <p class="separator"></p> + <p class="data-size">{{ file.datasize | bytes }}</p> + <p class="separator"></p> + <p class="created-at"> + %fa:R clock%<mk-time :time="file.created_at"/> + </p> + </footer> + </div> + </div> +</a> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + props: ['file'], + data() { + return { + isSelected: false + }; + }, + computed: { + browser(): any { + return this.$parent; + }, + thumbnail(): any { + return { + 'background-color': this.file.properties.average_color ? `rgb(${this.file.properties.average_color.join(',')})` : 'transparent', + 'background-image': `url(${this.file.url}?thumbnail&size=128)` + }; + } + }, + created() { + this.isSelected = this.browser.selectedFiles.some(f => f.id == this.file.id) + + this.browser.$on('change-selection', this.onBrowserChangeSelection); + }, + beforeDestroy() { + this.browser.$off('change-selection', this.onBrowserChangeSelection); + }, + methods: { + onBrowserChangeSelection(selections) { + this.isSelected = selections.some(f => f.id == this.file.id); + }, + onClick() { + this.browser.chooseFile(this.file); + } + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +.file + display block + text-decoration none !important + + * + user-select none + pointer-events none + + > .container + max-width 500px + margin 0 auto + padding 16px + + &:after + content "" + display block + clear both + + > .thumbnail + display block + float left + width 64px + height 64px + background-size cover + background-position center center + + > .body + display block + float left + width calc(100% - 74px) + margin-left 10px + + > .name + display block + margin 0 + padding 0 + font-size 0.9em + font-weight bold + color #555 + text-overflow ellipsis + overflow-wrap break-word + + > .ext + opacity 0.5 + + > .tags + display block + margin 4px 0 0 0 + padding 0 + list-style none + font-size 0.5em + + > .tag + display inline-block + margin 0 5px 0 0 + padding 1px 5px + border-radius 2px + + > footer + display block + margin 4px 0 0 0 + font-size 0.7em + + > .separator + display inline + margin 0 + padding 0 4px + color #CDCDCD + + > .type + display inline + margin 0 + padding 0 + color #9D9D9D + + > .mk-file-type-icon + margin-right 4px + + > .data-size + display inline + margin 0 + padding 0 + color #9D9D9D + + > .created-at + display inline + margin 0 + padding 0 + color #BDBDBD + + > [data-fa] + margin-right 2px + + &[data-is-selected] + background $theme-color + + &, * + color #fff !important + +</style> diff --git a/src/server/web/app/mobile/views/components/drive.folder.vue b/src/server/web/app/mobile/views/components/drive.folder.vue new file mode 100644 index 0000000000..22ff38fecb --- /dev/null +++ b/src/server/web/app/mobile/views/components/drive.folder.vue @@ -0,0 +1,58 @@ +<template> +<a class="root folder" @click.prevent="onClick" :href="`/i/drive/folder/${ folder.id }`"> + <div class="container"> + <p class="name">%fa:folder%{{ folder.name }}</p>%fa:angle-right% + </div> +</a> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + props: ['folder'], + computed: { + browser(): any { + return this.$parent; + } + }, + methods: { + onClick() { + this.browser.cd(this.folder); + } + } +}); +</script> + +<style lang="stylus" scoped> +.root.folder + display block + color #777 + text-decoration none !important + + * + user-select none + pointer-events none + + > .container + max-width 500px + margin 0 auto + padding 16px + + > .name + display block + margin 0 + padding 0 + + > [data-fa] + margin-right 6px + + > [data-fa] + position absolute + top 0 + bottom 0 + right 20px + + > * + height 100% + +</style> diff --git a/src/server/web/app/mobile/views/components/drive.vue b/src/server/web/app/mobile/views/components/drive.vue new file mode 100644 index 0000000000..696c63e2a4 --- /dev/null +++ b/src/server/web/app/mobile/views/components/drive.vue @@ -0,0 +1,581 @@ +<template> +<div class="mk-drive"> + <nav ref="nav"> + <a @click.prevent="goRoot()" href="/i/drive">%fa:cloud%%i18n:mobile.tags.mk-drive.drive%</a> + <template v-for="folder in hierarchyFolders"> + <span :key="folder.id + '>'">%fa:angle-right%</span> + <a :key="folder.id" @click.prevent="cd(folder)" :href="`/i/drive/folder/${folder.id}`">{{ folder.name }}</a> + </template> + <template v-if="folder != null"> + <span>%fa:angle-right%</span> + <p>{{ folder.name }}</p> + </template> + <template v-if="file != null"> + <span>%fa:angle-right%</span> + <p>{{ file.name }}</p> + </template> + </nav> + <mk-uploader ref="uploader"/> + <div class="browser" :class="{ fetching }" v-if="file == null"> + <div class="info" v-if="info"> + <p v-if="folder == null">{{ (info.usage / info.capacity * 100).toFixed(1) }}% %i18n:mobile.tags.mk-drive.used%</p> + <p v-if="folder != null && (folder.folders_count > 0 || folder.files_count > 0)"> + <template v-if="folder.folders_count > 0">{{ folder.folders_count }} %i18n:mobile.tags.mk-drive.folder-count%</template> + <template v-if="folder.folders_count > 0 && folder.files_count > 0">%i18n:mobile.tags.mk-drive.count-separator%</template> + <template v-if="folder.files_count > 0">{{ folder.files_count }} %i18n:mobile.tags.mk-drive.file-count%</template> + </p> + </div> + <div class="folders" v-if="folders.length > 0"> + <x-folder v-for="folder in folders" :key="folder.id" :folder="folder"/> + <p v-if="moreFolders">%i18n:mobile.tags.mk-drive.load-more%</p> + </div> + <div class="files" v-if="files.length > 0"> + <x-file v-for="file in files" :key="file.id" :file="file"/> + <button class="more" v-if="moreFiles" @click="fetchMoreFiles"> + {{ fetchingMoreFiles ? '%i18n:common.loading%' : '%i18n:mobile.tags.mk-drive.load-more%' }} + </button> + </div> + <div class="empty" v-if="files.length == 0 && folders.length == 0 && !fetching"> + <p v-if="folder == null">%i18n:mobile.tags.mk-drive.nothing-in-drive%</p> + <p v-if="folder != null">%i18n:mobile.tags.mk-drive.folder-is-empty%</p> + </div> + </div> + <div class="fetching" v-if="fetching && file == null && files.length == 0 && folders.length == 0"> + <div class="spinner"> + <div class="dot1"></div> + <div class="dot2"></div> + </div> + </div> + <input ref="file" class="file" type="file" multiple="multiple" @change="onChangeLocalFile"/> + <x-file-detail v-if="file != null" :file="file"/> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import XFolder from './drive.folder.vue'; +import XFile from './drive.file.vue'; +import XFileDetail from './drive.file-detail.vue'; + +export default Vue.extend({ + components: { + XFolder, + XFile, + XFileDetail + }, + props: ['initFolder', 'initFile', 'selectFile', 'multiple', 'isNaked', 'top'], + data() { + return { + /** + * 現在の階層(フォルダ) + * * null でルートを表す + */ + folder: null, + + file: null, + + files: [], + folders: [], + moreFiles: false, + moreFolders: false, + hierarchyFolders: [], + selectedFiles: [], + info: null, + connection: null, + connectionId: null, + + fetching: true, + fetchingMoreFiles: false, + fetchingMoreFolders: false + }; + }, + computed: { + isFileSelectMode(): boolean { + return this.selectFile; + } + }, + mounted() { + this.connection = (this as any).os.streams.driveStream.getConnection(); + this.connectionId = (this as any).os.streams.driveStream.use(); + + 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.initFolder) { + this.cd(this.initFolder, true); + } else if (this.initFile) { + this.cf(this.initFile, true); + } else { + this.fetch(); + } + + if (this.isNaked) { + (this.$refs.nav as any).style.top = `${this.top}px`; + } + }, + beforeDestroy() { + 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 as any).os.streams.driveStream.dispose(this.connectionId); + }, + methods: { + onStreamDriveFileCreated(file) { + this.addFile(file, true); + }, + + onStreamDriveFileUpdated(file) { + const current = this.folder ? this.folder.id : null; + if (current != file.folder_id) { + this.removeFile(file); + } else { + this.addFile(file, true); + } + }, + + onStreamDriveFolderCreated(folder) { + this.addFolder(folder, true); + }, + + onStreamDriveFolderUpdated(folder) { + const current = this.folder ? this.folder.id : null; + if (current != folder.parent_id) { + this.removeFolder(folder); + } else { + this.addFolder(folder, true); + } + }, + + dive(folder) { + this.hierarchyFolders.unshift(folder); + if (folder.parent) this.dive(folder.parent); + }, + + cd(target, silent = false) { + this.file = null; + + if (target == null) { + this.goRoot(silent); + return; + } else if (typeof target == 'object') { + target = target.id; + } + + this.fetching = true; + + (this as any).api('drive/folders/show', { + folder_id: target + }).then(folder => { + this.folder = folder; + this.hierarchyFolders = []; + + if (folder.parent) this.dive(folder.parent); + + this.$emit('open-folder', this.folder, silent); + this.fetch(); + }); + }, + + addFolder(folder, unshift = false) { + const current = this.folder ? this.folder.id : null; + // 追加しようとしているフォルダが、今居る階層とは違う階層のものだったら中断 + if (current != folder.parent_id) return; + + // 追加しようとしているフォルダを既に所有してたら中断 + if (this.folders.some(f => f.id == folder.id)) return; + + if (unshift) { + this.folders.unshift(folder); + } else { + this.folders.push(folder); + } + }, + + addFile(file, unshift = false) { + const current = this.folder ? this.folder.id : null; + // 追加しようとしているファイルが、今居る階層とは違う階層のものだったら中断 + if (current != file.folder_id) return; + + if (this.files.some(f => f.id == file.id)) { + const exist = this.files.map(f => f.id).indexOf(file.id); + Vue.set(this.files, exist, file); + return; + } + + if (unshift) { + this.files.unshift(file); + } else { + this.files.push(file); + } + }, + + removeFolder(folder) { + if (typeof folder == 'object') folder = folder.id; + this.folders = this.folders.filter(f => f.id != folder); + }, + + removeFile(file) { + if (typeof file == 'object') file = file.id; + this.files = this.files.filter(f => f.id != file); + }, + + appendFile(file) { + this.addFile(file); + }, + appendFolder(folder) { + this.addFolder(folder); + }, + prependFile(file) { + this.addFile(file, true); + }, + prependFolder(folder) { + this.addFolder(folder, true); + }, + + goRoot(silent = false) { + if (this.folder || this.file) { + this.file = null; + this.folder = null; + this.hierarchyFolders = []; + this.$emit('move-root', silent); + this.fetch(); + } + }, + + fetch() { + this.folders = []; + this.files = []; + this.moreFolders = false; + this.moreFiles = false; + this.fetching = true; + + this.$emit('begin-fetch'); + + let fetchedFolders = null; + let fetchedFiles = null; + + const foldersMax = 20; + const filesMax = 20; + + // フォルダ一覧取得 + (this as any).api('drive/folders', { + folder_id: this.folder ? this.folder.id : null, + limit: foldersMax + 1 + }).then(folders => { + if (folders.length == foldersMax + 1) { + this.moreFolders = true; + folders.pop(); + } + fetchedFolders = folders; + complete(); + }); + + // ファイル一覧取得 + (this as any).api('drive/files', { + folder_id: this.folder ? this.folder.id : null, + limit: filesMax + 1 + }).then(files => { + if (files.length == filesMax + 1) { + this.moreFiles = true; + files.pop(); + } + fetchedFiles = files; + complete(); + }); + + let flag = false; + const complete = () => { + if (flag) { + fetchedFolders.forEach(this.appendFolder); + fetchedFiles.forEach(this.appendFile); + this.fetching = false; + + // 一連の読み込みが完了したイベントを発行 + this.$emit('fetched'); + } else { + flag = true; + // 一連の読み込みが半分完了したイベントを発行 + this.$emit('fetch-mid'); + } + }; + + if (this.folder == null) { + // Fetch addtional drive info + (this as any).api('drive').then(info => { + this.info = info; + }); + } + }, + + fetchMoreFiles() { + this.fetching = true; + this.fetchingMoreFiles = true; + + const max = 30; + + // ファイル一覧取得 + (this as any).api('drive/files', { + folder_id: this.folder ? this.folder.id : null, + limit: max + 1, + until_id: this.files[this.files.length - 1].id + }).then(files => { + if (files.length == max + 1) { + this.moreFiles = true; + files.pop(); + } else { + this.moreFiles = false; + } + files.forEach(this.appendFile); + this.fetching = false; + this.fetchingMoreFiles = false; + }); + }, + + chooseFile(file) { + if (this.isFileSelectMode) { + if (this.multiple) { + if (this.selectedFiles.some(f => f.id == file.id)) { + this.selectedFiles = this.selectedFiles.filter(f => f.id != file.id); + } else { + this.selectedFiles.push(file); + } + this.$emit('change-selection', this.selectedFiles); + } else { + this.$emit('selected', file); + } + } else { + this.cf(file); + } + }, + + cf(file, silent = false) { + if (typeof file == 'object') file = file.id; + + this.fetching = true; + + (this as any).api('drive/files/show', { + file_id: file + }).then(file => { + this.file = file; + this.folder = null; + this.hierarchyFolders = []; + + if (file.folder) this.dive(file.folder); + + this.fetching = false; + + this.$emit('open-file', this.file, silent); + }); + }, + + openContextMenu() { + const fn = window.prompt('何をしますか?(数字を入力してください): <1 → ファイルをアップロード | 2 → ファイルをURLでアップロード | 3 → フォルダ作成 | 4 → このフォルダ名を変更 | 5 → このフォルダを移動 | 6 → このフォルダを削除>'); + if (fn == null || fn == '') return; + switch (fn) { + case '1': + this.selectLocalFile(); + break; + case '2': + this.urlUpload(); + break; + case '3': + this.createFolder(); + break; + case '4': + this.renameFolder(); + break; + case '5': + this.moveFolder(); + break; + case '6': + alert('ごめんなさい!フォルダの削除は未実装です...。'); + break; + } + }, + + selectLocalFile() { + (this.$refs.file as any).click(); + }, + + createFolder() { + const name = window.prompt('フォルダー名'); + if (name == null || name == '') return; + (this as any).api('drive/folders/create', { + name: name, + parent_id: this.folder ? this.folder.id : undefined + }).then(folder => { + this.addFolder(folder, true); + }); + }, + + renameFolder() { + if (this.folder == null) { + alert('現在いる場所はルートで、フォルダではないため名前の変更はできません。名前を変更したいフォルダに移動してからやってください。'); + return; + } + const name = window.prompt('フォルダー名', this.folder.name); + if (name == null || name == '') return; + (this as any).api('drive/folders/update', { + name: name, + folder_id: this.folder.id + }).then(folder => { + this.cd(folder); + }); + }, + + moveFolder() { + if (this.folder == null) { + alert('現在いる場所はルートで、フォルダではないため移動はできません。移動したいフォルダに移動してからやってください。'); + return; + } + (this as any).apis.chooseDriveFolder().then(folder => { + (this as any).api('drive/folders/update', { + parent_id: folder ? folder.id : null, + folder_id: this.folder.id + }).then(folder => { + this.cd(folder); + }); + }); + }, + + urlUpload() { + const url = window.prompt('アップロードしたいファイルのURL'); + if (url == null || url == '') return; + (this as any).api('drive/files/upload_from_url', { + url: url, + folder_id: this.folder ? this.folder.id : undefined + }); + alert('アップロードをリクエストしました。アップロードが完了するまで時間がかかる場合があります。'); + }, + + onChangeLocalFile() { + Array.from((this.$refs.file as any).files) + .forEach(f => (this.$refs.uploader as any).upload(f, this.folder)); + } + } +}); +</script> + +<style lang="stylus" scoped> +.mk-drive + background #fff + + > nav + display block + position sticky + position -webkit-sticky + top 0 + z-index 1 + width 100% + padding 10px 12px + overflow auto + white-space nowrap + font-size 0.9em + color rgba(0, 0, 0, 0.67) + -webkit-backdrop-filter blur(12px) + backdrop-filter blur(12px) + background-color rgba(#fff, 0.75) + border-bottom solid 1px rgba(0, 0, 0, 0.13) + + > p + > a + display inline + margin 0 + padding 0 + text-decoration none !important + color inherit + + &:last-child + font-weight bold + + > [data-fa] + margin-right 4px + + > span + margin 0 8px + opacity 0.5 + + > .browser + &.fetching + opacity 0.5 + + > .info + border-bottom solid 1px #eee + + &:empty + display none + + > p + display block + max-width 500px + margin 0 auto + padding 4px 16px + font-size 10px + color #777 + + > .folders + > .folder + border-bottom solid 1px #eee + + > .files + > .file + border-bottom solid 1px #eee + + > .more + display block + width 100% + padding 16px + font-size 16px + color #555 + + > .empty + padding 16px + text-align center + color #999 + pointer-events none + + > p + margin 0 + + > .fetching + .spinner + margin 100px auto + width 40px + height 40px + text-align center + + animation sk-rotate 2.0s infinite linear + + .dot1, .dot2 + width 60% + height 60% + display inline-block + position absolute + top 0 + background rgba(0, 0, 0, 0.2) + border-radius 100% + + animation sk-bounce 2.0s infinite ease-in-out + + .dot2 + top auto + bottom 0 + animation-delay -1.0s + + @keyframes sk-rotate { 100% { transform: rotate(360deg); }} + + @keyframes sk-bounce { + 0%, 100% { + transform: scale(0.0); + } 50% { + transform: scale(1.0); + } + } + + > .file + display none + +</style> diff --git a/src/server/web/app/mobile/views/components/follow-button.vue b/src/server/web/app/mobile/views/components/follow-button.vue new file mode 100644 index 0000000000..fb6eaa39c6 --- /dev/null +++ b/src/server/web/app/mobile/views/components/follow-button.vue @@ -0,0 +1,123 @@ +<template> +<button class="mk-follow-button" + :class="{ wait: wait, follow: !user.is_following, unfollow: user.is_following }" + @click="onClick" + :disabled="wait" +> + <template v-if="!wait && user.is_following">%fa:minus%</template> + <template v-if="!wait && !user.is_following">%fa:plus%</template> + <template v-if="wait">%fa:spinner .pulse .fw%</template> + {{ user.is_following ? '%i18n:mobile.tags.mk-follow-button.unfollow%' : '%i18n:mobile.tags.mk-follow-button.follow%' }} +</button> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + props: { + user: { + type: Object, + required: true + } + }, + data() { + return { + wait: false, + connection: null, + connectionId: null + }; + }, + mounted() { + this.connection = (this as any).os.stream.getConnection(); + this.connectionId = (this as any).os.stream.use(); + + this.connection.on('follow', this.onFollow); + this.connection.on('unfollow', this.onUnfollow); + }, + beforeDestroy() { + this.connection.off('follow', this.onFollow); + this.connection.off('unfollow', this.onUnfollow); + (this as any).os.stream.dispose(this.connectionId); + }, + methods: { + + onFollow(user) { + if (user.id == this.user.id) { + this.user.is_following = user.is_following; + } + }, + + onUnfollow(user) { + if (user.id == this.user.id) { + this.user.is_following = user.is_following; + } + }, + + onClick() { + this.wait = true; + if (this.user.is_following) { + (this as any).api('following/delete', { + user_id: this.user.id + }).then(() => { + this.user.is_following = false; + }).catch(err => { + console.error(err); + }).then(() => { + this.wait = false; + }); + } else { + (this as any).api('following/create', { + user_id: this.user.id + }).then(() => { + this.user.is_following = true; + }).catch(err => { + console.error(err); + }).then(() => { + this.wait = false; + }); + } + } + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +.mk-follow-button + display block + user-select none + cursor pointer + padding 0 16px + margin 0 + height inherit + font-size 16px + outline none + border solid 1px $theme-color + border-radius 4px + + * + pointer-events none + + &.follow + color $theme-color + background transparent + + &:hover + background rgba($theme-color, 0.1) + + &:active + background rgba($theme-color, 0.2) + + &.unfollow + color $theme-color-foreground + background $theme-color + + &.wait + cursor wait !important + opacity 0.7 + + > [data-fa] + margin-right 4px + +</style> diff --git a/src/server/web/app/mobile/views/components/friends-maker.vue b/src/server/web/app/mobile/views/components/friends-maker.vue new file mode 100644 index 0000000000..961a5f568a --- /dev/null +++ b/src/server/web/app/mobile/views/components/friends-maker.vue @@ -0,0 +1,127 @@ +<template> +<div class="mk-friends-maker"> + <p class="title">気になるユーザーをフォロー:</p> + <div class="users" v-if="!fetching && users.length > 0"> + <mk-user-card v-for="user in users" :key="user.id" :user="user"/> + </div> + <p class="empty" v-if="!fetching && users.length == 0">おすすめのユーザーは見つかりませんでした。</p> + <p class="fetching" v-if="fetching">%fa:spinner .pulse .fw%読み込んでいます<mk-ellipsis/></p> + <a class="refresh" @click="refresh">もっと見る</a> + <button class="close" @click="close" title="閉じる">%fa:times%</button> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + data() { + return { + users: [], + fetching: true, + limit: 6, + page: 0 + }; + }, + mounted() { + this.fetch(); + }, + methods: { + fetch() { + this.fetching = true; + this.users = []; + + (this as any).api('users/recommendation', { + limit: this.limit, + offset: this.limit * this.page + }).then(users => { + this.users = users; + this.fetching = false; + }); + }, + refresh() { + if (this.users.length < this.limit) { + this.page = 0; + } else { + this.page++; + } + this.fetch(); + }, + close() { + this.$destroy(); + } + } +}); +</script> + +<style lang="stylus" scoped> +.mk-friends-maker + background #fff + border-radius 8px + box-shadow 0 0 0 1px rgba(0, 0, 0, 0.2) + + > .title + margin 0 + padding 8px 16px + font-size 1em + font-weight bold + color #888 + + > .users + overflow-x scroll + -webkit-overflow-scrolling touch + white-space nowrap + padding 16px + background #eee + + > .mk-user-card + &:not(:last-child) + margin-right 16px + + > .empty + margin 0 + padding 16px + text-align center + color #aaa + + > .fetching + margin 0 + padding 16px + text-align center + color #aaa + + > [data-fa] + margin-right 4px + + > .refresh + display block + margin 0 + padding 8px 16px + text-align right + font-size 0.9em + color #999 + + > .close + 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 + + &:hover + color #555 + + &:active + color #222 + + > [data-fa] + padding 10px + +</style> diff --git a/src/server/web/app/mobile/views/components/index.ts b/src/server/web/app/mobile/views/components/index.ts new file mode 100644 index 0000000000..fb8f65f47d --- /dev/null +++ b/src/server/web/app/mobile/views/components/index.ts @@ -0,0 +1,47 @@ +import Vue from 'vue'; + +import ui from './ui.vue'; +import timeline from './timeline.vue'; +import post from './post.vue'; +import posts from './posts.vue'; +import mediaImage from './media-image.vue'; +import mediaVideo from './media-video.vue'; +import drive from './drive.vue'; +import postPreview from './post-preview.vue'; +import subPostContent from './sub-post-content.vue'; +import postCard from './post-card.vue'; +import userCard from './user-card.vue'; +import postDetail from './post-detail.vue'; +import followButton from './follow-button.vue'; +import friendsMaker from './friends-maker.vue'; +import notification from './notification.vue'; +import notifications from './notifications.vue'; +import notificationPreview from './notification-preview.vue'; +import usersList from './users-list.vue'; +import userPreview from './user-preview.vue'; +import userTimeline from './user-timeline.vue'; +import activity from './activity.vue'; +import widgetContainer from './widget-container.vue'; + +Vue.component('mk-ui', ui); +Vue.component('mk-timeline', timeline); +Vue.component('mk-post', post); +Vue.component('mk-posts', posts); +Vue.component('mk-media-image', mediaImage); +Vue.component('mk-media-video', mediaVideo); +Vue.component('mk-drive', drive); +Vue.component('mk-post-preview', postPreview); +Vue.component('mk-sub-post-content', subPostContent); +Vue.component('mk-post-card', postCard); +Vue.component('mk-user-card', userCard); +Vue.component('mk-post-detail', postDetail); +Vue.component('mk-follow-button', followButton); +Vue.component('mk-friends-maker', friendsMaker); +Vue.component('mk-notification', notification); +Vue.component('mk-notifications', notifications); +Vue.component('mk-notification-preview', notificationPreview); +Vue.component('mk-users-list', usersList); +Vue.component('mk-user-preview', userPreview); +Vue.component('mk-user-timeline', userTimeline); +Vue.component('mk-activity', activity); +Vue.component('mk-widget-container', widgetContainer); diff --git a/src/server/web/app/mobile/views/components/media-image.vue b/src/server/web/app/mobile/views/components/media-image.vue new file mode 100644 index 0000000000..faf8bad48a --- /dev/null +++ b/src/server/web/app/mobile/views/components/media-image.vue @@ -0,0 +1,31 @@ +<template> +<a class="mk-media-image" :href="image.url" target="_blank" :style="style" :title="image.name"></a> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +export default Vue.extend({ + props: ['image'], + computed: { + style(): any { + return { + 'background-color': this.image.properties.average_color ? `rgb(${this.image.properties.average_color.join(',')})` : 'transparent', + 'background-image': `url(${this.image.url}?thumbnail&size=512)` + }; + } + } +}); +</script> + +<style lang="stylus" scoped> +.mk-media-image + display block + overflow hidden + width 100% + height 100% + background-position center + background-size cover + border-radius 4px + +</style> diff --git a/src/server/web/app/mobile/views/components/media-video.vue b/src/server/web/app/mobile/views/components/media-video.vue new file mode 100644 index 0000000000..68cd48587a --- /dev/null +++ b/src/server/web/app/mobile/views/components/media-video.vue @@ -0,0 +1,36 @@ +<template> + <a class="mk-media-video" + :href="video.url" + target="_blank" + :style="imageStyle" + :title="video.name"> + %fa:R play-circle% + </a> +</template> + +<script lang="ts"> +import Vue from 'vue' +export default Vue.extend({ + props: ['video'], + computed: { + imageStyle(): any { + return { + 'background-image': `url(${this.video.url}?thumbnail&size=512)` + }; + } + },}) +</script> + +<style lang="stylus" scoped> +.mk-media-video + display flex + justify-content center + align-items center + + font-size 3.5em + overflow hidden + background-position center + background-size cover + width 100% + height 100% +</style> diff --git a/src/server/web/app/mobile/views/components/notification-preview.vue b/src/server/web/app/mobile/views/components/notification-preview.vue new file mode 100644 index 0000000000..47df626fa8 --- /dev/null +++ b/src/server/web/app/mobile/views/components/notification-preview.vue @@ -0,0 +1,128 @@ +<template> +<div class="mk-notification-preview" :class="notification.type"> + <template v-if="notification.type == 'reaction'"> + <img class="avatar" :src="`${notification.user.avatar_url}?thumbnail&size=64`" alt="avatar"/> + <div class="text"> + <p><mk-reaction-icon :reaction="notification.reaction"/>{{ notification.user.name }}</p> + <p class="post-ref">%fa:quote-left%{{ getPostSummary(notification.post) }}%fa:quote-right%</p> + </div> + </template> + + <template v-if="notification.type == 'repost'"> + <img class="avatar" :src="`${notification.post.user.avatar_url}?thumbnail&size=64`" alt="avatar"/> + <div class="text"> + <p>%fa:retweet%{{ notification.post.user.name }}</p> + <p class="post-ref">%fa:quote-left%{{ getPostSummary(notification.post.repost) }}%fa:quote-right%</p> + </div> + </template> + + <template v-if="notification.type == 'quote'"> + <img class="avatar" :src="`${notification.post.user.avatar_url}?thumbnail&size=64`" alt="avatar"/> + <div class="text"> + <p>%fa:quote-left%{{ notification.post.user.name }}</p> + <p class="post-preview">{{ getPostSummary(notification.post) }}</p> + </div> + </template> + + <template v-if="notification.type == 'follow'"> + <img class="avatar" :src="`${notification.user.avatar_url}?thumbnail&size=64`" alt="avatar"/> + <div class="text"> + <p>%fa:user-plus%{{ notification.user.name }}</p> + </div> + </template> + + <template v-if="notification.type == 'reply'"> + <img class="avatar" :src="`${notification.post.user.avatar_url}?thumbnail&size=64`" alt="avatar"/> + <div class="text"> + <p>%fa:reply%{{ notification.post.user.name }}</p> + <p class="post-preview">{{ getPostSummary(notification.post) }}</p> + </div> + </template> + + <template v-if="notification.type == 'mention'"> + <img class="avatar" :src="`${notification.post.user.avatar_url}?thumbnail&size=64`" alt="avatar"/> + <div class="text"> + <p>%fa:at%{{ notification.post.user.name }}</p> + <p class="post-preview">{{ getPostSummary(notification.post) }}</p> + </div> + </template> + + <template v-if="notification.type == 'poll_vote'"> + <img class="avatar" :src="`${notification.user.avatar_url}?thumbnail&size=64`" alt="avatar"/> + <div class="text"> + <p>%fa:chart-pie%{{ notification.user.name }}</p> + <p class="post-ref">%fa:quote-left%{{ getPostSummary(notification.post) }}%fa:quote-right%</p> + </div> + </template> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import getPostSummary from '../../../../../common/get-post-summary'; + +export default Vue.extend({ + props: ['notification'], + data() { + return { + getPostSummary + }; + } +}); +</script> + +<style lang="stylus" scoped> +.mk-notification-preview + margin 0 + padding 8px + color #fff + overflow-wrap break-word + + &:after + content "" + display block + clear both + + img + display block + float left + min-width 36px + min-height 36px + max-width 36px + max-height 36px + border-radius 6px + + .text + float right + width calc(100% - 36px) + padding-left 8px + + p + margin 0 + + i, mk-reaction-icon + margin-right 4px + + .post-ref + + [data-fa] + font-size 1em + font-weight normal + font-style normal + display inline-block + margin-right 3px + + &.repost, &.quote + .text p i + color #77B255 + + &.follow + .text p i + color #53c7ce + + &.reply, &.mention + .text p i + color #fff + +</style> + diff --git a/src/server/web/app/mobile/views/components/notification.vue b/src/server/web/app/mobile/views/components/notification.vue new file mode 100644 index 0000000000..150ac0fd8b --- /dev/null +++ b/src/server/web/app/mobile/views/components/notification.vue @@ -0,0 +1,164 @@ +<template> +<div class="mk-notification"> + <div class="notification reaction" v-if="notification.type == 'reaction'"> + <mk-time :time="notification.created_at"/> + <router-link class="avatar-anchor" :to="`/@${acct}`"> + <img class="avatar" :src="`${notification.user.avatar_url}?thumbnail&size=64`" alt="avatar"/> + </router-link> + <div class="text"> + <p> + <mk-reaction-icon :reaction="notification.reaction"/> + <router-link :to="`/@${acct}`">{{ notification.user.name }}</router-link> + </p> + <router-link class="post-ref" :to="`/@${acct}/${notification.post.id}`"> + %fa:quote-left%{{ getPostSummary(notification.post) }} + %fa:quote-right% + </router-link> + </div> + </div> + + <div class="notification repost" v-if="notification.type == 'repost'"> + <mk-time :time="notification.created_at"/> + <router-link class="avatar-anchor" :to="`/@${acct}`"> + <img class="avatar" :src="`${notification.post.user.avatar_url}?thumbnail&size=64`" alt="avatar"/> + </router-link> + <div class="text"> + <p> + %fa:retweet% + <router-link :to="`/@${acct}`">{{ notification.post.user.name }}</router-link> + </p> + <router-link class="post-ref" :to="`/@${acct}/${notification.post.id}`"> + %fa:quote-left%{{ getPostSummary(notification.post.repost) }}%fa:quote-right% + </router-link> + </div> + </div> + + <template v-if="notification.type == 'quote'"> + <mk-post :post="notification.post"/> + </template> + + <div class="notification follow" v-if="notification.type == 'follow'"> + <mk-time :time="notification.created_at"/> + <router-link class="avatar-anchor" :to="`/@${acct}`"> + <img class="avatar" :src="`${notification.user.avatar_url}?thumbnail&size=64`" alt="avatar"/> + </router-link> + <div class="text"> + <p> + %fa:user-plus% + <router-link :to="`/@${acct}`">{{ notification.user.name }}</router-link> + </p> + </div> + </div> + + <template v-if="notification.type == 'reply'"> + <mk-post :post="notification.post"/> + </template> + + <template v-if="notification.type == 'mention'"> + <mk-post :post="notification.post"/> + </template> + + <div class="notification poll_vote" v-if="notification.type == 'poll_vote'"> + <mk-time :time="notification.created_at"/> + <router-link class="avatar-anchor" :to="`/@${acct}`"> + <img class="avatar" :src="`${notification.user.avatar_url}?thumbnail&size=64`" alt="avatar"/> + </router-link> + <div class="text"> + <p> + %fa:chart-pie% + <router-link :to="`/@${acct}`">{{ notification.user.name }}</router-link> + </p> + <router-link class="post-ref" :to="`/@${acct}/${notification.post.id}`"> + %fa:quote-left%{{ getPostSummary(notification.post) }}%fa:quote-right% + </router-link> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import getPostSummary from '../../../../../common/get-post-summary'; +import getAcct from '../../../../../common/user/get-acct'; + +export default Vue.extend({ + props: ['notification'], + computed: { + acct() { + return getAcct(this.notification.user); + } + }, + data() { + return { + getPostSummary + }; + } +}); +</script> + +<style lang="stylus" scoped> +.mk-notification + + > .notification + padding 16px + overflow-wrap break-word + + &:after + content "" + display block + clear both + + > .mk-time + display inline + position absolute + top 16px + right 12px + vertical-align top + color rgba(0, 0, 0, 0.6) + font-size 0.9em + + > .avatar-anchor + display block + float left + + img + min-width 36px + min-height 36px + max-width 36px + max-height 36px + border-radius 6px + + > .text + float right + width calc(100% - 36px) + padding-left 8px + + p + margin 0 + + i, .mk-reaction-icon + margin-right 4px + + > .post-preview + color rgba(0, 0, 0, 0.7) + + > .post-ref + color rgba(0, 0, 0, 0.7) + + [data-fa] + font-size 1em + font-weight normal + font-style normal + display inline-block + margin-right 3px + + &.repost + .text p i + color #77B255 + + &.follow + .text p i + color #53c7ce + +</style> + diff --git a/src/server/web/app/mobile/views/components/notifications.vue b/src/server/web/app/mobile/views/components/notifications.vue new file mode 100644 index 0000000000..1cd6e2bc13 --- /dev/null +++ b/src/server/web/app/mobile/views/components/notifications.vue @@ -0,0 +1,168 @@ +<template> +<div class="mk-notifications"> + <div class="notifications" v-if="notifications.length != 0"> + <template v-for="(notification, i) in _notifications"> + <mk-notification :notification="notification" :key="notification.id"/> + <p class="date" v-if="i != notifications.length - 1 && notification._date != _notifications[i + 1]._date"> + <span>%fa:angle-up%{{ notification._datetext }}</span> + <span>%fa:angle-down%{{ _notifications[i + 1]._datetext }}</span> + </p> + </template> + </div> + <button class="more" v-if="moreNotifications" @click="fetchMoreNotifications" :disabled="fetchingMoreNotifications"> + <template v-if="fetchingMoreNotifications">%fa:spinner .pulse .fw%</template> + {{ fetchingMoreNotifications ? '%i18n:common.loading%' : '%i18n:mobile.tags.mk-notifications.more%' }} + </button> + <p class="empty" v-if="notifications.length == 0 && !fetching">%i18n:mobile.tags.mk-notifications.empty%</p> + <p class="fetching" v-if="fetching">%fa:spinner .pulse .fw%%i18n:common.loading%<mk-ellipsis/></p> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + data() { + return { + fetching: true, + fetchingMoreNotifications: false, + notifications: [], + moreNotifications: false, + connection: null, + connectionId: null + }; + }, + computed: { + _notifications(): any[] { + return (this.notifications as any).map(notification => { + const date = new Date(notification.created_at).getDate(); + const month = new Date(notification.created_at).getMonth() + 1; + notification._date = date; + notification._datetext = `${month}月 ${date}日`; + return notification; + }); + } + }, + mounted() { + this.connection = (this as any).os.stream.getConnection(); + this.connectionId = (this as any).os.stream.use(); + + this.connection.on('notification', this.onNotification); + + const max = 10; + + (this as any).api('i/notifications', { + limit: max + 1 + }).then(notifications => { + if (notifications.length == max + 1) { + this.moreNotifications = true; + notifications.pop(); + } + + this.notifications = notifications; + this.fetching = false; + this.$emit('fetched'); + }); + }, + beforeDestroy() { + this.connection.off('notification', this.onNotification); + (this as any).os.stream.dispose(this.connectionId); + }, + methods: { + fetchMoreNotifications() { + this.fetchingMoreNotifications = true; + + const max = 30; + + (this as any).api('i/notifications', { + limit: max + 1, + until_id: this.notifications[this.notifications.length - 1].id + }).then(notifications => { + if (notifications.length == max + 1) { + this.moreNotifications = true; + notifications.pop(); + } else { + this.moreNotifications = false; + } + this.notifications = this.notifications.concat(notifications); + this.fetchingMoreNotifications = false; + }); + }, + onNotification(notification) { + // TODO: ユーザーが画面を見てないと思われるとき(ブラウザやタブがアクティブじゃないなど)は送信しない + this.connection.send({ + type: 'read_notification', + id: notification.id + }); + + this.notifications.unshift(notification); + } + } +}); +</script> + +<style lang="stylus" scoped> +.mk-notifications + margin 8px auto + padding 0 + max-width 500px + width calc(100% - 16px) + background #fff + border-radius 8px + box-shadow 0 0 0 1px rgba(0, 0, 0, 0.2) + + @media (min-width 500px) + margin 16px auto + width calc(100% - 32px) + + > .notifications + + > .mk-notification + margin 0 auto + max-width 500px + border-bottom solid 1px rgba(0, 0, 0, 0.05) + + &:last-child + border-bottom none + + > .date + display block + margin 0 + line-height 32px + text-align center + font-size 0.8em + color #aaa + background #fdfdfd + border-bottom solid 1px rgba(0, 0, 0, 0.05) + + span + margin 0 16px + + i + margin-right 8px + + > .more + display block + width 100% + padding 16px + color #555 + border-top solid 1px rgba(0, 0, 0, 0.05) + + > [data-fa] + margin-right 4px + + > .empty + margin 0 + padding 16px + text-align center + color #aaa + + > .fetching + margin 0 + padding 16px + text-align center + color #aaa + + > [data-fa] + margin-right 4px + +</style> diff --git a/src/server/web/app/mobile/views/components/notify.vue b/src/server/web/app/mobile/views/components/notify.vue new file mode 100644 index 0000000000..6d4a481dbe --- /dev/null +++ b/src/server/web/app/mobile/views/components/notify.vue @@ -0,0 +1,49 @@ +<template> +<div class="mk-notify"> + <mk-notification-preview :notification="notification"/> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import * as anime from 'animejs'; + +export default Vue.extend({ + props: ['notification'], + mounted() { + this.$nextTick(() => { + anime({ + targets: this.$el, + bottom: '0px', + duration: 500, + easing: 'easeOutQuad' + }); + + setTimeout(() => { + anime({ + targets: this.$el, + bottom: '-64px', + duration: 500, + easing: 'easeOutQuad', + complete: () => this.$destroy() + }); + }, 6000); + }); + } +}); +</script> + +<style lang="stylus" scoped> +.mk-notify + position fixed + z-index 1024 + bottom -64px + left 0 + width 100% + height 64px + pointer-events none + -webkit-backdrop-filter blur(2px) + backdrop-filter blur(2px) + background-color rgba(#000, 0.5) + +</style> diff --git a/src/server/web/app/mobile/views/components/post-card.vue b/src/server/web/app/mobile/views/components/post-card.vue new file mode 100644 index 0000000000..8ca7550c2e --- /dev/null +++ b/src/server/web/app/mobile/views/components/post-card.vue @@ -0,0 +1,89 @@ +<template> +<div class="mk-post-card"> + <a :href="`/@${acct}/${post.id}`"> + <header> + <img :src="`${acct}?thumbnail&size=64`" alt="avatar"/><h3>{{ post.user.name }}</h3> + </header> + <div> + {{ text }} + </div> + <mk-time :time="post.created_at"/> + </a> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import summary from '../../../../../common/get-post-summary'; +import getAcct from '../../../../../common/user/get-acct'; + +export default Vue.extend({ + props: ['post'], + computed: { + acct() { + return getAcct(this.post.user); + }, + text(): string { + return summary(this.post); + } + } +}); +</script> + +<style lang="stylus" scoped> +.mk-post-card + display inline-block + width 150px + //height 120px + font-size 12px + background #fff + border-radius 4px + + > a + display block + color #2c3940 + + &:hover + text-decoration none + + > header + > img + position absolute + top 8px + left 8px + width 28px + height 28px + border-radius 6px + + > h3 + display inline-block + overflow hidden + width calc(100% - 45px) + margin 8px 0 0 42px + line-height 28px + white-space nowrap + text-overflow ellipsis + font-size 12px + + > div + padding 2px 8px 8px 8px + height 60px + overflow hidden + white-space normal + + &:after + content "" + display block + position absolute + top 40px + left 0 + width 100% + height 20px + background linear-gradient(to bottom, rgba(255, 255, 255, 0) 0%, #fff 100%) + + > .mk-time + display inline-block + padding 8px + color #aaa + +</style> diff --git a/src/server/web/app/mobile/views/components/post-detail.sub.vue b/src/server/web/app/mobile/views/components/post-detail.sub.vue new file mode 100644 index 0000000000..6906cf570e --- /dev/null +++ b/src/server/web/app/mobile/views/components/post-detail.sub.vue @@ -0,0 +1,109 @@ +<template> +<div class="root sub"> + <router-link class="avatar-anchor" :to="`/@${acct}`"> + <img class="avatar" :src="`${post.user.avatar_url}?thumbnail&size=64`" alt="avatar"/> + </router-link> + <div class="main"> + <header> + <router-link class="name" :to="`/@${acct}`">{{ post.user.name }}</router-link> + <span class="username">@{{ acct }}</span> + <router-link class="time" :to="`/@${acct}/${post.id}`"> + <mk-time :time="post.created_at"/> + </router-link> + </header> + <div class="body"> + <mk-sub-post-content class="text" :post="post"/> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import getAcct from '../../../../../common/user/get-acct'; + +export default Vue.extend({ + props: ['post'], + computed: { + acct() { + return getAcct(this.post.user); + } + } +}); +</script> + +<style lang="stylus" scoped> +.root.sub + padding 8px + font-size 0.9em + background #fdfdfd + + @media (min-width 500px) + padding 12px + + &:after + content "" + display block + clear both + + &:hover + > .main > footer > button + color #888 + + > .avatar-anchor + display block + float left + margin 0 12px 0 0 + + > .avatar + display block + width 48px + height 48px + margin 0 + border-radius 8px + vertical-align bottom + + > .main + float left + width calc(100% - 60px) + + > header + display flex + margin-bottom 4px + white-space nowrap + + > .name + display block + margin 0 .5em 0 0 + padding 0 + overflow hidden + color #607073 + font-size 1em + font-weight 700 + text-align left + text-decoration none + text-overflow ellipsis + + &:hover + text-decoration underline + + > .username + text-align left + margin 0 .5em 0 0 + color #d1d8da + + > .time + margin-left auto + color #b2b8bb + + > .body + + > .text + cursor default + margin 0 + padding 0 + font-size 1.1em + color #717171 + +</style> + diff --git a/src/server/web/app/mobile/views/components/post-detail.vue b/src/server/web/app/mobile/views/components/post-detail.vue new file mode 100644 index 0000000000..b5c9158300 --- /dev/null +++ b/src/server/web/app/mobile/views/components/post-detail.vue @@ -0,0 +1,447 @@ +<template> +<div class="mk-post-detail"> + <button + class="more" + v-if="p.reply && p.reply.reply_id && context == null" + @click="fetchContext" + :disabled="fetchingContext" + > + <template v-if="!contextFetching">%fa:ellipsis-v%</template> + <template v-if="contextFetching">%fa:spinner .pulse%</template> + </button> + <div class="context"> + <x-sub v-for="post in context" :key="post.id" :post="post"/> + </div> + <div class="reply-to" v-if="p.reply"> + <x-sub :post="p.reply"/> + </div> + <div class="repost" v-if="isRepost"> + <p> + <router-link class="avatar-anchor" :to="`/@${acct}`"> + <img class="avatar" :src="`${post.user.avatar_url}?thumbnail&size=32`" alt="avatar"/> + </router-link> + %fa:retweet% + <router-link class="name" :to="`/@${acct}`"> + {{ post.user.name }} + </router-link> + がRepost + </p> + </div> + <article> + <header> + <router-link class="avatar-anchor" :to="`/@${pAcct}`"> + <img class="avatar" :src="`${p.user.avatar_url}?thumbnail&size=64`" alt="avatar"/> + </router-link> + <div> + <router-link class="name" :to="`/@${pAcct}`">{{ p.user.name }}</router-link> + <span class="username">@{{ pAcct }}</span> + </div> + </header> + <div class="body"> + <mk-post-html v-if="p.ast" :ast="p.ast" :i="os.i" :class="$style.text"/> + <div class="tags" v-if="p.tags && p.tags.length > 0"> + <router-link v-for="tag in p.tags" :key="tag" :to="`/search?q=#${tag}`">{{ tag }}</router-link> + </div> + <div class="media" v-if="p.media"> + <mk-media-list :media-list="p.media"/> + </div> + <mk-poll v-if="p.poll" :post="p"/> + <mk-url-preview v-for="url in urls" :url="url" :key="url"/> + <a class="location" v-if="p.geo" :href="`http://maps.google.com/maps?q=${p.geo.latitude},${p.geo.longitude}`" target="_blank">%fa:map-marker-alt% 位置情報</a> + <div class="map" v-if="p.geo" ref="map"></div> + <div class="repost" v-if="p.repost"> + <mk-post-preview :post="p.repost"/> + </div> + </div> + <router-link class="time" :to="`/@${pAcct}/${p.id}`"> + <mk-time :time="p.created_at" mode="detail"/> + </router-link> + <footer> + <mk-reactions-viewer :post="p"/> + <button @click="reply" title="%i18n:mobile.tags.mk-post-detail.reply%"> + %fa:reply%<p class="count" v-if="p.replies_count > 0">{{ p.replies_count }}</p> + </button> + <button @click="repost" title="Repost"> + %fa:retweet%<p class="count" v-if="p.repost_count > 0">{{ p.repost_count }}</p> + </button> + <button :class="{ reacted: p.my_reaction != null }" @click="react" ref="reactButton" title="%i18n:mobile.tags.mk-post-detail.reaction%"> + %fa:plus%<p class="count" v-if="p.reactions_count > 0">{{ p.reactions_count }}</p> + </button> + <button @click="menu" ref="menuButton"> + %fa:ellipsis-h% + </button> + </footer> + </article> + <div class="replies" v-if="!compact"> + <x-sub v-for="post in replies" :key="post.id" :post="post"/> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import getAcct from '../../../../../common/user/get-acct'; +import MkPostMenu from '../../../common/views/components/post-menu.vue'; +import MkReactionPicker from '../../../common/views/components/reaction-picker.vue'; +import XSub from './post-detail.sub.vue'; + +export default Vue.extend({ + components: { + XSub + }, + props: { + post: { + type: Object, + required: true + }, + compact: { + default: false + } + }, + data() { + return { + context: [], + contextFetching: false, + replies: [], + }; + }, + computed: { + acct() { + return getAcct(this.post.user); + }, + pAcct() { + return getAcct(this.p.user); + }, + isRepost(): boolean { + return (this.post.repost && + this.post.text == null && + this.post.media_ids == null && + this.post.poll == null); + }, + p(): any { + return this.isRepost ? this.post.repost : this.post; + }, + reactionsCount(): number { + return this.p.reaction_counts + ? Object.keys(this.p.reaction_counts) + .map(key => this.p.reaction_counts[key]) + .reduce((a, b) => a + b) + : 0; + }, + urls(): string[] { + if (this.p.ast) { + return this.p.ast + .filter(t => (t.type == 'url' || t.type == 'link') && !t.silent) + .map(t => t.url); + } else { + return null; + } + } + }, + mounted() { + // Get replies + if (!this.compact) { + (this as any).api('posts/replies', { + post_id: this.p.id, + limit: 8 + }).then(replies => { + this.replies = replies; + }); + } + + // Draw map + if (this.p.geo) { + const shouldShowMap = (this as any).os.isSignedIn ? (this as any).os.i.account.client_settings.showMaps : true; + if (shouldShowMap) { + (this as any).os.getGoogleMaps().then(maps => { + const uluru = new maps.LatLng(this.p.geo.latitude, this.p.geo.longitude); + const map = new maps.Map(this.$refs.map, { + center: uluru, + zoom: 15 + }); + new maps.Marker({ + position: uluru, + map: map + }); + }); + } + } + }, + methods: { + fetchContext() { + this.contextFetching = true; + + // Fetch context + (this as any).api('posts/context', { + post_id: this.p.reply_id + }).then(context => { + this.contextFetching = false; + this.context = context.reverse(); + }); + }, + reply() { + (this as any).apis.post({ + reply: this.p + }); + }, + repost() { + (this as any).apis.post({ + repost: this.p + }); + }, + react() { + (this as any).os.new(MkReactionPicker, { + source: this.$refs.reactButton, + post: this.p, + compact: true + }); + }, + menu() { + (this as any).os.new(MkPostMenu, { + source: this.$refs.menuButton, + post: this.p, + compact: true + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +.mk-post-detail + overflow hidden + margin 0 auto + padding 0 + width 100% + text-align left + background #fff + border-radius 8px + box-shadow 0 0 0 1px rgba(0, 0, 0, 0.2) + + > .fetching + padding 64px 0 + + > .more + display block + margin 0 + padding 10px 0 + width 100% + font-size 1em + text-align center + color #999 + cursor pointer + background #fafafa + outline none + border none + border-bottom solid 1px #eef0f2 + border-radius 6px 6px 0 0 + box-shadow none + + &:hover + background #f6f6f6 + + &:active + background #f0f0f0 + + &:disabled + color #ccc + + > .context + > * + border-bottom 1px solid #eef0f2 + + > .repost + color #9dbb00 + background linear-gradient(to bottom, #edfde2 0%, #fff 100%) + + > p + margin 0 + padding 16px 32px + + .avatar-anchor + display inline-block + + .avatar + vertical-align bottom + min-width 28px + min-height 28px + max-width 28px + max-height 28px + margin 0 8px 0 0 + border-radius 6px + + [data-fa] + margin-right 4px + + .name + font-weight bold + + & + article + padding-top 8px + + > .reply-to + border-bottom 1px solid #eef0f2 + + > article + padding 14px 16px 9px 16px + + @media (min-width 500px) + padding 28px 32px 18px 32px + + &:after + content "" + display block + clear both + + &:hover + > .main > footer > button + color #888 + + > header + display flex + line-height 1.1 + + > .avatar-anchor + display block + padding 0 .5em 0 0 + + > .avatar + display block + width 54px + height 54px + margin 0 + border-radius 8px + vertical-align bottom + + @media (min-width 500px) + width 60px + height 60px + + > div + + > .name + display inline-block + margin .4em 0 + color #777 + font-size 16px + font-weight bold + text-align left + text-decoration none + + &:hover + text-decoration underline + + > .username + display block + text-align left + margin 0 + color #ccc + + > .body + padding 8px 0 + + > .repost + margin 8px 0 + + > .mk-post-preview + padding 16px + border dashed 1px #c0dac6 + border-radius 8px + + > .location + margin 4px 0 + font-size 12px + color #ccc + + > .map + width 100% + height 200px + + &:empty + display none + + > .mk-url-preview + margin-top 8px + + > .media + > img + display block + max-width 100% + + > .tags + margin 4px 0 0 0 + + > * + display inline-block + margin 0 8px 0 0 + padding 2px 8px 2px 16px + font-size 90% + color #8d969e + background #edf0f3 + border-radius 4px + + &:before + content "" + display block + position absolute + top 0 + bottom 0 + left 4px + width 8px + height 8px + margin auto 0 + background #fff + border-radius 100% + + > .time + font-size 16px + color #c0c0c0 + + > footer + font-size 1.2em + + > button + margin 0 + padding 8px + background transparent + border none + box-shadow none + font-size 1em + color #ddd + cursor pointer + + &:not(:last-child) + margin-right 28px + + &:hover + color #666 + + > .count + display inline + margin 0 0 0 8px + color #999 + + &.reacted + color $theme-color + + > .replies + > * + border-top 1px solid #eef0f2 + +</style> + +<style lang="stylus" module> +.text + display block + margin 0 + padding 0 + overflow-wrap break-word + font-size 16px + color #717171 + + @media (min-width 500px) + font-size 24px + +</style> diff --git a/src/server/web/app/mobile/views/components/post-form.vue b/src/server/web/app/mobile/views/components/post-form.vue new file mode 100644 index 0000000000..2aa3c6f6c0 --- /dev/null +++ b/src/server/web/app/mobile/views/components/post-form.vue @@ -0,0 +1,276 @@ +<template> +<div class="mk-post-form"> + <header> + <button class="cancel" @click="cancel">%fa:times%</button> + <div> + <span class="text-count" :class="{ over: text.length > 1000 }">{{ 1000 - text.length }}</span> + <span class="geo" v-if="geo">%fa:map-marker-alt%</span> + <button class="submit" :disabled="posting" @click="post">{{ reply ? '返信' : '%i18n:mobile.tags.mk-post-form.submit%' }}</button> + </div> + </header> + <div class="form"> + <mk-post-preview v-if="reply" :post="reply"/> + <textarea v-model="text" ref="text" :disabled="posting" :placeholder="reply ? '%i18n:mobile.tags.mk-post-form.reply-placeholder%' : '%i18n:mobile.tags.mk-post-form.post-placeholder%'"></textarea> + <div class="attaches" v-show="files.length != 0"> + <x-draggable class="files" :list="files" :options="{ animation: 150 }"> + <div class="file" v-for="file in files" :key="file.id"> + <div class="img" :style="`background-image: url(${file.url}?thumbnail&size=128)`" @click="detachMedia(file)"></div> + </div> + </x-draggable> + </div> + <mk-poll-editor v-if="poll" ref="poll" @destroyed="poll = false"/> + <mk-uploader ref="uploader" @uploaded="attachMedia" @change="onChangeUploadings"/> + <button class="upload" @click="chooseFile">%fa:upload%</button> + <button class="drive" @click="chooseFileFromDrive">%fa:cloud%</button> + <button class="kao" @click="kao">%fa:R smile%</button> + <button class="poll" @click="poll = true">%fa:chart-pie%</button> + <button class="geo" @click="geo ? removeGeo() : setGeo()">%fa:map-marker-alt%</button> + <input ref="file" class="file" type="file" accept="image/*" multiple="multiple" @change="onChangeFile"/> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import * as XDraggable from 'vuedraggable'; +import getKao from '../../../common/scripts/get-kao'; + +export default Vue.extend({ + components: { + XDraggable + }, + props: ['reply'], + data() { + return { + posting: false, + text: '', + uploadings: [], + files: [], + poll: false, + geo: null + }; + }, + mounted() { + this.$nextTick(() => { + this.focus(); + }); + }, + methods: { + focus() { + (this.$refs.text as any).focus(); + }, + chooseFile() { + (this.$refs.file as any).click(); + }, + chooseFileFromDrive() { + (this as any).apis.chooseDriveFile({ + multiple: true + }).then(files => { + files.forEach(this.attachMedia); + }); + }, + attachMedia(driveFile) { + this.files.push(driveFile); + this.$emit('change-attached-media', this.files); + }, + detachMedia(file) { + this.files = this.files.filter(x => x.id != file.id); + this.$emit('change-attached-media', this.files); + }, + onChangeFile() { + Array.from((this.$refs.file as any).files).forEach(this.upload); + }, + upload(file) { + (this.$refs.uploader as any).upload(file); + }, + onChangeUploadings(uploads) { + this.$emit('change-uploadings', uploads); + }, + setGeo() { + if (navigator.geolocation == null) { + alert('お使いの端末は位置情報に対応していません'); + return; + } + + navigator.geolocation.getCurrentPosition(pos => { + this.geo = pos.coords; + }, err => { + alert('エラー: ' + err.message); + }, { + enableHighAccuracy: true + }); + }, + removeGeo() { + this.geo = null; + }, + clear() { + this.text = ''; + this.files = []; + this.poll = false; + this.$emit('change-attached-media'); + }, + post() { + this.posting = true; + const viaMobile = (this as any).os.i.account.client_settings.disableViaMobile !== true; + (this as any).api('posts/create', { + text: this.text == '' ? undefined : this.text, + media_ids: this.files.length > 0 ? this.files.map(f => f.id) : undefined, + reply_id: this.reply ? this.reply.id : undefined, + poll: this.poll ? (this.$refs.poll as any).get() : undefined, + geo: this.geo ? { + latitude: this.geo.latitude, + longitude: this.geo.longitude, + altitude: this.geo.altitude, + accuracy: this.geo.accuracy, + altitudeAccuracy: this.geo.altitudeAccuracy, + heading: isNaN(this.geo.heading) ? null : this.geo.heading, + speed: this.geo.speed, + } : null, + via_mobile: viaMobile + }).then(data => { + this.$emit('post'); + this.$destroy(); + }).catch(err => { + this.posting = false; + }); + }, + cancel() { + this.$emit('cancel'); + this.$destroy(); + }, + kao() { + this.text += getKao(); + } + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +.mk-post-form + max-width 500px + width calc(100% - 16px) + margin 8px auto + background #fff + border-radius 8px + box-shadow 0 0 0 1px rgba(0, 0, 0, 0.2) + + @media (min-width 500px) + margin 16px auto + width calc(100% - 32px) + + > header + z-index 1 + height 50px + box-shadow 0 1px 0 0 rgba(0, 0, 0, 0.1) + + > .cancel + padding 0 + width 50px + line-height 50px + font-size 24px + color #555 + + > div + position absolute + top 0 + right 0 + color #657786 + + > .text-count + line-height 50px + + > .geo + margin 0 8px + line-height 50px + + > .submit + margin 8px + padding 0 16px + line-height 34px + vertical-align bottom + color $theme-color-foreground + background $theme-color + border-radius 4px + + &:disabled + opacity 0.7 + + > .form + max-width 500px + margin 0 auto + + > .mk-post-preview + padding 16px + + > .attaches + + > .files + display block + margin 0 + padding 4px + list-style none + + &:after + content "" + display block + clear both + + > .file + display block + float left + margin 0 + padding 0 + border solid 4px transparent + + > .img + width 64px + height 64px + background-size cover + background-position center center + + > .mk-uploader + margin 8px 0 0 0 + padding 8px + + > .file + display none + + > textarea + display block + padding 12px + margin 0 + width 100% + max-width 100% + min-width 100% + min-height 80px + font-size 16px + color #333 + border none + border-bottom solid 1px #ddd + border-radius 0 + + &:disabled + opacity 0.5 + + > .upload + > .drive + > .kao + > .poll + > .geo + display inline-block + padding 0 + margin 0 + width 48px + height 48px + font-size 20px + color #657786 + background transparent + outline none + border none + border-radius 0 + box-shadow none + +</style> + diff --git a/src/server/web/app/mobile/views/components/post-preview.vue b/src/server/web/app/mobile/views/components/post-preview.vue new file mode 100644 index 0000000000..0bd0a355b3 --- /dev/null +++ b/src/server/web/app/mobile/views/components/post-preview.vue @@ -0,0 +1,106 @@ +<template> +<div class="mk-post-preview"> + <router-link class="avatar-anchor" :to="`/@${acct}`"> + <img class="avatar" :src="`${post.user.avatar_url}?thumbnail&size=64`" alt="avatar"/> + </router-link> + <div class="main"> + <header> + <router-link class="name" :to="`/@${acct}`">{{ post.user.name }}</router-link> + <span class="username">@{{ acct }}</span> + <router-link class="time" :to="`/@${acct}/${post.id}`"> + <mk-time :time="post.created_at"/> + </router-link> + </header> + <div class="body"> + <mk-sub-post-content class="text" :post="post"/> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import getAcct from '../../../../../common/user/get-acct'; + +export default Vue.extend({ + props: ['post'], + computed: { + acct() { + return getAcct(this.post.user); + } + } +}); +</script> + +<style lang="stylus" scoped> +.mk-post-preview + margin 0 + padding 0 + font-size 0.9em + background #fff + + &:after + content "" + display block + clear both + + &:hover + > .main > footer > button + color #888 + + > .avatar-anchor + display block + float left + margin 0 12px 0 0 + + > .avatar + display block + width 48px + height 48px + margin 0 + border-radius 8px + vertical-align bottom + + > .main + float left + width calc(100% - 60px) + + > header + display flex + margin-bottom 4px + white-space nowrap + + > .name + display block + margin 0 .5em 0 0 + padding 0 + overflow hidden + color #607073 + font-size 1em + font-weight 700 + text-align left + text-decoration none + text-overflow ellipsis + + &:hover + text-decoration underline + + > .username + text-align left + margin 0 .5em 0 0 + color #d1d8da + + > .time + margin-left auto + color #b2b8bb + + > .body + + > .text + cursor default + margin 0 + padding 0 + font-size 1.1em + color #717171 + +</style> diff --git a/src/server/web/app/mobile/views/components/post.sub.vue b/src/server/web/app/mobile/views/components/post.sub.vue new file mode 100644 index 0000000000..b6ee7c1e08 --- /dev/null +++ b/src/server/web/app/mobile/views/components/post.sub.vue @@ -0,0 +1,115 @@ +<template> +<div class="sub"> + <router-link class="avatar-anchor" :to="`/@${acct}`"> + <img class="avatar" :src="`${post.user.avatar_url}?thumbnail&size=96`" alt="avatar"/> + </router-link> + <div class="main"> + <header> + <router-link class="name" :to="`/@${acct}`">{{ post.user.name }}</router-link> + <span class="username">@{{ acct }}</span> + <router-link class="created-at" :to="`/@${acct}/${post.id}`"> + <mk-time :time="post.created_at"/> + </router-link> + </header> + <div class="body"> + <mk-sub-post-content class="text" :post="post"/> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import getAcct from '../../../../../common/user/get-acct'; + +export default Vue.extend({ + props: ['post'], + computed: { + acct() { + return getAcct(this.post.user); + } + } +}); +</script> + +<style lang="stylus" scoped> +.sub + font-size 0.9em + padding 16px + + &:after + content "" + display block + clear both + + > .avatar-anchor + display block + float left + margin 0 10px 0 0 + + @media (min-width 500px) + margin-right 16px + + > .avatar + display block + width 44px + height 44px + margin 0 + border-radius 8px + vertical-align bottom + + @media (min-width 500px) + width 52px + height 52px + + > .main + float left + width calc(100% - 54px) + + @media (min-width 500px) + width calc(100% - 68px) + + > header + display flex + margin-bottom 2px + white-space nowrap + + > .name + display block + margin 0 0.5em 0 0 + padding 0 + overflow hidden + color #607073 + font-size 1em + font-weight 700 + text-align left + text-decoration none + text-overflow ellipsis + + &:hover + text-decoration underline + + > .username + text-align left + margin 0 + color #d1d8da + + > .created-at + margin-left auto + color #b2b8bb + + > .body + + > .text + cursor default + margin 0 + padding 0 + font-size 1.1em + color #717171 + + pre + max-height 120px + font-size 80% + +</style> + diff --git a/src/server/web/app/mobile/views/components/post.vue b/src/server/web/app/mobile/views/components/post.vue new file mode 100644 index 0000000000..e5bc964792 --- /dev/null +++ b/src/server/web/app/mobile/views/components/post.vue @@ -0,0 +1,523 @@ +<template> +<div class="post" :class="{ repost: isRepost }"> + <div class="reply-to" v-if="p.reply"> + <x-sub :post="p.reply"/> + </div> + <div class="repost" v-if="isRepost"> + <p> + <router-link class="avatar-anchor" :to="`/@${acct}`"> + <img class="avatar" :src="`${post.user.avatar_url}?thumbnail&size=64`" alt="avatar"/> + </router-link> + %fa:retweet% + <span>{{ '%i18n:mobile.tags.mk-timeline-post.reposted-by%'.substr(0, '%i18n:mobile.tags.mk-timeline-post.reposted-by%'.indexOf('{')) }}</span> + <router-link class="name" :to="`/@${acct}`">{{ post.user.name }}</router-link> + <span>{{ '%i18n:mobile.tags.mk-timeline-post.reposted-by%'.substr('%i18n:mobile.tags.mk-timeline-post.reposted-by%'.indexOf('}') + 1) }}</span> + </p> + <mk-time :time="post.created_at"/> + </div> + <article> + <router-link class="avatar-anchor" :to="`/@${pAcct}`"> + <img class="avatar" :src="`${p.user.avatar_url}?thumbnail&size=96`" alt="avatar"/> + </router-link> + <div class="main"> + <header> + <router-link class="name" :to="`/@${pAcct}`">{{ p.user.name }}</router-link> + <span class="is-bot" v-if="p.user.host === null && p.user.account.is_bot">bot</span> + <span class="username">@{{ pAcct }}</span> + <div class="info"> + <span class="mobile" v-if="p.via_mobile">%fa:mobile-alt%</span> + <router-link class="created-at" :to="url"> + <mk-time :time="p.created_at"/> + </router-link> + </div> + </header> + <div class="body"> + <p class="channel" v-if="p.channel != null"><a target="_blank">{{ p.channel.title }}</a>:</p> + <div class="text"> + <a class="reply" v-if="p.reply"> + %fa:reply% + </a> + <mk-post-html v-if="p.ast" :ast="p.ast" :i="os.i" :class="$style.text"/> + <a class="rp" v-if="p.repost != null">RP:</a> + </div> + <div class="media" v-if="p.media"> + <mk-media-list :media-list="p.media"/> + </div> + <mk-poll v-if="p.poll" :post="p" ref="pollViewer"/> + <div class="tags" v-if="p.tags && p.tags.length > 0"> + <router-link v-for="tag in p.tags" :key="tag" :to="`/search?q=#${tag}`">{{ tag }}</router-link> + </div> + <mk-url-preview v-for="url in urls" :url="url" :key="url"/> + <a class="location" v-if="p.geo" :href="`http://maps.google.com/maps?q=${p.geo.latitude},${p.geo.longitude}`" target="_blank">%fa:map-marker-alt% 位置情報</a> + <div class="map" v-if="p.geo" ref="map"></div> + <span class="app" v-if="p.app">via <b>{{ p.app.name }}</b></span> + <div class="repost" v-if="p.repost"> + <mk-post-preview :post="p.repost"/> + </div> + </div> + <footer> + <mk-reactions-viewer :post="p" ref="reactionsViewer"/> + <button @click="reply"> + %fa:reply%<p class="count" v-if="p.replies_count > 0">{{ p.replies_count }}</p> + </button> + <button @click="repost" title="Repost"> + %fa:retweet%<p class="count" v-if="p.repost_count > 0">{{ p.repost_count }}</p> + </button> + <button :class="{ reacted: p.my_reaction != null }" @click="react" ref="reactButton"> + %fa:plus%<p class="count" v-if="p.reactions_count > 0">{{ p.reactions_count }}</p> + </button> + <button class="menu" @click="menu" ref="menuButton"> + %fa:ellipsis-h% + </button> + </footer> + </div> + </article> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import getAcct from '../../../../../common/user/get-acct'; +import MkPostMenu from '../../../common/views/components/post-menu.vue'; +import MkReactionPicker from '../../../common/views/components/reaction-picker.vue'; +import XSub from './post.sub.vue'; + +export default Vue.extend({ + components: { + XSub + }, + props: ['post'], + data() { + return { + connection: null, + connectionId: null + }; + }, + computed: { + acct() { + return getAcct(this.post.user); + }, + pAcct() { + return getAcct(this.p.user); + }, + isRepost(): boolean { + return (this.post.repost && + this.post.text == null && + this.post.media_ids == null && + this.post.poll == null); + }, + p(): any { + return this.isRepost ? this.post.repost : this.post; + }, + reactionsCount(): number { + return this.p.reaction_counts + ? Object.keys(this.p.reaction_counts) + .map(key => this.p.reaction_counts[key]) + .reduce((a, b) => a + b) + : 0; + }, + url(): string { + return `/@${this.pAcct}/${this.p.id}`; + }, + urls(): string[] { + if (this.p.ast) { + return this.p.ast + .filter(t => (t.type == 'url' || t.type == 'link') && !t.silent) + .map(t => t.url); + } else { + return null; + } + } + }, + created() { + if ((this as any).os.isSignedIn) { + this.connection = (this as any).os.stream.getConnection(); + this.connectionId = (this as any).os.stream.use(); + } + }, + mounted() { + this.capture(true); + + if ((this as any).os.isSignedIn) { + this.connection.on('_connected_', this.onStreamConnected); + } + + // Draw map + if (this.p.geo) { + const shouldShowMap = (this as any).os.isSignedIn ? (this as any).os.i.account.client_settings.showMaps : true; + if (shouldShowMap) { + (this as any).os.getGoogleMaps().then(maps => { + const uluru = new maps.LatLng(this.p.geo.latitude, this.p.geo.longitude); + const map = new maps.Map(this.$refs.map, { + center: uluru, + zoom: 15 + }); + new maps.Marker({ + position: uluru, + map: map + }); + }); + } + } + }, + beforeDestroy() { + this.decapture(true); + + if ((this as any).os.isSignedIn) { + this.connection.off('_connected_', this.onStreamConnected); + (this as any).os.stream.dispose(this.connectionId); + } + }, + methods: { + capture(withHandler = false) { + if ((this as any).os.isSignedIn) { + this.connection.send({ + type: 'capture', + id: this.p.id + }); + if (withHandler) this.connection.on('post-updated', this.onStreamPostUpdated); + } + }, + decapture(withHandler = false) { + if ((this as any).os.isSignedIn) { + this.connection.send({ + type: 'decapture', + id: this.p.id + }); + if (withHandler) this.connection.off('post-updated', this.onStreamPostUpdated); + } + }, + onStreamConnected() { + this.capture(); + }, + onStreamPostUpdated(data) { + const post = data.post; + if (post.id == this.post.id) { + this.$emit('update:post', post); + } else if (post.id == this.post.repost_id) { + this.post.repost = post; + } + }, + reply() { + (this as any).apis.post({ + reply: this.p + }); + }, + repost() { + (this as any).apis.post({ + repost: this.p + }); + }, + react() { + (this as any).os.new(MkReactionPicker, { + source: this.$refs.reactButton, + post: this.p, + compact: true + }); + }, + menu() { + (this as any).os.new(MkPostMenu, { + source: this.$refs.menuButton, + post: this.p, + compact: true + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +.post + font-size 12px + border-bottom solid 1px #eaeaea + + &:first-child + border-radius 8px 8px 0 0 + + > .repost + border-radius 8px 8px 0 0 + + &:last-of-type + border-bottom none + + @media (min-width 350px) + font-size 14px + + @media (min-width 500px) + font-size 16px + + > .repost + color #9dbb00 + background linear-gradient(to bottom, #edfde2 0%, #fff 100%) + + > p + margin 0 + padding 8px 16px + line-height 28px + + @media (min-width 500px) + padding 16px + + .avatar-anchor + display inline-block + + .avatar + vertical-align bottom + width 28px + height 28px + margin 0 8px 0 0 + border-radius 6px + + [data-fa] + margin-right 4px + + .name + font-weight bold + + > .mk-time + position absolute + top 8px + right 16px + font-size 0.9em + line-height 28px + + @media (min-width 500px) + top 16px + + & + article + padding-top 8px + + > .reply-to + background rgba(0, 0, 0, 0.0125) + + > .mk-post-preview + background transparent + + > article + padding 14px 16px 9px 16px + + &:after + content "" + display block + clear both + + > .avatar-anchor + display block + float left + margin 0 10px 8px 0 + position -webkit-sticky + position sticky + top 62px + + @media (min-width 500px) + margin-right 16px + + > .avatar + display block + width 48px + height 48px + margin 0 + border-radius 6px + vertical-align bottom + + @media (min-width 500px) + width 58px + height 58px + border-radius 8px + + > .main + float left + width calc(100% - 58px) + + @media (min-width 500px) + width calc(100% - 74px) + + > header + display flex + align-items center + white-space nowrap + + @media (min-width 500px) + margin-bottom 2px + + > .name + display block + margin 0 0.5em 0 0 + padding 0 + overflow hidden + color #627079 + font-size 1em + font-weight bold + text-decoration none + text-overflow ellipsis + + &:hover + text-decoration underline + + > .is-bot + margin 0 0.5em 0 0 + padding 1px 6px + font-size 12px + color #aaa + border solid 1px #ddd + border-radius 3px + + > .username + margin 0 0.5em 0 0 + color #ccc + + > .info + margin-left auto + font-size 0.9em + + > .mobile + margin-right 6px + color #c0c0c0 + + > .created-at + color #c0c0c0 + + > .body + + > .text + display block + margin 0 + padding 0 + overflow-wrap break-word + font-size 1.1em + color #717171 + + >>> .quote + margin 8px + padding 6px 12px + color #aaa + border-left solid 3px #eee + + > .reply + margin-right 8px + color #717171 + + > .rp + margin-left 4px + font-style oblique + color #a0bf46 + + [data-is-me]:after + content "you" + padding 0 4px + margin-left 4px + font-size 80% + color $theme-color-foreground + background $theme-color + border-radius 4px + + .mk-url-preview + margin-top 8px + + > .channel + margin 0 + + > .tags + margin 4px 0 0 0 + + > * + display inline-block + margin 0 8px 0 0 + padding 2px 8px 2px 16px + font-size 90% + color #8d969e + background #edf0f3 + border-radius 4px + + &:before + content "" + display block + position absolute + top 0 + bottom 0 + left 4px + width 8px + height 8px + margin auto 0 + background #fff + border-radius 100% + + > .media + > img + display block + max-width 100% + + > .location + margin 4px 0 + font-size 12px + color #ccc + + > .map + width 100% + height 200px + + &:empty + display none + + > .app + font-size 12px + color #ccc + + > .mk-poll + font-size 80% + + > .repost + margin 8px 0 + + > .mk-post-preview + padding 16px + border dashed 1px #c0dac6 + border-radius 8px + + > footer + > button + margin 0 + padding 8px + background transparent + border none + box-shadow none + font-size 1em + color #ddd + cursor pointer + + &:not(:last-child) + margin-right 28px + + &:hover + color #666 + + > .count + display inline + margin 0 0 0 8px + color #999 + + &.reacted + color $theme-color + + &.menu + @media (max-width 350px) + display none + +</style> + +<style lang="stylus" module> +.text + code + padding 4px 8px + margin 0 0.5em + font-size 80% + color #525252 + background #f8f8f8 + border-radius 2px + + pre > code + padding 16px + margin 0 +</style> diff --git a/src/server/web/app/mobile/views/components/posts.vue b/src/server/web/app/mobile/views/components/posts.vue new file mode 100644 index 0000000000..7e71fa0982 --- /dev/null +++ b/src/server/web/app/mobile/views/components/posts.vue @@ -0,0 +1,111 @@ +<template> +<div class="mk-posts"> + <slot name="head"></slot> + <slot></slot> + <template v-for="(post, i) in _posts"> + <mk-post :post="post" :key="post.id" @update:post="onPostUpdated(i, $event)"/> + <p class="date" v-if="i != posts.length - 1 && post._date != _posts[i + 1]._date"> + <span>%fa:angle-up%{{ post._datetext }}</span> + <span>%fa:angle-down%{{ _posts[i + 1]._datetext }}</span> + </p> + </template> + <footer> + <slot name="tail"></slot> + </footer> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +export default Vue.extend({ + props: { + posts: { + type: Array, + default: () => [] + } + }, + computed: { + _posts(): any[] { + return (this.posts as any).map(post => { + const date = new Date(post.created_at).getDate(); + const month = new Date(post.created_at).getMonth() + 1; + post._date = date; + post._datetext = `${month}月 ${date}日`; + return post; + }); + } + }, + methods: { + onPostUpdated(i, post) { + Vue.set((this as any).posts, i, post); + } + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +.mk-posts + background #fff + border-radius 8px + box-shadow 0 0 0 1px rgba(0, 0, 0, 0.2) + + > .init + padding 64px 0 + text-align center + color #999 + + > [data-fa] + margin-right 4px + + > .empty + margin 0 auto + padding 32px + max-width 400px + text-align center + color #999 + + > [data-fa] + display block + margin-bottom 16px + font-size 3em + color #ccc + + > .date + display block + margin 0 + line-height 32px + text-align center + font-size 0.9em + color #aaa + background #fdfdfd + border-bottom solid 1px #eaeaea + + span + margin 0 16px + + [data-fa] + margin-right 8px + + > footer + text-align center + border-top solid 1px #eaeaea + border-bottom-left-radius 4px + border-bottom-right-radius 4px + + &:empty + display none + + > button + margin 0 + padding 16px + width 100% + color $theme-color + border-radius 0 0 8px 8px + + &:disabled + opacity 0.7 + +</style> diff --git a/src/server/web/app/mobile/views/components/sub-post-content.vue b/src/server/web/app/mobile/views/components/sub-post-content.vue new file mode 100644 index 0000000000..389fc420ea --- /dev/null +++ b/src/server/web/app/mobile/views/components/sub-post-content.vue @@ -0,0 +1,43 @@ +<template> +<div class="mk-sub-post-content"> + <div class="body"> + <a class="reply" v-if="post.reply_id">%fa:reply%</a> + <mk-post-html v-if="post.ast" :ast="post.ast" :i="os.i"/> + <a class="rp" v-if="post.repost_id">RP: ...</a> + </div> + <details v-if="post.media"> + <summary>({{ post.media.length }}個のメディア)</summary> + <mk-media-list :media-list="post.media"/> + </details> + <details v-if="post.poll"> + <summary>%i18n:mobile.tags.mk-sub-post-content.poll%</summary> + <mk-poll :post="post"/> + </details> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + props: ['post'] +}); +</script> + +<style lang="stylus" scoped> +.mk-sub-post-content + overflow-wrap break-word + + > .body + > .reply + margin-right 6px + color #717171 + + > .rp + margin-left 4px + font-style oblique + color #a0bf46 + + mk-poll + font-size 80% + +</style> diff --git a/src/server/web/app/mobile/views/components/timeline.vue b/src/server/web/app/mobile/views/components/timeline.vue new file mode 100644 index 0000000000..c0e766523f --- /dev/null +++ b/src/server/web/app/mobile/views/components/timeline.vue @@ -0,0 +1,109 @@ +<template> +<div class="mk-timeline"> + <mk-friends-maker v-if="alone"/> + <mk-posts :posts="posts"> + <div class="init" v-if="fetching"> + %fa:spinner .pulse%%i18n:common.loading% + </div> + <div class="empty" v-if="!fetching && posts.length == 0"> + %fa:R comments% + %i18n:mobile.tags.mk-home-timeline.empty-timeline% + </div> + <button v-if="!fetching && existMore" @click="more" :disabled="moreFetching" slot="tail"> + <span v-if="!moreFetching">%i18n:mobile.tags.mk-timeline.load-more%</span> + <span v-if="moreFetching">%i18n:common.loading%<mk-ellipsis/></span> + </button> + </mk-posts> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +const limit = 10; + +export default Vue.extend({ + props: { + date: { + type: Date, + required: false + } + }, + data() { + return { + fetching: true, + moreFetching: false, + posts: [], + existMore: false, + connection: null, + connectionId: null + }; + }, + computed: { + alone(): boolean { + return (this as any).os.i.following_count == 0; + } + }, + mounted() { + this.connection = (this as any).os.stream.getConnection(); + this.connectionId = (this as any).os.stream.use(); + + this.connection.on('post', this.onPost); + this.connection.on('follow', this.onChangeFollowing); + this.connection.on('unfollow', this.onChangeFollowing); + + this.fetch(); + }, + beforeDestroy() { + this.connection.off('post', this.onPost); + this.connection.off('follow', this.onChangeFollowing); + this.connection.off('unfollow', this.onChangeFollowing); + (this as any).os.stream.dispose(this.connectionId); + }, + methods: { + fetch(cb?) { + this.fetching = true; + (this as any).api('posts/timeline', { + limit: limit + 1, + until_date: this.date ? (this.date as any).getTime() : undefined + }).then(posts => { + if (posts.length == limit + 1) { + posts.pop(); + this.existMore = true; + } + this.posts = posts; + this.fetching = false; + this.$emit('loaded'); + if (cb) cb(); + }); + }, + more() { + this.moreFetching = true; + (this as any).api('posts/timeline', { + limit: limit + 1, + until_id: this.posts[this.posts.length - 1].id + }).then(posts => { + if (posts.length == limit + 1) { + posts.pop(); + this.existMore = true; + } else { + this.existMore = false; + } + this.posts = this.posts.concat(posts); + this.moreFetching = false; + }); + }, + onPost(post) { + this.posts.unshift(post); + }, + onChangeFollowing() { + this.fetch(); + } + } +}); +</script> + +<style lang="stylus" scoped> +.mk-friends-maker + margin-bottom 8px +</style> diff --git a/src/server/web/app/mobile/views/components/ui.header.vue b/src/server/web/app/mobile/views/components/ui.header.vue new file mode 100644 index 0000000000..66e10a0f8a --- /dev/null +++ b/src/server/web/app/mobile/views/components/ui.header.vue @@ -0,0 +1,242 @@ +<template> +<div class="header"> + <mk-special-message/> + <div class="main" ref="main"> + <div class="backdrop"></div> + <p ref="welcomeback" v-if="os.isSignedIn">おかえりなさい、<b>{{ os.i.name }}</b>さん</p> + <div class="content" ref="mainContainer"> + <button class="nav" @click="$parent.isDrawerOpening = true">%fa:bars%</button> + <template v-if="hasUnreadNotifications || hasUnreadMessagingMessages || hasGameInvitations">%fa:circle%</template> + <h1> + <slot>Misskey</slot> + </h1> + <slot name="func"></slot> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import * as anime from 'animejs'; + +export default Vue.extend({ + props: ['func'], + data() { + return { + hasUnreadNotifications: false, + hasUnreadMessagingMessages: false, + hasGameInvitations: false, + connection: null, + connectionId: null + }; + }, + mounted() { + if ((this as any).os.isSignedIn) { + this.connection = (this as any).os.stream.getConnection(); + this.connectionId = (this as any).os.stream.use(); + + this.connection.on('read_all_notifications', this.onReadAllNotifications); + this.connection.on('unread_notification', this.onUnreadNotification); + this.connection.on('read_all_messaging_messages', this.onReadAllMessagingMessages); + this.connection.on('unread_messaging_message', this.onUnreadMessagingMessage); + this.connection.on('othello_invited', this.onOthelloInvited); + this.connection.on('othello_no_invites', this.onOthelloNoInvites); + + // Fetch count of unread notifications + (this as any).api('notifications/get_unread_count').then(res => { + if (res.count > 0) { + this.hasUnreadNotifications = true; + } + }); + + // Fetch count of unread messaging messages + (this as any).api('messaging/unread').then(res => { + if (res.count > 0) { + this.hasUnreadMessagingMessages = true; + } + }); + + const ago = (new Date().getTime() - new Date((this as any).os.i.account.last_used_at).getTime()) / 1000 + const isHisasiburi = ago >= 3600; + (this as any).os.i.account.last_used_at = new Date(); + if (isHisasiburi) { + (this.$refs.welcomeback as any).style.display = 'block'; + (this.$refs.main as any).style.overflow = 'hidden'; + + anime({ + targets: this.$refs.welcomeback, + top: '0', + opacity: 1, + delay: 1000, + duration: 500, + easing: 'easeOutQuad' + }); + + anime({ + targets: this.$refs.mainContainer, + opacity: 0, + delay: 1000, + duration: 500, + easing: 'easeOutQuad' + }); + + setTimeout(() => { + anime({ + targets: this.$refs.welcomeback, + top: '-48px', + opacity: 0, + duration: 500, + complete: () => { + (this.$refs.welcomeback as any).style.display = 'none'; + (this.$refs.main as any).style.overflow = 'initial'; + }, + easing: 'easeInQuad' + }); + + anime({ + targets: this.$refs.mainContainer, + opacity: 1, + duration: 500, + easing: 'easeInQuad' + }); + }, 2500); + } + } + }, + beforeDestroy() { + if ((this as any).os.isSignedIn) { + this.connection.off('read_all_notifications', this.onReadAllNotifications); + this.connection.off('unread_notification', this.onUnreadNotification); + this.connection.off('read_all_messaging_messages', this.onReadAllMessagingMessages); + this.connection.off('unread_messaging_message', this.onUnreadMessagingMessage); + this.connection.off('othello_invited', this.onOthelloInvited); + this.connection.off('othello_no_invites', this.onOthelloNoInvites); + (this as any).os.stream.dispose(this.connectionId); + } + }, + methods: { + onReadAllNotifications() { + this.hasUnreadNotifications = false; + }, + onUnreadNotification() { + this.hasUnreadNotifications = true; + }, + onReadAllMessagingMessages() { + this.hasUnreadMessagingMessages = false; + }, + onUnreadMessagingMessage() { + this.hasUnreadMessagingMessages = true; + }, + onOthelloInvited() { + this.hasGameInvitations = true; + }, + onOthelloNoInvites() { + this.hasGameInvitations = false; + } + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +.header + $height = 48px + + position fixed + top 0 + z-index 1024 + width 100% + box-shadow 0 1px 0 rgba(#000, 0.075) + + > .main + color rgba(#fff, 0.9) + + > .backdrop + position absolute + top 0 + z-index 1000 + width 100% + height $height + -webkit-backdrop-filter blur(12px) + backdrop-filter blur(12px) + //background-color rgba(#1b2023, 0.75) + background-color #1b2023 + + > p + display none + position absolute + z-index 1002 + top $height + width 100% + line-height $height + margin 0 + text-align center + color #fff + opacity 0 + + > .content + z-index 1001 + + > h1 + display block + margin 0 auto + padding 0 + width 100% + max-width calc(100% - 112px) + text-align center + font-size 1.1em + font-weight normal + line-height $height + white-space nowrap + overflow hidden + text-overflow ellipsis + + [data-fa], [data-icon] + margin-right 4px + + > img + display inline-block + vertical-align bottom + width ($height - 16px) + height ($height - 16px) + margin 8px + border-radius 6px + + > .nav + display block + position absolute + top 0 + left 0 + padding 0 + width $height + font-size 1.4em + line-height $height + border-right solid 1px rgba(#000, 0.1) + + > [data-fa] + transition all 0.2s ease + + > [data-fa].circle + position absolute + top 8px + left 8px + pointer-events none + font-size 10px + color $theme-color + + > button:last-child + display block + position absolute + top 0 + right 0 + padding 0 + width $height + text-align center + font-size 1.4em + color inherit + line-height $height + border-left solid 1px rgba(#000, 0.1) + +</style> diff --git a/src/server/web/app/mobile/views/components/ui.nav.vue b/src/server/web/app/mobile/views/components/ui.nav.vue new file mode 100644 index 0000000000..760a5b5184 --- /dev/null +++ b/src/server/web/app/mobile/views/components/ui.nav.vue @@ -0,0 +1,244 @@ +<template> +<div class="nav"> + <transition name="back"> + <div class="backdrop" + v-if="isOpen" + @click="$parent.isDrawerOpening = false" + @touchstart="$parent.isDrawerOpening = false" + ></div> + </transition> + <transition name="nav"> + <div class="body" v-if="isOpen"> + <router-link class="me" v-if="os.isSignedIn" :to="`/@${os.i.username}`"> + <img class="avatar" :src="`${os.i.avatar_url}?thumbnail&size=128`" alt="avatar"/> + <p class="name">{{ os.i.name }}</p> + </router-link> + <div class="links"> + <ul> + <li><router-link to="/">%fa:home%%i18n:mobile.tags.mk-ui-nav.home%%fa:angle-right%</router-link></li> + <li><router-link to="/i/notifications">%fa:R bell%%i18n:mobile.tags.mk-ui-nav.notifications%<template v-if="hasUnreadNotifications">%fa:circle%</template>%fa:angle-right%</router-link></li> + <li><router-link to="/i/messaging">%fa:R comments%%i18n:mobile.tags.mk-ui-nav.messaging%<template v-if="hasUnreadMessagingMessages">%fa:circle%</template>%fa:angle-right%</router-link></li> + <li><router-link to="/othello">%fa:gamepad%ゲーム<template v-if="hasGameInvitations">%fa:circle%</template>%fa:angle-right%</router-link></li> + </ul> + <ul> + <li><a :href="chUrl" target="_blank">%fa:tv%%i18n:mobile.tags.mk-ui-nav.ch%%fa:angle-right%</a></li> + <li><router-link to="/i/drive">%fa:cloud%%i18n:mobile.tags.mk-ui-nav.drive%%fa:angle-right%</router-link></li> + </ul> + <ul> + <li><a @click="search">%fa:search%%i18n:mobile.tags.mk-ui-nav.search%%fa:angle-right%</a></li> + </ul> + <ul> + <li><router-link to="/i/settings">%fa:cog%%i18n:mobile.tags.mk-ui-nav.settings%%fa:angle-right%</router-link></li> + </ul> + </div> + <a :href="aboutUrl"><p class="about">%i18n:mobile.tags.mk-ui-nav.about%</p></a> + </div> + </transition> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { docsUrl, chUrl, lang } from '../../../config'; + +export default Vue.extend({ + props: ['isOpen'], + data() { + return { + hasUnreadNotifications: false, + hasUnreadMessagingMessages: false, + hasGameInvitations: false, + connection: null, + connectionId: null, + aboutUrl: `${docsUrl}/${lang}/about`, + chUrl + }; + }, + mounted() { + if ((this as any).os.isSignedIn) { + this.connection = (this as any).os.stream.getConnection(); + this.connectionId = (this as any).os.stream.use(); + + this.connection.on('read_all_notifications', this.onReadAllNotifications); + this.connection.on('unread_notification', this.onUnreadNotification); + this.connection.on('read_all_messaging_messages', this.onReadAllMessagingMessages); + this.connection.on('unread_messaging_message', this.onUnreadMessagingMessage); + this.connection.on('othello_invited', this.onOthelloInvited); + this.connection.on('othello_no_invites', this.onOthelloNoInvites); + + // Fetch count of unread notifications + (this as any).api('notifications/get_unread_count').then(res => { + if (res.count > 0) { + this.hasUnreadNotifications = true; + } + }); + + // Fetch count of unread messaging messages + (this as any).api('messaging/unread').then(res => { + if (res.count > 0) { + this.hasUnreadMessagingMessages = true; + } + }); + } + }, + beforeDestroy() { + if ((this as any).os.isSignedIn) { + this.connection.off('read_all_notifications', this.onReadAllNotifications); + this.connection.off('unread_notification', this.onUnreadNotification); + this.connection.off('read_all_messaging_messages', this.onReadAllMessagingMessages); + this.connection.off('unread_messaging_message', this.onUnreadMessagingMessage); + this.connection.off('othello_invited', this.onOthelloInvited); + this.connection.off('othello_no_invites', this.onOthelloNoInvites); + (this as any).os.stream.dispose(this.connectionId); + } + }, + methods: { + search() { + const query = window.prompt('%i18n:mobile.tags.mk-ui-nav.search%'); + if (query == null || query == '') return; + this.$router.push('/search?q=' + encodeURIComponent(query)); + }, + onReadAllNotifications() { + this.hasUnreadNotifications = false; + }, + onUnreadNotification() { + this.hasUnreadNotifications = true; + }, + onReadAllMessagingMessages() { + this.hasUnreadMessagingMessages = false; + }, + onUnreadMessagingMessage() { + this.hasUnreadMessagingMessages = true; + }, + onOthelloInvited() { + this.hasGameInvitations = true; + }, + onOthelloNoInvites() { + this.hasGameInvitations = false; + } + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +.nav + .backdrop + position fixed + top 0 + left 0 + z-index 1025 + width 100% + height 100% + background rgba(0, 0, 0, 0.2) + + .body + position fixed + top 0 + left 0 + z-index 1026 + width 240px + height 100% + overflow auto + -webkit-overflow-scrolling touch + color #777 + background #fff + + .me + display block + margin 0 + padding 16px + + .avatar + display inline + max-width 64px + border-radius 32px + vertical-align middle + + .name + display block + margin 0 16px + position absolute + top 0 + left 80px + padding 0 + width calc(100% - 112px) + color #777 + line-height 96px + overflow hidden + text-overflow ellipsis + white-space nowrap + + ul + display block + margin 16px 0 + padding 0 + list-style none + + &:first-child + margin-top 0 + + li + display block + font-size 1em + line-height 1em + + a + display block + padding 0 20px + line-height 3rem + line-height calc(1rem + 30px) + color #777 + text-decoration none + + > [data-fa]:first-child + margin-right 0.5em + + > [data-fa].circle + margin-left 6px + font-size 10px + color $theme-color + + > [data-fa]:last-child + position absolute + top 0 + right 0 + padding 0 20px + font-size 1.2em + line-height calc(1rem + 30px) + color #ccc + + .about + margin 0 + padding 1em 0 + text-align center + font-size 0.8em + opacity 0.5 + + a + color #777 + +.nav-enter-active, +.nav-leave-active { + opacity: 1; + transform: translateX(0); + transition: transform 300ms cubic-bezier(0.23, 1, 0.32, 1), opacity 300ms cubic-bezier(0.23, 1, 0.32, 1); +} +.nav-enter, +.nav-leave-active { + opacity: 0; + transform: translateX(-240px); +} + +.back-enter-active, +.back-leave-active { + opacity: 1; + transition: opacity 300ms cubic-bezier(0.23, 1, 0.32, 1); +} +.back-enter, +.back-leave-active { + opacity: 0; +} + +</style> diff --git a/src/server/web/app/mobile/views/components/ui.vue b/src/server/web/app/mobile/views/components/ui.vue new file mode 100644 index 0000000000..325ce9d40e --- /dev/null +++ b/src/server/web/app/mobile/views/components/ui.vue @@ -0,0 +1,75 @@ +<template> +<div class="mk-ui"> + <x-header> + <template slot="func"><slot name="func"></slot></template> + <slot name="header"></slot> + </x-header> + <x-nav :is-open="isDrawerOpening"/> + <div class="content"> + <slot></slot> + </div> + <mk-stream-indicator v-if="os.isSignedIn"/> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import MkNotify from './notify.vue'; +import XHeader from './ui.header.vue'; +import XNav from './ui.nav.vue'; + +export default Vue.extend({ + components: { + XHeader, + XNav + }, + props: ['title'], + data() { + return { + isDrawerOpening: false, + connection: null, + connectionId: null + }; + }, + mounted() { + if ((this as any).os.isSignedIn) { + this.connection = (this as any).os.stream.getConnection(); + this.connectionId = (this as any).os.stream.use(); + + this.connection.on('notification', this.onNotification); + } + }, + beforeDestroy() { + if ((this as any).os.isSignedIn) { + this.connection.off('notification', this.onNotification); + (this as any).os.stream.dispose(this.connectionId); + } + }, + methods: { + onNotification(notification) { + // TODO: ユーザーが画面を見てないと思われるとき(ブラウザやタブがアクティブじゃないなど)は送信しない + this.connection.send({ + type: 'read_notification', + id: notification.id + }); + + (this as any).os.new(MkNotify, { + notification + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +.mk-ui + display flex + flex 1 + flex-direction column + padding-top 48px + + > .content + display flex + flex 1 + flex-direction column +</style> diff --git a/src/server/web/app/mobile/views/components/user-card.vue b/src/server/web/app/mobile/views/components/user-card.vue new file mode 100644 index 0000000000..5a7309cfd3 --- /dev/null +++ b/src/server/web/app/mobile/views/components/user-card.vue @@ -0,0 +1,69 @@ +<template> +<div class="mk-user-card"> + <header :style="user.banner_url ? `background-image: url(${user.banner_url}?thumbnail&size=1024)` : ''"> + <a :href="`/@${acct}`"> + <img :src="`${user.avatar_url}?thumbnail&size=200`" alt="avatar"/> + </a> + </header> + <a class="name" :href="`/@${acct}`" target="_blank">{{ user.name }}</a> + <p class="username">@{{ acct }}</p> + <mk-follow-button :user="user"/> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import getAcct from '../../../../../common/user/get-acct'; + +export default Vue.extend({ + props: ['user'], + computed: { + acct() { + return getAcct(this.user); + } + } +}); +</script> + +<style lang="stylus" scoped> +.mk-user-card + display inline-block + width 200px + text-align center + border-radius 8px + background #fff + + > header + display block + height 80px + background-color #ddd + background-size cover + background-position center + border-radius 8px 8px 0 0 + + > a + > img + position absolute + top 20px + left calc(50% - 40px) + width 80px + height 80px + border solid 2px #fff + border-radius 8px + + > .name + display block + margin 24px 0 0 0 + font-size 16px + color #555 + + > .username + margin 0 + font-size 15px + color #ccc + + > .mk-follow-button + display inline-block + margin 8px 0 16px 0 + +</style> diff --git a/src/server/web/app/mobile/views/components/user-preview.vue b/src/server/web/app/mobile/views/components/user-preview.vue new file mode 100644 index 0000000000..be80582cac --- /dev/null +++ b/src/server/web/app/mobile/views/components/user-preview.vue @@ -0,0 +1,110 @@ +<template> +<div class="mk-user-preview"> + <router-link class="avatar-anchor" :to="`/@${acct}`"> + <img class="avatar" :src="`${user.avatar_url}?thumbnail&size=64`" alt="avatar"/> + </router-link> + <div class="main"> + <header> + <router-link class="name" :to="`/@${acct}`">{{ user.name }}</router-link> + <span class="username">@{{ acct }}</span> + </header> + <div class="body"> + <div class="description">{{ user.description }}</div> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import getAcct from '../../../../../common/user/get-acct'; + +export default Vue.extend({ + props: ['user'], + computed: { + acct() { + return getAcct(this.user); + } + } +}); +</script> + +<style lang="stylus" scoped> +.mk-user-preview + margin 0 + padding 16px + font-size 12px + + @media (min-width 350px) + font-size 14px + + @media (min-width 500px) + font-size 16px + + &:after + content "" + display block + clear both + + > .avatar-anchor + display block + float left + margin 0 10px 0 0 + + @media (min-width 500px) + margin-right 16px + + > .avatar + display block + width 48px + height 48px + margin 0 + border-radius 6px + vertical-align bottom + + @media (min-width 500px) + width 58px + height 58px + border-radius 8px + + > .main + float left + width calc(100% - 58px) + + @media (min-width 500px) + width calc(100% - 74px) + + > header + @media (min-width 500px) + margin-bottom 2px + + > .name + display inline + margin 0 + padding 0 + color #777 + font-size 1em + font-weight 700 + text-align left + text-decoration none + + &:hover + text-decoration underline + + > .username + text-align left + margin 0 0 0 8px + color #ccc + + > .body + + > .description + cursor default + display block + margin 0 + padding 0 + overflow-wrap break-word + font-size 1.1em + color #717171 + +</style> diff --git a/src/server/web/app/mobile/views/components/user-timeline.vue b/src/server/web/app/mobile/views/components/user-timeline.vue new file mode 100644 index 0000000000..39f959187c --- /dev/null +++ b/src/server/web/app/mobile/views/components/user-timeline.vue @@ -0,0 +1,76 @@ +<template> +<div class="mk-user-timeline"> + <mk-posts :posts="posts"> + <div class="init" v-if="fetching"> + %fa:spinner .pulse%%i18n:common.loading% + </div> + <div class="empty" v-if="!fetching && posts.length == 0"> + %fa:R comments% + {{ withMedia ? '%i18n:mobile.tags.mk-user-timeline.no-posts-with-media%' : '%i18n:mobile.tags.mk-user-timeline.no-posts%' }} + </div> + <button v-if="!fetching && existMore" @click="more" :disabled="moreFetching" slot="tail"> + <span v-if="!moreFetching">%i18n:mobile.tags.mk-user-timeline.load-more%</span> + <span v-if="moreFetching">%i18n:common.loading%<mk-ellipsis/></span> + </button> + </mk-posts> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +const limit = 10; + +export default Vue.extend({ + props: ['user', 'withMedia'], + data() { + return { + fetching: true, + posts: [], + existMore: false, + moreFetching: false + }; + }, + mounted() { + (this as any).api('users/posts', { + user_id: this.user.id, + with_media: this.withMedia, + limit: limit + 1 + }).then(posts => { + if (posts.length == limit + 1) { + posts.pop(); + this.existMore = true; + } + this.posts = posts; + this.fetching = false; + this.$emit('loaded'); + }); + }, + methods: { + more() { + this.moreFetching = true; + (this as any).api('users/posts', { + user_id: this.user.id, + with_media: this.withMedia, + limit: limit + 1, + until_id: this.posts[this.posts.length - 1].id + }).then(posts => { + if (posts.length == limit + 1) { + posts.pop(); + this.existMore = true; + } else { + this.existMore = false; + } + this.posts = this.posts.concat(posts); + this.moreFetching = false; + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +.mk-user-timeline + max-width 600px + margin 0 auto +</style> diff --git a/src/server/web/app/mobile/views/components/users-list.vue b/src/server/web/app/mobile/views/components/users-list.vue new file mode 100644 index 0000000000..b11e4549d6 --- /dev/null +++ b/src/server/web/app/mobile/views/components/users-list.vue @@ -0,0 +1,133 @@ +<template> +<div class="mk-users-list"> + <nav> + <span :data-is-active="mode == 'all'" @click="mode = 'all'">%i18n:mobile.tags.mk-users-list.all%<span>{{ count }}</span></span> + <span v-if="os.isSignedIn && youKnowCount" :data-is-active="mode == 'iknow'" @click="mode = 'iknow'">%i18n:mobile.tags.mk-users-list.known%<span>{{ youKnowCount }}</span></span> + </nav> + <div class="users" v-if="!fetching && users.length != 0"> + <mk-user-preview v-for="u in users" :user="u" :key="u.id"/> + </div> + <button class="more" v-if="!fetching && next != null" @click="more" :disabled="moreFetching"> + <span v-if="!moreFetching">%i18n:mobile.tags.mk-users-list.load-more%</span> + <span v-if="moreFetching">%i18n:common.loading%<mk-ellipsis/></span> + </button> + <p class="no" v-if="!fetching && users.length == 0"> + <slot></slot> + </p> + <p class="fetching" v-if="fetching">%fa:spinner .pulse .fw%%i18n:common.loading%<mk-ellipsis/></p> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + props: ['fetch', 'count', 'youKnowCount'], + data() { + return { + limit: 30, + mode: 'all', + fetching: true, + moreFetching: false, + users: [], + next: null + }; + }, + watch: { + mode() { + this._fetch(); + } + }, + mounted() { + this._fetch(() => { + this.$emit('loaded'); + }); + }, + methods: { + _fetch(cb?) { + this.fetching = true; + this.fetch(this.mode == 'iknow', this.limit, null, obj => { + this.users = obj.users; + this.next = obj.next; + this.fetching = false; + if (cb) cb(); + }); + }, + more() { + this.moreFetching = true; + this.fetch(this.mode == 'iknow', this.limit, this.next, obj => { + this.moreFetching = false; + this.users = this.users.concat(obj.users); + this.next = obj.next; + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +.mk-users-list + + > nav + display flex + justify-content center + margin 0 auto + max-width 600px + border-bottom solid 1px rgba(0, 0, 0, 0.2) + + > span + display block + flex 1 1 + text-align center + line-height 52px + font-size 14px + color #657786 + border-bottom solid 2px transparent + + &[data-is-active] + font-weight bold + color $theme-color + border-color $theme-color + + > span + display inline-block + margin-left 4px + padding 2px 5px + font-size 12px + line-height 1 + color #fff + background rgba(0, 0, 0, 0.3) + border-radius 20px + + > .users + margin 8px auto + max-width 500px + width calc(100% - 16px) + background #fff + border-radius 8px + box-shadow 0 0 0 1px rgba(0, 0, 0, 0.2) + + @media (min-width 500px) + margin 16px auto + width calc(100% - 32px) + + > * + border-bottom solid 1px rgba(0, 0, 0, 0.05) + + > .no + margin 0 + padding 16px + text-align center + color #aaa + + > .fetching + margin 0 + padding 16px + text-align center + color #aaa + + > [data-fa] + margin-right 4px + +</style> diff --git a/src/server/web/app/mobile/views/components/widget-container.vue b/src/server/web/app/mobile/views/components/widget-container.vue new file mode 100644 index 0000000000..7319c90849 --- /dev/null +++ b/src/server/web/app/mobile/views/components/widget-container.vue @@ -0,0 +1,68 @@ +<template> +<div class="mk-widget-container" :class="{ naked, hideHeader: !showHeader }"> + <header v-if="showHeader"> + <div class="title"><slot name="header"></slot></div> + <slot name="func"></slot> + </header> + <slot></slot> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + props: { + showHeader: { + type: Boolean, + default: true + }, + naked: { + type: Boolean, + default: false + } + } +}); +</script> + +<style lang="stylus" scoped> +.mk-widget-container + background #eee + border-radius 8px + box-shadow 0 0 0 1px rgba(0, 0, 0, 0.2) + overflow hidden + + &.hideHeader + background #fff + + &.naked + background transparent !important + box-shadow none !important + + > header + > .title + margin 0 + padding 8px 10px + font-size 15px + font-weight normal + color #465258 + background #fff + border-radius 8px 8px 0 0 + + > [data-fa] + margin-right 6px + + &:empty + display none + + > button + position absolute + z-index 2 + top 0 + right 0 + padding 0 + width 42px + height 100% + font-size 15px + color #465258 + +</style> diff --git a/src/server/web/app/mobile/views/directives/index.ts b/src/server/web/app/mobile/views/directives/index.ts new file mode 100644 index 0000000000..324e07596d --- /dev/null +++ b/src/server/web/app/mobile/views/directives/index.ts @@ -0,0 +1,6 @@ +import Vue from 'vue'; + +import userPreview from './user-preview'; + +Vue.directive('userPreview', userPreview); +Vue.directive('user-preview', userPreview); diff --git a/src/server/web/app/mobile/views/directives/user-preview.ts b/src/server/web/app/mobile/views/directives/user-preview.ts new file mode 100644 index 0000000000..1a54abc20d --- /dev/null +++ b/src/server/web/app/mobile/views/directives/user-preview.ts @@ -0,0 +1,2 @@ +// nope +export default {}; diff --git a/src/server/web/app/mobile/views/pages/drive.vue b/src/server/web/app/mobile/views/pages/drive.vue new file mode 100644 index 0000000000..200379f222 --- /dev/null +++ b/src/server/web/app/mobile/views/pages/drive.vue @@ -0,0 +1,107 @@ +<template> +<mk-ui> + <span slot="header"> + <template v-if="folder">%fa:R folder-open%{{ folder.name }}</template> + <template v-if="file"><mk-file-type-icon data-icon :type="file.type"/>{{ file.name }}</template> + <template v-if="!folder && !file">%fa:cloud%%i18n:mobile.tags.mk-drive-page.drive%</template> + </span> + <template slot="func"><button @click="fn">%fa:ellipsis-h%</button></template> + <mk-drive + ref="browser" + :init-folder="initFolder" + :init-file="initFile" + :is-naked="true" + :top="48" + @begin-fetch="Progress.start()" + @fetched-mid="Progress.set(0.5)" + @fetched="Progress.done()" + @move-root="onMoveRoot" + @open-folder="onOpenFolder" + @open-file="onOpenFile" + /> +</mk-ui> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import Progress from '../../../common/scripts/loading'; + +export default Vue.extend({ + data() { + return { + Progress, + folder: null, + file: null, + initFolder: null, + initFile: null + }; + }, + created() { + this.initFolder = this.$route.params.folder; + this.initFile = this.$route.params.file; + + window.addEventListener('popstate', this.onPopState); + }, + mounted() { + document.title = 'Misskey Drive'; + document.documentElement.style.background = '#fff'; + }, + beforeDestroy() { + window.removeEventListener('popstate', this.onPopState); + }, + methods: { + onPopState() { + if (this.$route.params.folder) { + (this.$refs as any).browser.cd(this.$route.params.folder, true); + } else if (this.$route.params.file) { + (this.$refs as any).browser.cf(this.$route.params.file, true); + } else { + (this.$refs as any).browser.goRoot(true); + } + }, + fn() { + (this.$refs as any).browser.openContextMenu(); + }, + onMoveRoot(silent) { + const title = 'Misskey Drive'; + + if (!silent) { + // Rewrite URL + history.pushState(null, title, '/i/drive'); + } + + document.title = title; + + this.file = null; + this.folder = null; + }, + onOpenFolder(folder, silent) { + const title = folder.name + ' | Misskey Drive'; + + if (!silent) { + // Rewrite URL + history.pushState(null, title, '/i/drive/folder/' + folder.id); + } + + document.title = title; + + this.file = null; + this.folder = folder; + }, + onOpenFile(file, silent) { + const title = file.name + ' | Misskey Drive'; + + if (!silent) { + // Rewrite URL + history.pushState(null, title, '/i/drive/file/' + file.id); + } + + document.title = title; + + this.file = file; + this.folder = null; + } + } +}); +</script> + diff --git a/src/server/web/app/mobile/views/pages/followers.vue b/src/server/web/app/mobile/views/pages/followers.vue new file mode 100644 index 0000000000..1edf4e38ad --- /dev/null +++ b/src/server/web/app/mobile/views/pages/followers.vue @@ -0,0 +1,65 @@ +<template> +<mk-ui> + <template slot="header" v-if="!fetching"> + <img :src="`${user.avatar_url}?thumbnail&size=64`" alt=""> + {{ '%i18n:mobile.tags.mk-user-followers-page.followers-of%'.replace('{}', user.name) }} + </template> + <mk-users-list + v-if="!fetching" + :fetch="fetchUsers" + :count="user.followers_count" + :you-know-count="user.followers_you_know_count" + @loaded="onLoaded" + > + %i18n:mobile.tags.mk-user-followers.no-users% + </mk-users-list> +</mk-ui> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import Progress from '../../../common/scripts/loading'; +import parseAcct from '../../../../../common/user/parse-acct'; + +export default Vue.extend({ + data() { + return { + fetching: true, + user: null + }; + }, + watch: { + $route: 'fetch' + }, + created() { + this.fetch(); + }, + mounted() { + document.documentElement.style.background = '#313a42'; + }, + methods: { + fetch() { + Progress.start(); + this.fetching = true; + + (this as any).api('users/show', parseAcct(this.$route.params.user)).then(user => { + this.user = user; + this.fetching = false; + + document.title = '%i18n:mobile.tags.mk-user-followers-page.followers-of%'.replace('{}', user.name) + ' | Misskey'; + }); + }, + onLoaded() { + Progress.done(); + }, + fetchUsers(iknow, limit, cursor, cb) { + (this as any).api('users/followers', { + user_id: this.user.id, + iknow: iknow, + limit: limit, + cursor: cursor ? cursor : undefined + }).then(cb); + } + } +}); +</script> diff --git a/src/server/web/app/mobile/views/pages/following.vue b/src/server/web/app/mobile/views/pages/following.vue new file mode 100644 index 0000000000..0dd171cce1 --- /dev/null +++ b/src/server/web/app/mobile/views/pages/following.vue @@ -0,0 +1,65 @@ +<template> +<mk-ui> + <template slot="header" v-if="!fetching"> + <img :src="`${user.avatar_url}?thumbnail&size=64`" alt=""> + {{ '%i18n:mobile.tags.mk-user-following-page.following-of%'.replace('{}', user.name) }} + </template> + <mk-users-list + v-if="!fetching" + :fetch="fetchUsers" + :count="user.following_count" + :you-know-count="user.following_you_know_count" + @loaded="onLoaded" + > + %i18n:mobile.tags.mk-user-following.no-users% + </mk-users-list> +</mk-ui> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import Progress from '../../../common/scripts/loading'; +import parseAcct from '../../../../../common/user/parse-acct'; + +export default Vue.extend({ + data() { + return { + fetching: true, + user: null + }; + }, + watch: { + $route: 'fetch' + }, + created() { + this.fetch(); + }, + mounted() { + document.documentElement.style.background = '#313a42'; + }, + methods: { + fetch() { + Progress.start(); + this.fetching = true; + + (this as any).api('users/show', parseAcct(this.$route.params.user)).then(user => { + this.user = user; + this.fetching = false; + + document.title = '%i18n:mobile.tags.mk-user-followers-page.followers-of%'.replace('{}', user.name) + ' | Misskey'; + }); + }, + onLoaded() { + Progress.done(); + }, + fetchUsers(iknow, limit, cursor, cb) { + (this as any).api('users/following', { + user_id: this.user.id, + iknow: iknow, + limit: limit, + cursor: cursor ? cursor : undefined + }).then(cb); + } + } +}); +</script> diff --git a/src/server/web/app/mobile/views/pages/home.vue b/src/server/web/app/mobile/views/pages/home.vue new file mode 100644 index 0000000000..b110fc4091 --- /dev/null +++ b/src/server/web/app/mobile/views/pages/home.vue @@ -0,0 +1,259 @@ +<template> +<mk-ui> + <span slot="header" @click="showTl = !showTl"> + <template v-if="showTl">%fa:home%タイムライン</template> + <template v-else>%fa:home%ウィジェット</template> + <span style="margin-left:8px"> + <template v-if="showTl">%fa:angle-down%</template> + <template v-else>%fa:angle-up%</template> + </span> + </span> + <template slot="func"> + <button @click="fn" v-if="showTl">%fa:pencil-alt%</button> + <button @click="customizing = !customizing" v-else>%fa:cog%</button> + </template> + <main> + <div class="tl"> + <mk-timeline @loaded="onLoaded" v-show="showTl"/> + </div> + <div class="widgets" v-show="!showTl"> + <template v-if="customizing"> + <header> + <select v-model="widgetAdderSelected"> + <option value="profile">プロフィール</option> + <option value="calendar">カレンダー</option> + <option value="activity">アクティビティ</option> + <option value="rss">RSSリーダー</option> + <option value="photo-stream">フォトストリーム</option> + <option value="slideshow">スライドショー</option> + <option value="version">バージョン</option> + <option value="access-log">アクセスログ</option> + <option value="server">サーバー情報</option> + <option value="donation">寄付のお願い</option> + <option value="nav">ナビゲーション</option> + <option value="tips">ヒント</option> + </select> + <button @click="addWidget">追加</button> + <p><a @click="hint">カスタマイズのヒント</a></p> + </header> + <x-draggable + :list="widgets" + :options="{ handle: '.handle', animation: 150 }" + @sort="onWidgetSort" + > + <div v-for="widget in widgets" class="customize-container" :key="widget.id"> + <header> + <span class="handle">%fa:bars%</span>{{ widget.name }}<button class="remove" @click="removeWidget(widget)">%fa:times%</button> + </header> + <div @click="widgetFunc(widget.id)"> + <component :is="`mkw-${widget.name}`" :widget="widget" :ref="widget.id" :is-customize-mode="true" :is-mobile="true"/> + </div> + </div> + </x-draggable> + </template> + <template v-else> + <component class="widget" v-for="widget in widgets" :is="`mkw-${widget.name}`" :key="widget.id" :ref="widget.id" :widget="widget" :is-mobile="true" @chosen="warp"/> + </template> + </div> + </main> +</mk-ui> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import * as XDraggable from 'vuedraggable'; +import * as uuid from 'uuid'; +import Progress from '../../../common/scripts/loading'; +import getPostSummary from '../../../../../common/get-post-summary'; + +export default Vue.extend({ + components: { + XDraggable + }, + data() { + return { + connection: null, + connectionId: null, + unreadCount: 0, + showTl: true, + widgets: [], + customizing: false, + widgetAdderSelected: null + }; + }, + created() { + if ((this as any).os.i.account.client_settings.mobile_home == null) { + Vue.set((this as any).os.i.account.client_settings, 'mobile_home', [{ + name: 'calendar', + id: 'a', data: {} + }, { + name: 'activity', + id: 'b', data: {} + }, { + name: 'rss', + id: 'c', data: {} + }, { + name: 'photo-stream', + id: 'd', data: {} + }, { + name: 'donation', + id: 'e', data: {} + }, { + name: 'nav', + id: 'f', data: {} + }, { + name: 'version', + id: 'g', data: {} + }]); + this.widgets = (this as any).os.i.account.client_settings.mobile_home; + this.saveHome(); + } else { + this.widgets = (this as any).os.i.account.client_settings.mobile_home; + } + + this.$watch('os.i.account.client_settings', i => { + this.widgets = (this as any).os.i.account.client_settings.mobile_home; + }, { + deep: true + }); + }, + mounted() { + document.title = 'Misskey'; + document.documentElement.style.background = '#313a42'; + + this.connection = (this as any).os.stream.getConnection(); + this.connectionId = (this as any).os.stream.use(); + + this.connection.on('post', this.onStreamPost); + this.connection.on('mobile_home_updated', this.onHomeUpdated); + document.addEventListener('visibilitychange', this.onVisibilitychange, false); + + Progress.start(); + }, + beforeDestroy() { + this.connection.off('post', this.onStreamPost); + this.connection.off('mobile_home_updated', this.onHomeUpdated); + (this as any).os.stream.dispose(this.connectionId); + document.removeEventListener('visibilitychange', this.onVisibilitychange); + }, + methods: { + fn() { + (this as any).apis.post(); + }, + onLoaded() { + Progress.done(); + }, + onStreamPost(post) { + if (document.hidden && post.user_id !== (this as any).os.i.id) { + this.unreadCount++; + document.title = `(${this.unreadCount}) ${getPostSummary(post)}`; + } + }, + onVisibilitychange() { + if (!document.hidden) { + this.unreadCount = 0; + document.title = 'Misskey'; + } + }, + onHomeUpdated(data) { + if (data.home) { + (this as any).os.i.account.client_settings.mobile_home = data.home; + this.widgets = data.home; + } else { + const w = (this as any).os.i.account.client_settings.mobile_home.find(w => w.id == data.id); + if (w != null) { + w.data = data.data; + this.$refs[w.id][0].preventSave = true; + this.$refs[w.id][0].props = w.data; + this.widgets = (this as any).os.i.account.client_settings.mobile_home; + } + } + }, + hint() { + alert('ウィジェットを追加/削除したり並べ替えたりできます。ウィジェットを移動するには「三」をドラッグします。ウィジェットを削除するには「x」をタップします。いくつかのウィジェットはタップすることで表示を変更できます。'); + }, + widgetFunc(id) { + const w = this.$refs[id][0]; + if (w.func) w.func(); + }, + onWidgetSort() { + this.saveHome(); + }, + addWidget() { + const widget = { + name: this.widgetAdderSelected, + id: uuid(), + data: {} + }; + + this.widgets.unshift(widget); + this.saveHome(); + }, + removeWidget(widget) { + this.widgets = this.widgets.filter(w => w.id != widget.id); + this.saveHome(); + }, + saveHome() { + (this as any).os.i.account.client_settings.mobile_home = this.widgets; + (this as any).api('i/update_mobile_home', { + home: this.widgets + }); + }, + warp() { + + } + } +}); +</script> + +<style lang="stylus" scoped> +main + + > .tl + > .mk-timeline + max-width 600px + margin 0 auto + padding 8px + + @media (min-width 500px) + padding 16px + + > .widgets + margin 0 auto + max-width 500px + + @media (min-width 500px) + padding 8px + + > header + padding 8px + background #fff + + .widget + margin 8px + + .customize-container + margin 8px + background #fff + + > header + line-height 32px + background #eee + + > .handle + padding 0 8px + + > .remove + position absolute + top 0 + right 0 + padding 0 8px + line-height 32px + + > div + padding 8px + + > * + pointer-events none + +</style> diff --git a/src/server/web/app/mobile/views/pages/index.vue b/src/server/web/app/mobile/views/pages/index.vue new file mode 100644 index 0000000000..0ea47d913b --- /dev/null +++ b/src/server/web/app/mobile/views/pages/index.vue @@ -0,0 +1,16 @@ +<template> +<component :is="os.isSignedIn ? 'home' : 'welcome'"></component> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import Home from './home.vue'; +import Welcome from './welcome.vue'; + +export default Vue.extend({ + components: { + Home, + Welcome + } +}); +</script> diff --git a/src/server/web/app/mobile/views/pages/messaging-room.vue b/src/server/web/app/mobile/views/pages/messaging-room.vue new file mode 100644 index 0000000000..193c41179c --- /dev/null +++ b/src/server/web/app/mobile/views/pages/messaging-room.vue @@ -0,0 +1,42 @@ +<template> +<mk-ui> + <span slot="header"> + <template v-if="user">%fa:R comments%{{ user.name }}</template> + <template v-else><mk-ellipsis/></template> + </span> + <mk-messaging-room v-if="!fetching" :user="user" :is-naked="true"/> +</mk-ui> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import parseAcct from '../../../../../common/user/parse-acct'; + +export default Vue.extend({ + data() { + return { + fetching: true, + user: null + }; + }, + watch: { + $route: 'fetch' + }, + created() { + document.documentElement.style.background = '#fff'; + this.fetch(); + }, + methods: { + fetch() { + this.fetching = true; + (this as any).api('users/show', parseAcct(this.$route.params.user)).then(user => { + this.user = user; + this.fetching = false; + + document.title = `%i18n:mobile.tags.mk-messaging-room-page.message%: ${user.name} | Misskey`; + }); + } + } +}); +</script> + diff --git a/src/server/web/app/mobile/views/pages/messaging.vue b/src/server/web/app/mobile/views/pages/messaging.vue new file mode 100644 index 0000000000..e92068eda5 --- /dev/null +++ b/src/server/web/app/mobile/views/pages/messaging.vue @@ -0,0 +1,23 @@ +<template> +<mk-ui> + <span slot="header">%fa:R comments%%i18n:mobile.tags.mk-messaging-page.message%</span> + <mk-messaging @navigate="navigate" :header-top="48"/> +</mk-ui> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import getAcct from '../../../../../common/user/get-acct'; + +export default Vue.extend({ + mounted() { + document.title = 'Misskey %i18n:mobile.tags.mk-messaging-page.message%'; + document.documentElement.style.background = '#fff'; + }, + methods: { + navigate(user) { + (this as any).$router.push(`/i/messaging/${getAcct(user)}`); + } + } +}); +</script> diff --git a/src/server/web/app/mobile/views/pages/notifications.vue b/src/server/web/app/mobile/views/pages/notifications.vue new file mode 100644 index 0000000000..3dcfb2f38c --- /dev/null +++ b/src/server/web/app/mobile/views/pages/notifications.vue @@ -0,0 +1,32 @@ +<template> +<mk-ui> + <span slot="header">%fa:R bell%%i18n:mobile.tags.mk-notifications-page.notifications%</span> + <template slot="func"><button @click="fn">%fa:check%</button></template> + <mk-notifications @fetched="onFetched"/> +</mk-ui> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import Progress from '../../../common/scripts/loading'; + +export default Vue.extend({ + mounted() { + document.title = 'Misskey | %i18n:mobile.tags.mk-notifications-page.notifications%'; + document.documentElement.style.background = '#313a42'; + + Progress.start(); + }, + methods: { + fn() { + const ok = window.confirm('%i18n:mobile.tags.mk-notifications-page.read-all%'); + if (!ok) return; + + (this as any).api('notifications/mark_as_read_all'); + }, + onFetched() { + Progress.done(); + } + } +}); +</script> diff --git a/src/server/web/app/mobile/views/pages/othello.vue b/src/server/web/app/mobile/views/pages/othello.vue new file mode 100644 index 0000000000..b110bf309e --- /dev/null +++ b/src/server/web/app/mobile/views/pages/othello.vue @@ -0,0 +1,50 @@ +<template> +<mk-ui> + <span slot="header">%fa:gamepad%オセロ</span> + <mk-othello v-if="!fetching" :init-game="game" @gamed="onGamed"/> +</mk-ui> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import Progress from '../../../common/scripts/loading'; + +export default Vue.extend({ + data() { + return { + fetching: false, + game: null + }; + }, + watch: { + $route: 'fetch' + }, + created() { + this.fetch(); + }, + mounted() { + document.title = 'Misskey オセロ'; + document.documentElement.style.background = '#fff'; + }, + methods: { + fetch() { + if (this.$route.params.game == null) return; + + Progress.start(); + this.fetching = true; + + (this as any).api('othello/games/show', { + game_id: this.$route.params.game + }).then(game => { + this.game = game; + this.fetching = false; + + Progress.done(); + }); + }, + onGamed(game) { + history.pushState(null, null, '/othello/' + game.id); + } + } +}); +</script> diff --git a/src/server/web/app/mobile/views/pages/post.vue b/src/server/web/app/mobile/views/pages/post.vue new file mode 100644 index 0000000000..2ed2ebfcfd --- /dev/null +++ b/src/server/web/app/mobile/views/pages/post.vue @@ -0,0 +1,85 @@ +<template> +<mk-ui> + <span slot="header">%fa:R sticky-note%%i18n:mobile.tags.mk-post-page.title%</span> + <main v-if="!fetching"> + <a v-if="post.next" :href="post.next">%fa:angle-up%%i18n:mobile.tags.mk-post-page.next%</a> + <div> + <mk-post-detail :post="post"/> + </div> + <a v-if="post.prev" :href="post.prev">%fa:angle-down%%i18n:mobile.tags.mk-post-page.prev%</a> + </main> +</mk-ui> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import Progress from '../../../common/scripts/loading'; + +export default Vue.extend({ + data() { + return { + fetching: true, + post: null + }; + }, + watch: { + $route: 'fetch' + }, + created() { + this.fetch(); + }, + mounted() { + document.title = 'Misskey'; + document.documentElement.style.background = '#313a42'; + }, + methods: { + fetch() { + Progress.start(); + this.fetching = true; + + (this as any).api('posts/show', { + post_id: this.$route.params.post + }).then(post => { + this.post = post; + this.fetching = false; + + Progress.done(); + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +main + text-align center + + > div + margin 8px auto + padding 0 + max-width 500px + width calc(100% - 16px) + + @media (min-width 500px) + margin 16px auto + width calc(100% - 32px) + + > a + display inline-block + + &:first-child + margin-top 8px + + @media (min-width 500px) + margin-top 16px + + &:last-child + margin-bottom 8px + + @media (min-width 500px) + margin-bottom 16px + + > [data-fa] + margin-right 4px + +</style> diff --git a/src/server/web/app/mobile/views/pages/profile-setting.vue b/src/server/web/app/mobile/views/pages/profile-setting.vue new file mode 100644 index 0000000000..941165c99e --- /dev/null +++ b/src/server/web/app/mobile/views/pages/profile-setting.vue @@ -0,0 +1,226 @@ +<template> +<mk-ui> + <span slot="header">%fa:user%%i18n:mobile.tags.mk-profile-setting-page.title%</span> + <div :class="$style.content"> + <p>%fa:info-circle%%i18n:mobile.tags.mk-profile-setting.will-be-published%</p> + <div :class="$style.form"> + <div :style="os.i.banner_url ? `background-image: url(${os.i.banner_url}?thumbnail&size=1024)` : ''" @click="setBanner"> + <img :src="`${os.i.avatar_url}?thumbnail&size=200`" alt="avatar" @click="setAvatar"/> + </div> + <label> + <p>%i18n:mobile.tags.mk-profile-setting.name%</p> + <input v-model="name" type="text"/> + </label> + <label> + <p>%i18n:mobile.tags.mk-profile-setting.location%</p> + <input v-model="location" type="text"/> + </label> + <label> + <p>%i18n:mobile.tags.mk-profile-setting.description%</p> + <textarea v-model="description"></textarea> + </label> + <label> + <p>%i18n:mobile.tags.mk-profile-setting.birthday%</p> + <input v-model="birthday" type="date"/> + </label> + <label> + <p>%i18n:mobile.tags.mk-profile-setting.avatar%</p> + <button @click="setAvatar" :disabled="avatarSaving">%i18n:mobile.tags.mk-profile-setting.set-avatar%</button> + </label> + <label> + <p>%i18n:mobile.tags.mk-profile-setting.banner%</p> + <button @click="setBanner" :disabled="bannerSaving">%i18n:mobile.tags.mk-profile-setting.set-banner%</button> + </label> + </div> + <button :class="$style.save" @click="save" :disabled="saving">%fa:check%%i18n:mobile.tags.mk-profile-setting.save%</button> + </div> +</mk-ui> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + data() { + return { + name: null, + location: null, + description: null, + birthday: null, + avatarSaving: false, + bannerSaving: false, + saving: false + }; + }, + created() { + this.name = (this as any).os.i.name; + this.location = (this as any).os.i.account.profile.location; + this.description = (this as any).os.i.description; + this.birthday = (this as any).os.i.account.profile.birthday; + }, + mounted() { + document.title = 'Misskey | %i18n:mobile.tags.mk-profile-setting-page.title%'; + document.documentElement.style.background = '#313a42'; + }, + methods: { + setAvatar() { + (this as any).apis.chooseDriveFile({ + multiple: false + }).then(file => { + this.avatarSaving = true; + + (this as any).api('i/update', { + avatar_id: file.id + }).then(() => { + this.avatarSaving = false; + alert('%i18n:mobile.tags.mk-profile-setting.avatar-saved%'); + }); + }); + }, + setBanner() { + (this as any).apis.chooseDriveFile({ + multiple: false + }).then(file => { + this.bannerSaving = true; + + (this as any).api('i/update', { + banner_id: file.id + }).then(() => { + this.bannerSaving = false; + alert('%i18n:mobile.tags.mk-profile-setting.banner-saved%'); + }); + }); + }, + save() { + this.saving = true; + + (this as any).api('i/update', { + name: this.name, + location: this.location || null, + description: this.description || null, + birthday: this.birthday || null + }).then(() => { + this.saving = false; + alert('%i18n:mobile.tags.mk-profile-setting.saved%'); + }); + } + } +}); +</script> + +<style lang="stylus" module> +@import '~const.styl' + +.content + margin 8px auto + max-width 500px + width calc(100% - 16px) + + @media (min-width 500px) + margin 16px auto + width calc(100% - 32px) + + > p + display block + margin 0 0 8px 0 + padding 12px 16px + font-size 14px + color #79d4e6 + border solid 1px #71afbb + //color #276f86 + //background #f8ffff + //border solid 1px #a9d5de + border-radius 8px + + > [data-fa] + margin-right 6px + +.form + position relative + background #fff + box-shadow 0 0 0 1px rgba(0, 0, 0, 0.2) + border-radius 8px + + &:before + content "" + display block + position absolute + bottom -20px + left calc(50% - 10px) + border-top solid 10px rgba(0, 0, 0, 0.2) + border-right solid 10px transparent + border-bottom solid 10px transparent + border-left solid 10px transparent + + &:after + content "" + display block + position absolute + bottom -16px + left calc(50% - 8px) + border-top solid 8px #fff + border-right solid 8px transparent + border-bottom solid 8px transparent + border-left solid 8px transparent + + > div + height 128px + background-color #e4e4e4 + background-size cover + background-position center + border-radius 8px 8px 0 0 + + > img + position absolute + top 25px + left calc(50% - 40px) + width 80px + height 80px + border solid 2px #fff + border-radius 8px + + > label + display block + margin 0 + padding 16px + border-bottom solid 1px #eee + + &:last-of-type + border none + + > p:first-child + display block + margin 0 + padding 0 0 4px 0 + font-weight bold + color #2f3c42 + + > input[type="text"] + > textarea + display block + width 100% + padding 12px + font-size 16px + color #192427 + border solid 2px #ddd + border-radius 4px + + > textarea + min-height 80px + +.save + display block + margin 8px 0 0 0 + padding 16px + width 100% + font-size 16px + color $theme-color-foreground + background $theme-color + border-radius 8px + + &:disabled + opacity 0.7 + + > [data-fa] + margin-right 4px + +</style> diff --git a/src/server/web/app/mobile/views/pages/search.vue b/src/server/web/app/mobile/views/pages/search.vue new file mode 100644 index 0000000000..cbab504e3c --- /dev/null +++ b/src/server/web/app/mobile/views/pages/search.vue @@ -0,0 +1,93 @@ +<template> +<mk-ui> + <span slot="header">%fa:search% {{ q }}</span> + <main v-if="!fetching"> + <mk-posts :class="$style.posts" :posts="posts"> + <span v-if="posts.length == 0">{{ '%i18n:mobile.tags.mk-search-posts.empty%'.replace('{}', q) }}</span> + <button v-if="existMore" @click="more" :disabled="fetching" slot="tail"> + <span v-if="!fetching">%i18n:mobile.tags.mk-timeline.load-more%</span> + <span v-if="fetching">%i18n:common.loading%<mk-ellipsis/></span> + </button> + </mk-posts> + </main> +</mk-ui> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import Progress from '../../../common/scripts/loading'; +import parse from '../../../common/scripts/parse-search-query'; + +const limit = 20; + +export default Vue.extend({ + data() { + return { + fetching: true, + existMore: false, + posts: [], + offset: 0 + }; + }, + watch: { + $route: 'fetch' + }, + computed: { + q(): string { + return this.$route.query.q; + } + }, + mounted() { + document.title = `%i18n:mobile.tags.mk-search-page.search%: ${this.q} | Misskey`; + document.documentElement.style.background = '#313a42'; + + this.fetch(); + }, + methods: { + fetch() { + this.fetching = true; + Progress.start(); + + (this as any).api('posts/search', Object.assign({ + limit: limit + 1 + }, parse(this.q))).then(posts => { + if (posts.length == limit + 1) { + posts.pop(); + this.existMore = true; + } + this.posts = posts; + this.fetching = false; + Progress.done(); + }); + }, + more() { + this.offset += limit; + (this as any).api('posts/search', Object.assign({ + limit: limit + 1, + offset: this.offset + }, parse(this.q))).then(posts => { + if (posts.length == limit + 1) { + posts.pop(); + } else { + this.existMore = false; + } + this.posts = this.posts.concat(posts); + }); + } + } +}); +</script> + +<style lang="stylus" module> +.posts + margin 8px auto + max-width 500px + width calc(100% - 16px) + background #fff + border-radius 8px + box-shadow 0 0 0 1px rgba(0, 0, 0, 0.2) + + @media (min-width 500px) + margin 16px auto + width calc(100% - 32px) +</style> diff --git a/src/server/web/app/mobile/views/pages/selectdrive.vue b/src/server/web/app/mobile/views/pages/selectdrive.vue new file mode 100644 index 0000000000..3480a0d103 --- /dev/null +++ b/src/server/web/app/mobile/views/pages/selectdrive.vue @@ -0,0 +1,96 @@ +<template> +<div class="mk-selectdrive"> + <header> + <h1>%i18n:mobile.tags.mk-selectdrive-page.select-file%<span class="count" v-if="files.length > 0">({{ files.length }})</span></h1> + <button class="upload" @click="upload">%fa:upload%</button> + <button v-if="multiple" class="ok" @click="ok">%fa:check%</button> + </header> + <mk-drive ref="browser" select-file :multiple="multiple" is-naked :top="42"/> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +export default Vue.extend({ + data() { + return { + files: [] + }; + }, + computed: { + multiple(): boolean { + const q = (new URL(location.toString())).searchParams; + return q.get('multiple') == 'true'; + } + }, + mounted() { + document.title = '%i18n:desktop.tags.mk-selectdrive-page.title%'; + }, + methods: { + onSelected(file) { + this.files = [file]; + this.ok(); + }, + onChangeSelection(files) { + this.files = files; + }, + upload() { + (this.$refs.browser as any).selectLocalFile(); + }, + close() { + window.close(); + }, + ok() { + window.opener.cb(this.multiple ? this.files : this.files[0]); + this.close(); + } + } +}); +</script> + +<style lang="stylus" scoped> +.mk-selectdrive + width 100% + height 100% + background #fff + + > header + position fixed + top 0 + left 0 + width 100% + z-index 1000 + background #fff + box-shadow 0 1px rgba(0, 0, 0, 0.1) + + > h1 + margin 0 + padding 0 + text-align center + line-height 42px + font-size 1em + font-weight normal + + > .count + margin-left 4px + opacity 0.5 + + > .upload + position absolute + top 0 + left 0 + line-height 42px + width 42px + + > .ok + position absolute + top 0 + right 0 + line-height 42px + width 42px + + > .mk-drive + top 42px + +</style> diff --git a/src/server/web/app/mobile/views/pages/settings.vue b/src/server/web/app/mobile/views/pages/settings.vue new file mode 100644 index 0000000000..3250999e12 --- /dev/null +++ b/src/server/web/app/mobile/views/pages/settings.vue @@ -0,0 +1,102 @@ +<template> +<mk-ui> + <span slot="header">%fa:cog%%i18n:mobile.tags.mk-settings-page.settings%</span> + <div :class="$style.content"> + <p v-html="'%i18n:mobile.tags.mk-settings.signed-in-as%'.replace('{}', '<b>' + os.i.name + '</b>')"></p> + <ul> + <li><router-link to="./settings/profile">%fa:user%%i18n:mobile.tags.mk-settings-page.profile%%fa:angle-right%</router-link></li> + <li><router-link to="./settings/authorized-apps">%fa:puzzle-piece%%i18n:mobile.tags.mk-settings-page.applications%%fa:angle-right%</router-link></li> + <li><router-link to="./settings/twitter">%fa:B twitter%%i18n:mobile.tags.mk-settings-page.twitter-integration%%fa:angle-right%</router-link></li> + <li><router-link to="./settings/signin-history">%fa:sign-in-alt%%i18n:mobile.tags.mk-settings-page.signin-history%%fa:angle-right%</router-link></li> + </ul> + <ul> + <li><a @click="signout">%fa:power-off%%i18n:mobile.tags.mk-settings-page.signout%</a></li> + </ul> + <p><small>ver {{ v }} (葵 aoi)</small></p> + </div> +</mk-ui> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { version } from '../../../config'; + +export default Vue.extend({ + data() { + return { + v: version + }; + }, + mounted() { + document.title = 'Misskey | %i18n:mobile.tags.mk-settings-page.settings%'; + document.documentElement.style.background = '#313a42'; + }, + methods: { + signout() { + (this as any).os.signout(); + } + } +}); +</script> + +<style lang="stylus" module> +.content + + > p + display block + margin 24px + text-align center + color #cad2da + + > ul + $radius = 8px + + display block + margin 16px auto + padding 0 + max-width 500px + width calc(100% - 32px) + list-style none + background #fff + border solid 1px rgba(0, 0, 0, 0.2) + border-radius $radius + + > li + display block + border-bottom solid 1px #ddd + + &:hover + background rgba(0, 0, 0, 0.1) + + &:first-child + border-top-left-radius $radius + border-top-right-radius $radius + + &:last-child + border-bottom-left-radius $radius + border-bottom-right-radius $radius + border-bottom none + + > a + $height = 48px + + display block + position relative + padding 0 16px + line-height $height + color #4d635e + + > [data-fa]:nth-of-type(1) + margin-right 4px + + > [data-fa]:nth-of-type(2) + display block + position absolute + top 0 + right 8px + z-index 1 + padding 0 20px + font-size 1.2em + line-height $height + +</style> diff --git a/src/server/web/app/mobile/views/pages/signup.vue b/src/server/web/app/mobile/views/pages/signup.vue new file mode 100644 index 0000000000..9dc07a4b86 --- /dev/null +++ b/src/server/web/app/mobile/views/pages/signup.vue @@ -0,0 +1,57 @@ +<template> +<div class="signup"> + <h1>Misskeyをはじめる</h1> + <p>いつでも、どこからでもMisskeyを利用できます。もちろん、無料です。</p> + <div class="form"> + <p>新規登録</p> + <div> + <mk-signup/> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + mounted() { + document.documentElement.style.background = '#293946'; + } +}); +</script> + +<style lang="stylus" scoped> +.signup + padding 16px + margin 0 auto + max-width 500px + + h1 + margin 0 + padding 8px + font-size 1.5em + font-weight normal + color #c3c6ca + + & + p + margin 0 0 16px 0 + padding 0 8px 0 8px + color #949fa9 + + .form + background #fff + border solid 1px rgba(0, 0, 0, 0.2) + border-radius 8px + overflow hidden + + > p + margin 0 + padding 12px 20px + color #555 + background #f5f5f5 + border-bottom solid 1px #ddd + + > div + padding 16px + +</style> diff --git a/src/server/web/app/mobile/views/pages/user.vue b/src/server/web/app/mobile/views/pages/user.vue new file mode 100644 index 0000000000..7ff897e42d --- /dev/null +++ b/src/server/web/app/mobile/views/pages/user.vue @@ -0,0 +1,247 @@ +<template> +<mk-ui> + <span slot="header" v-if="!fetching">%fa:user% {{ user.name }}</span> + <main v-if="!fetching"> + <header> + <div class="banner" :style="user.banner_url ? `background-image: url(${user.banner_url}?thumbnail&size=1024)` : ''"></div> + <div class="body"> + <div class="top"> + <a class="avatar"> + <img :src="`${user.avatar_url}?thumbnail&size=200`" alt="avatar"/> + </a> + <mk-follow-button v-if="os.isSignedIn && os.i.id != user.id" :user="user"/> + </div> + <div class="title"> + <h1>{{ user.name }}</h1> + <span class="username">@{{ acct }}</span> + <span class="followed" v-if="user.is_followed">%i18n:mobile.tags.mk-user.follows-you%</span> + </div> + <div class="description">{{ user.description }}</div> + <div class="info"> + <p class="location" v-if="user.host === null && user.account.profile.location"> + %fa:map-marker%{{ user.account.profile.location }} + </p> + <p class="birthday" v-if="user.host === null && user.account.profile.birthday"> + %fa:birthday-cake%{{ user.account.profile.birthday.replace('-', '年').replace('-', '月') + '日' }} ({{ age }}歳) + </p> + </div> + <div class="status"> + <a> + <b>{{ user.posts_count | number }}</b> + <i>%i18n:mobile.tags.mk-user.posts%</i> + </a> + <a :href="`@${acct}/following`"> + <b>{{ user.following_count | number }}</b> + <i>%i18n:mobile.tags.mk-user.following%</i> + </a> + <a :href="`@${acct}/followers`"> + <b>{{ user.followers_count | number }}</b> + <i>%i18n:mobile.tags.mk-user.followers%</i> + </a> + </div> + </div> + </header> + <nav> + <div class="nav-container"> + <a :data-is-active=" page == 'home' " @click="page = 'home'">%i18n:mobile.tags.mk-user.overview%</a> + <a :data-is-active=" page == 'posts' " @click="page = 'posts'">%i18n:mobile.tags.mk-user.timeline%</a> + <a :data-is-active=" page == 'media' " @click="page = 'media'">%i18n:mobile.tags.mk-user.media%</a> + </div> + </nav> + <div class="body"> + <x-home v-if="page == 'home'" :user="user"/> + <mk-user-timeline v-if="page == 'posts'" :user="user"/> + <mk-user-timeline v-if="page == 'media'" :user="user" with-media/> + </div> + </main> +</mk-ui> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import * as age from 's-age'; +import getAcct from '../../../../../common/user/get-acct'; +import getAcct from '../../../../../common/user/parse-acct'; +import Progress from '../../../common/scripts/loading'; +import XHome from './user/home.vue'; + +export default Vue.extend({ + components: { + XHome + }, + data() { + return { + fetching: true, + user: null, + page: 'home' + }; + }, + computed: { + acct() { + return this.getAcct(this.user); + }, + age(): number { + return age(this.user.account.profile.birthday); + } + }, + watch: { + $route: 'fetch' + }, + created() { + this.fetch(); + }, + mounted() { + document.documentElement.style.background = '#313a42'; + }, + methods: { + fetch() { + Progress.start(); + + (this as any).api('users/show', parseAcct(this.$route.params.user)).then(user => { + this.user = user; + this.fetching = false; + + Progress.done(); + document.title = user.name + ' | Misskey'; + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +main + > header + + > .banner + padding-bottom 33.3% + background-color #1b1b1b + background-size cover + background-position center + + > .body + padding 12px + margin 0 auto + max-width 600px + + > .top + &:after + content '' + display block + clear both + + > .avatar + display block + float left + width 25% + height 40px + + > img + display block + position absolute + left -2px + bottom -2px + width 100% + border 3px solid #313a42 + border-radius 6px + + @media (min-width 500px) + left -4px + bottom -4px + border 4px solid #313a42 + border-radius 12px + + > .mk-follow-button + float right + height 40px + + > .title + margin 8px 0 + + > h1 + margin 0 + line-height 22px + font-size 20px + color #fff + + > .username + display inline-block + line-height 20px + font-size 16px + font-weight bold + color #657786 + + > .followed + margin-left 8px + padding 2px 4px + font-size 12px + color #657786 + background #f8f8f8 + border-radius 4px + + > .description + margin 8px 0 + color #fff + + > .info + margin 8px 0 + + > p + display inline + margin 0 16px 0 0 + color #a9b9c1 + + > i + margin-right 4px + + > .status + > a + color #657786 + + &:not(:last-child) + margin-right 16px + + > b + margin-right 4px + font-size 16px + color #fff + + > i + font-size 14px + + > nav + position sticky + top 48px + box-shadow 0 4px 4px rgba(0, 0, 0, 0.3) + background-color #313a42 + z-index 1 + > .nav-container + display flex + justify-content center + margin 0 auto + max-width 600px + + > a + display block + flex 1 1 + text-align center + line-height 52px + font-size 14px + text-decoration none + color #657786 + border-bottom solid 2px transparent + + &[data-is-active] + font-weight bold + color $theme-color + border-color $theme-color + + > .body + padding 8px + + @media (min-width 500px) + padding 16px + +</style> diff --git a/src/server/web/app/mobile/views/pages/user/home.followers-you-know.vue b/src/server/web/app/mobile/views/pages/user/home.followers-you-know.vue new file mode 100644 index 0000000000..1a2b8f7083 --- /dev/null +++ b/src/server/web/app/mobile/views/pages/user/home.followers-you-know.vue @@ -0,0 +1,67 @@ +<template> +<div class="root followers-you-know"> + <p class="initializing" v-if="fetching">%fa:spinner .pulse .fw%%i18n:mobile.tags.mk-user-overview-followers-you-know.loading%<mk-ellipsis/></p> + <div v-if="!fetching && users.length > 0"> + <a v-for="user in users" :key="user.id" :href="`/@${getAcct(user)}`"> + <img :src="`${user.avatar_url}?thumbnail&size=64`" :alt="user.name"/> + </a> + </div> + <p class="empty" v-if="!fetching && users.length == 0">%i18n:mobile.tags.mk-user-overview-followers-you-know.no-users%</p> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import getAcct from '../../../../../../common/user/get-acct'; + +export default Vue.extend({ + props: ['user'], + data() { + return { + fetching: true, + users: [] + }; + }, + methods: { + getAcct + }, + mounted() { + (this as any).api('users/followers', { + user_id: this.user.id, + iknow: true, + limit: 30 + }).then(res => { + this.fetching = false; + this.users = res.users; + }); + } +}); +</script> + +<style lang="stylus" scoped> +.root.followers-you-know + + > div + padding 4px + + > 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> diff --git a/src/server/web/app/mobile/views/pages/user/home.friends.vue b/src/server/web/app/mobile/views/pages/user/home.friends.vue new file mode 100644 index 0000000000..b37f1a2fe8 --- /dev/null +++ b/src/server/web/app/mobile/views/pages/user/home.friends.vue @@ -0,0 +1,54 @@ +<template> +<div class="root friends"> + <p class="fetching" v-if="fetching">%fa:spinner .pulse .fw%%i18n:mobile.tags.mk-user-overview-frequently-replied-users.loading%<mk-ellipsis/></p> + <div v-if="!fetching && users.length > 0"> + <mk-user-card v-for="user in users" :key="user.id" :user="user"/> + </div> + <p class="empty" v-if="!fetching && users.length == 0">%i18n:mobile.tags.mk-user-overview-frequently-replied-users.no-users%</p> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + props: ['user'], + data() { + return { + fetching: true, + users: [] + }; + }, + mounted() { + (this as any).api('users/get_frequently_replied_users', { + user_id: this.user.id + }).then(res => { + this.users = res.map(x => x.user); + this.fetching = false; + }); + } +}); +</script> + +<style lang="stylus" scoped> +.root.friends + > div + overflow-x scroll + -webkit-overflow-scrolling touch + white-space nowrap + padding 8px + + > .mk-user-card + &:not(:last-child) + margin-right 8px + + > .fetching + > .empty + margin 0 + padding 16px + text-align center + color #aaa + + > i + margin-right 4px + +</style> diff --git a/src/server/web/app/mobile/views/pages/user/home.photos.vue b/src/server/web/app/mobile/views/pages/user/home.photos.vue new file mode 100644 index 0000000000..f12f59a407 --- /dev/null +++ b/src/server/web/app/mobile/views/pages/user/home.photos.vue @@ -0,0 +1,83 @@ +<template> +<div class="root photos"> + <p class="initializing" v-if="fetching">%fa:spinner .pulse .fw%%i18n:mobile.tags.mk-user-overview-photos.loading%<mk-ellipsis/></p> + <div class="stream" v-if="!fetching && images.length > 0"> + <a v-for="image in images" + class="img" + :style="`background-image: url(${image.media.url}?thumbnail&size=256)`" + :href="`/@${getAcct(image.post.user)}/${image.post.id}`" + ></a> + </div> + <p class="empty" v-if="!fetching && images.length == 0">%i18n:mobile.tags.mk-user-overview-photos.no-photos%</p> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import getAcct from '../../../../../../common/user/get-acct'; + +export default Vue.extend({ + props: ['user'], + data() { + return { + fetching: true, + images: [] + }; + }, + methods: { + getAcct + }, + mounted() { + (this as any).api('users/posts', { + user_id: this.user.id, + with_media: true, + limit: 6 + }).then(posts => { + posts.forEach(post => { + post.media.forEach(media => { + if (this.images.length < 9) this.images.push({ + post, + media + }); + }); + }); + this.fetching = false; + }); + } +}); +</script> + +<style lang="stylus" scoped> +.root.photos + + > .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 + border-radius 4px + + > .initializing + > .empty + margin 0 + padding 16px + text-align center + color #aaa + + > i + margin-right 4px + +</style> + diff --git a/src/server/web/app/mobile/views/pages/user/home.posts.vue b/src/server/web/app/mobile/views/pages/user/home.posts.vue new file mode 100644 index 0000000000..70b20ce943 --- /dev/null +++ b/src/server/web/app/mobile/views/pages/user/home.posts.vue @@ -0,0 +1,57 @@ +<template> +<div class="root posts"> + <p class="fetching" v-if="fetching">%fa:spinner .pulse .fw%%i18n:mobile.tags.mk-user-overview-posts.loading%<mk-ellipsis/></p> + <div v-if="!fetching && posts.length > 0"> + <mk-post-card v-for="post in posts" :key="post.id" :post="post"/> + </div> + <p class="empty" v-if="!fetching && posts.length == 0">%i18n:mobile.tags.mk-user-overview-posts.no-posts%</p> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + props: ['user'], + data() { + return { + fetching: true, + posts: [] + }; + }, + mounted() { + (this as any).api('users/posts', { + user_id: this.user.id + }).then(posts => { + this.posts = posts; + this.fetching = false; + }); + } +}); +</script> + +<style lang="stylus" scoped> +.root.posts + + > div + overflow-x scroll + -webkit-overflow-scrolling touch + white-space nowrap + padding 8px + + > * + vertical-align top + + &:not(:last-child) + margin-right 8px + + > .fetching + > .empty + margin 0 + padding 16px + text-align center + color #aaa + + > i + margin-right 4px + +</style> diff --git a/src/server/web/app/mobile/views/pages/user/home.vue b/src/server/web/app/mobile/views/pages/user/home.vue new file mode 100644 index 0000000000..e3def61512 --- /dev/null +++ b/src/server/web/app/mobile/views/pages/user/home.vue @@ -0,0 +1,94 @@ +<template> +<div class="root home"> + <mk-post-detail v-if="user.pinned_post" :post="user.pinned_post" :compact="true"/> + <section class="recent-posts"> + <h2>%fa:R comments%%i18n:mobile.tags.mk-user-overview.recent-posts%</h2> + <div> + <x-posts :user="user"/> + </div> + </section> + <section class="images"> + <h2>%fa:image%%i18n:mobile.tags.mk-user-overview.images%</h2> + <div> + <x-photos :user="user"/> + </div> + </section> + <section class="activity"> + <h2>%fa:chart-bar%%i18n:mobile.tags.mk-user-overview.activity%</h2> + <div> + <mk-activity :user="user"/> + </div> + </section> + <section class="frequently-replied-users"> + <h2>%fa:users%%i18n:mobile.tags.mk-user-overview.frequently-replied-users%</h2> + <div> + <x-friends :user="user"/> + </div> + </section> + <section class="followers-you-know" v-if="os.isSignedIn && os.i.id !== user.id"> + <h2>%fa:users%%i18n:mobile.tags.mk-user-overview.followers-you-know%</h2> + <div> + <x-followers-you-know :user="user"/> + </div> + </section> + <p v-if="user.host === null">%i18n:mobile.tags.mk-user-overview.last-used-at%: <b><mk-time :time="user.account.last_used_at"/></b></p> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import XPosts from './home.posts.vue'; +import XPhotos from './home.photos.vue'; +import XFriends from './home.friends.vue'; +import XFollowersYouKnow from './home.followers-you-know.vue'; + +export default Vue.extend({ + components: { + XPosts, + XPhotos, + XFriends, + XFollowersYouKnow + }, + props: ['user'] +}); +</script> + +<style lang="stylus" scoped> +.root.home + max-width 600px + margin 0 auto + + > .mk-post-detail + margin 0 0 8px 0 + + > section + background #eee + border-radius 8px + box-shadow 0 0 0 1px rgba(0, 0, 0, 0.2) + + &:not(:last-child) + margin-bottom 8px + + > h2 + margin 0 + padding 8px 10px + font-size 15px + font-weight normal + color #465258 + background #fff + border-radius 8px 8px 0 0 + + > i + margin-right 6px + + > .activity + > div + padding 8px + + > p + display block + margin 16px + text-align center + color #cad2da + +</style> diff --git a/src/server/web/app/mobile/views/pages/welcome.vue b/src/server/web/app/mobile/views/pages/welcome.vue new file mode 100644 index 0000000000..3384ee6997 --- /dev/null +++ b/src/server/web/app/mobile/views/pages/welcome.vue @@ -0,0 +1,206 @@ +<template> +<div class="welcome"> + <h1><b>Misskey</b>へようこそ</h1> + <p>Twitter風ミニブログSNS、Misskeyへようこそ。共有したいことを投稿したり、タイムラインでみんなの投稿を読むこともできます。<br><a href="/signup">アカウントを作成する</a></p> + <div class="form"> + <p>%fa:lock% ログイン</p> + <div> + <form @submit.prevent="onSubmit"> + <input v-model="username" type="text" pattern="^[a-zA-Z0-9-]+$" placeholder="ユーザー名" autofocus required @change="onUsernameChange"/> + <input v-model="password" type="password" placeholder="パスワード" required/> + <input v-if="user && user.account.two_factor_enabled" v-model="token" type="number" placeholder="トークン" required/> + <button type="submit" :disabled="signing">{{ signing ? 'ログインしています' : 'ログイン' }}</button> + </form> + <div> + <a :href="`${apiUrl}/signin/twitter`">Twitterでログイン</a> + </div> + </div> + </div> + <div class="tl"> + <p>%fa:comments R% タイムラインを見てみる</p> + <mk-welcome-timeline/> + </div> + <div class="users"> + <router-link v-for="user in users" :key="user.id" class="avatar-anchor" :to="`/@${user.username}`"> + <img class="avatar" :src="`${user.avatar_url}?thumbnail&size=64`" alt="avatar"/> + </router-link> + </div> + <footer> + <small>{{ copyright }}</small> + </footer> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { apiUrl, copyright } from '../../../config'; + +export default Vue.extend({ + data() { + return { + signing: false, + user: null, + username: '', + password: '', + token: '', + apiUrl, + copyright, + users: [] + }; + }, + mounted() { + (this as any).api('users', { + sort: '+follower', + limit: 20 + }).then(users => { + this.users = users; + }); + }, + methods: { + onUsernameChange() { + (this as any).api('users/show', { + username: this.username + }).then(user => { + this.user = user; + }); + }, + onSubmit() { + this.signing = true; + + (this as any).api('signin', { + username: this.username, + password: this.password, + token: this.user && this.user.account.two_factor_enabled ? this.token : undefined + }).then(() => { + location.reload(); + }).catch(() => { + alert('something happened'); + this.signing = false; + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +.welcome + padding 16px + margin 0 auto + max-width 500px + + h1 + margin 0 + padding 8px + font-size 1.5em + font-weight normal + color #cacac3 + + & + p + margin 0 0 16px 0 + padding 0 8px 0 8px + color #949fa9 + + .form + margin-bottom 16px + background #fff + border solid 1px rgba(0, 0, 0, 0.2) + border-radius 8px + overflow hidden + + > p + margin 0 + padding 12px 20px + color #555 + background #f5f5f5 + border-bottom solid 1px #ddd + + > div + + > form + padding 16px + border-bottom solid 1px #ddd + + input + display block + padding 12px + margin 0 0 16px 0 + width 100% + font-size 1em + color rgba(0, 0, 0, 0.7) + background #fff + outline none + border solid 1px #ddd + border-radius 4px + + button + display block + width 100% + padding 10px + margin 0 + color #333 + font-size 1em + text-align center + text-decoration none + text-shadow 0 1px 0 rgba(255, 255, 255, 0.9) + background-image linear-gradient(#fafafa, #eaeaea) + border 1px solid #ddd + border-bottom-color #cecece + border-radius 4px + + &:active + background-color #767676 + background-image none + border-color #444 + box-shadow 0 1px 3px rgba(0, 0, 0, 0.075), inset 0 0 5px rgba(0, 0, 0, 0.2) + + > div + padding 16px + text-align center + + > .tl + background #fff + border solid 1px rgba(0, 0, 0, 0.2) + border-radius 8px + overflow hidden + + > p + margin 0 + padding 12px 20px + color #555 + background #f5f5f5 + border-bottom solid 1px #ddd + + > .mk-welcome-timeline + max-height 300px + overflow auto + + > .users + margin 12px 0 0 0 + + > * + display inline-block + margin 4px + + > * + display inline-block + width 38px + height 38px + vertical-align top + border-radius 6px + + > footer + text-align center + color #fff + + > small + display block + margin 16px 0 0 0 + opacity 0.7 + +</style> + +<style lang="stylus"> +html +body + background linear-gradient(to bottom, #1e1d65, #bd6659) +</style> diff --git a/src/server/web/app/mobile/views/widgets/activity.vue b/src/server/web/app/mobile/views/widgets/activity.vue new file mode 100644 index 0000000000..48dcafb3ed --- /dev/null +++ b/src/server/web/app/mobile/views/widgets/activity.vue @@ -0,0 +1,32 @@ +<template> +<div class="mkw-activity"> + <mk-widget-container :show-header="!props.compact"> + <template slot="header">%fa:chart-bar%アクティビティ</template> + <div :class="$style.body"> + <mk-activity :user="os.i"/> + </div> + </mk-widget-container> +</div> +</template> + +<script lang="ts"> +import define from '../../../common/define-widget'; + +export default define({ + name: 'activity', + props: () => ({ + compact: false + }) +}).extend({ + methods: { + func() { + this.props.compact = !this.props.compact; + } + } +}); +</script> + +<style lang="stylus" module> +.body + padding 8px +</style> diff --git a/src/server/web/app/mobile/views/widgets/index.ts b/src/server/web/app/mobile/views/widgets/index.ts new file mode 100644 index 0000000000..4de912b64c --- /dev/null +++ b/src/server/web/app/mobile/views/widgets/index.ts @@ -0,0 +1,7 @@ +import Vue from 'vue'; + +import wActivity from './activity.vue'; +import wProfile from './profile.vue'; + +Vue.component('mkw-activity', wActivity); +Vue.component('mkw-profile', wProfile); diff --git a/src/server/web/app/mobile/views/widgets/profile.vue b/src/server/web/app/mobile/views/widgets/profile.vue new file mode 100644 index 0000000000..1c9d038b4c --- /dev/null +++ b/src/server/web/app/mobile/views/widgets/profile.vue @@ -0,0 +1,62 @@ +<template> +<div class="mkw-profile"> + <mk-widget-container> + <div :class="$style.banner" + :style="os.i.banner_url ? `background-image: url(${os.i.banner_url}?thumbnail&size=256)` : ''" + ></div> + <img :class="$style.avatar" + :src="`${os.i.avatar_url}?thumbnail&size=96`" + alt="avatar" + /> + <router-link :class="$style.name" :to="`/@${os.i.username}`">{{ os.i.name }}</router-link> + </mk-widget-container> +</div> +</template> + +<script lang="ts"> +import define from '../../../common/define-widget'; +export default define({ + name: 'profile' +}); +</script> + +<style lang="stylus" module> +.banner + height 100px + background-color #f5f5f5 + background-size cover + background-position center + cursor pointer + +.banner:before + content "" + display block + width 100% + height 100% + background rgba(0, 0, 0, 0.5) + +.avatar + display block + position absolute + width 58px + height 58px + margin 0 + vertical-align bottom + 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 + display block + position absolute + top 0 + left 92px + margin 0 + line-height 100px + color #fff + font-weight bold + text-shadow 0 0 8px rgba(0, 0, 0, 0.5) + +</style> diff --git a/src/server/web/app/reset.styl b/src/server/web/app/reset.styl new file mode 100644 index 0000000000..10bd3113a2 --- /dev/null +++ b/src/server/web/app/reset.styl @@ -0,0 +1,32 @@ +input:not([type]) +input[type='text'] +input[type='password'] +input[type='search'] +input[type='email'] +textarea +button +progress + -webkit-appearance none + -moz-appearance none + appearance none + box-shadow none + +textarea + font-family sans-serif + +button + margin 0 + background transparent + border none + cursor pointer + color inherit + + * + pointer-events none + + &[disabled] + cursor default + +pre + overflow auto + white-space pre diff --git a/src/server/web/app/safe.js b/src/server/web/app/safe.js new file mode 100644 index 0000000000..2fd5361725 --- /dev/null +++ b/src/server/web/app/safe.js @@ -0,0 +1,31 @@ +/** + * ブラウザの検証 + */ + +// Detect an old browser +if (!('fetch' in window)) { + alert( + 'お使いのブラウザが古いためMisskeyを動作させることができません。' + + 'バージョンを最新のものに更新するか、別のブラウザをお試しください。' + + '\n\n' + + 'Your browser seems outdated. ' + + 'To run Misskey, please update your browser to latest version or try other browsers.'); +} + +// 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/server/web/app/stats/style.styl b/src/server/web/app/stats/style.styl new file mode 100644 index 0000000000..5ae230ea56 --- /dev/null +++ b/src/server/web/app/stats/style.styl @@ -0,0 +1,10 @@ +@import "../app" +@import "../reset" + +html + color #456267 + background #fff + +body + margin 0 + padding 0 diff --git a/src/server/web/app/stats/tags/index.tag b/src/server/web/app/stats/tags/index.tag new file mode 100644 index 0000000000..4b167ccbc8 --- /dev/null +++ b/src/server/web/app/stats/tags/index.tag @@ -0,0 +1,209 @@ +<mk-index> + <h1>Misskey<i>Statistics</i></h1> + <main v-if="!initializing"> + <mk-users stats={ stats }/> + <mk-posts stats={ stats }/> + </main> + <footer><a href={ _URL_ }>{ _HOST_ }</a></footer> + <style lang="stylus" scoped> + :scope + display block + margin 0 auto + padding 0 16px + max-width 700px + + > h1 + margin 0 + padding 24px 0 0 0 + font-size 24px + font-weight normal + + > i + font-style normal + color #f43b16 + + > main + > * + margin 24px 0 + padding-top 24px + border-top solid 1px #eee + + > h2 + margin 0 0 12px 0 + font-size 18px + font-weight normal + + > footer + margin 24px 0 + text-align center + + > a + color #546567 + </style> + <script lang="typescript"> + this.mixin('api'); + + this.initializing = true; + + this.on('mount', () => { + this.$root.$data.os.api('stats').then(stats => { + this.update({ + initializing: false, + stats + }); + }); + }); + </script> +</mk-index> + +<mk-posts> + <h2>%i18n:stats.posts-count% <b>{ stats.posts_count }</b></h2> + <mk-posts-chart v-if="!initializing" data={ data }/> + <style lang="stylus" scoped> + :scope + display block + </style> + <script lang="typescript"> + this.mixin('api'); + + this.initializing = true; + this.stats = this.opts.stats; + + this.on('mount', () => { + this.$root.$data.os.api('aggregation/posts', { + limit: 365 + }).then(data => { + this.update({ + initializing: false, + data + }); + }); + }); + </script> +</mk-posts> + +<mk-users> + <h2>%i18n:stats.users-count% <b>{ stats.users_count }</b></h2> + <mk-users-chart v-if="!initializing" data={ data }/> + <style lang="stylus" scoped> + :scope + display block + </style> + <script lang="typescript"> + this.mixin('api'); + + this.initializing = true; + this.stats = this.opts.stats; + + this.on('mount', () => { + this.$root.$data.os.api('aggregation/users', { + limit: 365 + }).then(data => { + this.update({ + initializing: false, + data + }); + }); + }); + </script> +</mk-users> + +<mk-posts-chart> + <svg riot-viewBox="0 0 { viewBoxX } { viewBoxY }" preserveAspectRatio="none"> + <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 lang="stylus" scoped> + :scope + display block + + > svg + display block + padding 1px + width 100% + </style> + <script lang="typescript"> + this.viewBoxX = 365; + this.viewBoxY = 80; + + 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},${(1 - (d.posts / peak)) * this.viewBoxY}`).join(' '), + pointsReply: this.data.map((d, i) => `${i},${(1 - (d.replies / peak)) * this.viewBoxY}`).join(' '), + pointsRepost: this.data.map((d, i) => `${i},${(1 - (d.reposts / peak)) * this.viewBoxY}`).join(' '), + pointsTotal: this.data.map((d, i) => `${i},${(1 - (d.total / peak)) * this.viewBoxY}`).join(' ') + }); + }; + </script> +</mk-posts-chart> + +<mk-users-chart> + <svg riot-viewBox="0 0 { viewBoxX } { viewBoxY }" preserveAspectRatio="none"> + <polyline + riot-points={ createdPoints } + fill="none" + stroke-width="1" + stroke="#1cde84"/> + <polyline + riot-points={ totalPoints } + fill="none" + stroke-width="1" + stroke="#555"/> + </svg> + <style lang="stylus" scoped> + :scope + display block + + > svg + display block + padding 1px + width 100% + </style> + <script lang="typescript"> + this.viewBoxX = 365; + this.viewBoxY = 80; + + this.data = this.opts.data.reverse(); + const totalPeak = Math.max.apply(null, this.data.map(d => d.total)); + const createdPeak = Math.max.apply(null, this.data.map(d => d.created)); + + this.on('mount', () => { + this.render(); + }); + + this.render = () => { + this.update({ + totalPoints: this.data.map((d, i) => `${i},${(1 - (d.total / totalPeak)) * this.viewBoxY}`).join(' '), + createdPoints: this.data.map((d, i) => `${i},${(1 - (d.created / createdPeak)) * this.viewBoxY}`).join(' ') + }); + }; + </script> +</mk-users-chart> diff --git a/src/server/web/app/stats/tags/index.ts b/src/server/web/app/stats/tags/index.ts new file mode 100644 index 0000000000..f41151949f --- /dev/null +++ b/src/server/web/app/stats/tags/index.ts @@ -0,0 +1 @@ +require('./index.tag'); diff --git a/src/server/web/app/status/style.styl b/src/server/web/app/status/style.styl new file mode 100644 index 0000000000..5ae230ea56 --- /dev/null +++ b/src/server/web/app/status/style.styl @@ -0,0 +1,10 @@ +@import "../app" +@import "../reset" + +html + color #456267 + background #fff + +body + margin 0 + padding 0 diff --git a/src/server/web/app/status/tags/index.tag b/src/server/web/app/status/tags/index.tag new file mode 100644 index 0000000000..899467097a --- /dev/null +++ b/src/server/web/app/status/tags/index.tag @@ -0,0 +1,201 @@ +<mk-index> + <h1>Misskey<i>Status</i></h1> + <p>%fa:info-circle%%i18n:status.all-systems-maybe-operational%</p> + <main> + <mk-cpu-usage connection={ connection }/> + <mk-mem-usage connection={ connection }/> + </main> + <footer><a href={ _URL_ }>{ _HOST_ }</a></footer> + <style lang="stylus" scoped> + :scope + display block + margin 0 auto + padding 0 16px + max-width 700px + + > h1 + margin 0 + padding 24px 0 16px 0 + font-size 24px + font-weight normal + + > [data-fa] + font-style normal + color #f43b16 + + > p + display block + margin 0 + padding 12px 16px + background #eaf4ef + //border solid 1px #99ccb2 + border-radius 4px + + > [data-fa] + margin-right 5px + + > main + > * + margin 24px 0 + + > h2 + margin 0 0 12px 0 + font-size 18px + font-weight normal + + > footer + margin 24px 0 + text-align center + + > a + color #546567 + </style> + <script lang="typescript"> + import Connection from '../../common/scripts/streaming/server-stream'; + + this.mixin('api'); + + this.initializing = true; + this.connection = new Connection(); + + this.on('mount', () => { + this.$root.$data.os.api('meta').then(meta => { + this.update({ + initializing: false, + meta + }); + }); + }); + + this.on('unmount', () => { + this.connection.close(); + }); + + </script> +</mk-index> + +<mk-cpu-usage> + <h2>CPU <b>{ percentage }%</b></h2> + <mk-line-chart ref="chart"/> + <style lang="stylus" scoped> + :scope + display block + </style> + <script lang="typescript"> + this.connection = this.opts.connection; + + this.on('mount', () => { + this.connection.on('stats', this.onStats); + }); + + this.on('unmount', () => { + this.connection.off('stats', this.onStats); + }); + + this.onStats = stats => { + this.$refs.chart.addData(1 - stats.cpu_usage); + + const percentage = (stats.cpu_usage * 100).toFixed(0); + + this.update({ + percentage + }); + }; + </script> +</mk-cpu-usage> + +<mk-mem-usage> + <h2>MEM <b>{ percentage }%</b></h2> + <mk-line-chart ref="chart"/> + <style lang="stylus" scoped> + :scope + display block + </style> + <script lang="typescript"> + this.connection = this.opts.connection; + + this.on('mount', () => { + this.connection.on('stats', this.onStats); + }); + + this.on('unmount', () => { + this.connection.off('stats', this.onStats); + }); + + this.onStats = stats => { + stats.mem.used = stats.mem.total - stats.mem.free; + this.$refs.chart.addData(1 - (stats.mem.used / stats.mem.total)); + + const percentage = (stats.mem.used / stats.mem.total * 100).toFixed(0); + + this.update({ + percentage + }); + }; + </script> +</mk-mem-usage> + +<mk-line-chart> + <svg riot-viewBox="0 0 { viewBoxX } { viewBoxY }" preserveAspectRatio="none"> + <defs> + <linearGradient id={ gradientId } x1="0" x2="0" y1="1" y2="0"> + <stop offset="0%" stop-color="rgba(244, 59, 22, 0)"></stop> + <stop offset="100%" stop-color="#f43b16"></stop> + </linearGradient> + <mask id={ maskId } x="0" y="0" riot-width={ viewBoxX } riot-height={ viewBoxY }> + <polygon + riot-points={ polygonPoints } + fill="#fff" + fill-opacity="0.5"/> + </mask> + </defs> + <line x1="0" y1="0" riot-x2={ viewBoxX } y2="0" stroke="rgba(255, 255, 255, 0.1)" stroke-width="0.25" stroke-dasharray="1"/> + <line x1="0" y1="25%" riot-x2={ viewBoxX } y2="25%" stroke="rgba(255, 255, 255, 0.1)" stroke-width="0.25" stroke-dasharray="1"/> + <line x1="0" y1="50%" riot-x2={ viewBoxX } y2="50%" stroke="rgba(255, 255, 255, 0.1)" stroke-width="0.25" stroke-dasharray="1"/> + <line x1="0" y1="75%" riot-x2={ viewBoxX } y2="75%" stroke="rgba(255, 255, 255, 0.1)" stroke-width="0.25" stroke-dasharray="1"/> + <line x1="0" y1="100%" riot-x2={ viewBoxX } y2="100%" stroke="rgba(255, 255, 255, 0.1)" stroke-width="0.25" stroke-dasharray="1"/> + <rect + x="-1" y="-1" + riot-width={ viewBoxX + 2 } riot-height={ viewBoxY + 2 } + style="stroke: none; fill: url(#{ gradientId }); mask: url(#{ maskId })"/> + <polyline + riot-points={ polylinePoints } + fill="none" + stroke="#f43b16" + stroke-width="0.5"/> + </svg> + <style lang="stylus" scoped> + :scope + display block + padding 16px + border-radius 8px + background #1c2531 + + > svg + display block + padding 1px + width 100% + </style> + <script lang="typescript"> + import uuid from 'uuid'; + + this.viewBoxX = 100; + this.viewBoxY = 30; + this.data = []; + this.gradientId = uuid(); + this.maskId = uuid(); + + this.addData = data => { + this.data.push(data); + if (this.data.length > 100) this.data.shift(); + + const polylinePoints = this.data.map((d, i) => `${this.viewBoxX - ((this.data.length - 1) - i)},${d * this.viewBoxY}`).join(' '); + const polygonPoints = `${this.viewBoxX - (this.data.length - 1)},${ this.viewBoxY } ${ polylinePoints } ${ this.viewBoxX },${ this.viewBoxY }`; + + this.update({ + polylinePoints, + polygonPoints + }); + }; + </script> +</mk-line-chart> diff --git a/src/server/web/app/status/tags/index.ts b/src/server/web/app/status/tags/index.ts new file mode 100644 index 0000000000..f41151949f --- /dev/null +++ b/src/server/web/app/status/tags/index.ts @@ -0,0 +1 @@ +require('./index.tag'); diff --git a/src/server/web/app/sw.js b/src/server/web/app/sw.js new file mode 100644 index 0000000000..669703b16c --- /dev/null +++ b/src/server/web/app/sw.js @@ -0,0 +1,71 @@ +/** + * Service Worker + */ + +import composeNotification from './common/scripts/compose-notification'; + +// キャッシュするリソース +const cachee = [ + '/' +]; + +// インストールされたとき +self.addEventListener('install', ev => { + console.info('installed'); + + ev.waitUntil(Promise.all([ + self.skipWaiting(), // Force activate + caches.open(_VERSION_).then(cache => cache.addAll(cachee)) // Cache + ])); +}); + +// アクティベートされたとき +self.addEventListener('activate', ev => { + // Clean up old caches + ev.waitUntil( + caches.keys().then(keys => Promise.all( + keys + .filter(key => key != _VERSION_) + .map(key => caches.delete(key)) + )) + ); +}); + +// リクエストが発生したとき +self.addEventListener('fetch', ev => { + ev.respondWith( + // キャッシュがあるか確認してあればそれを返す + caches.match(ev.request).then(response => + response || fetch(ev.request) + ) + ); +}); + +// プッシュ通知を受け取ったとき +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, + }); + })); +}); + +self.addEventListener('message', ev => { + if (ev.data == 'clear') { + caches.keys().then(keys => keys.forEach(key => caches.delete(key))); + } +}); diff --git a/src/server/web/app/tsconfig.json b/src/server/web/app/tsconfig.json new file mode 100644 index 0000000000..e31b52dab1 --- /dev/null +++ b/src/server/web/app/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "allowJs": true, + "noEmitOnError": false, + "noImplicitAny": false, + "noImplicitReturns": true, + "noUnusedParameters": false, + "noUnusedLocals": true, + "noFallthroughCasesInSwitch": true, + "declaration": false, + "sourceMap": false, + "target": "es2017", + "module": "commonjs", + "removeComments": false, + "noLib": false, + "strict": true, + "strictNullChecks": false + }, + "compileOnSave": false, + "include": [ + "./**/*.ts" + ] +} diff --git a/src/server/web/app/v.d.ts b/src/server/web/app/v.d.ts new file mode 100644 index 0000000000..8f3a240d80 --- /dev/null +++ b/src/server/web/app/v.d.ts @@ -0,0 +1,4 @@ +declare module "*.vue" { + import Vue from 'vue'; + export default Vue; +} diff --git a/src/server/web/assets/404.js b/src/server/web/assets/404.js new file mode 100644 index 0000000000..f897f0db6b --- /dev/null +++ b/src/server/web/assets/404.js @@ -0,0 +1,21 @@ +const yn = window.confirm( + 'サーバー上に存在しないスクリプトがリクエストされました。お使いのMisskeyのバージョンが古いことが原因の可能性があります。Misskeyを更新しますか?'); + +if (yn) { + // Clear cache (serive worker) + try { + navigator.serviceWorker.controller.postMessage('clear'); + + navigator.serviceWorker.getRegistrations().then(registrations => { + registrations.forEach(registration => registration.unregister()); + }); + } catch (e) { + console.error(e); + } + + localStorage.removeItem('v'); + + location.reload(true); +} else { + alert('問題が解決しない場合はサーバー管理者までお問い合せください。'); +} diff --git a/src/server/web/assets/code-highlight.css b/src/server/web/assets/code-highlight.css new file mode 100644 index 0000000000..f0807dc9c3 --- /dev/null +++ b/src/server/web/assets/code-highlight.css @@ -0,0 +1,93 @@ +.hljs { + font-family: Consolas, 'Courier New', Courier, Monaco, monospace; +} + +.hljs, +.hljs-subst { + color: #444; +} + +.hljs-comment { + color: #888888; +} + +.hljs-keyword { + color: #2973b7; +} + +.hljs-number { + color: #ae81ff; +} + +.hljs-string { + color: #e96900; +} + +.hljs-regexp { + color: #e9003f; +} + +.hljs-attribute, +.hljs-selector-tag, +.hljs-meta-keyword, +.hljs-doctag, +.hljs-name { + font-weight: bold; +} + +.hljs-type, +.hljs-selector-id, +.hljs-selector-class, +.hljs-quote, +.hljs-template-tag, +.hljs-deletion { + color: #880000; +} + +.hljs-title, +.hljs-section { + color: #880000; + font-weight: bold; +} + +.hljs-symbol, +.hljs-variable, +.hljs-template-variable, +.hljs-link, +.hljs-selector-attr, +.hljs-selector-pseudo { + color: #BC6060; +} + +/* Language color: hue: 90; */ + +.hljs-literal { + color: #78A960; +} + +.hljs-built_in, +.hljs-bullet, +.hljs-code, +.hljs-addition { + color: #397300; +} + +/* Meta color: hue: 200 */ + +.hljs-meta { + color: #1f7199; +} + +.hljs-meta-string { + color: #4d99bf; +} + +/* Misc effects */ + +.hljs-emphasis { + font-style: italic; +} + +.hljs-strong { + font-weight: bold; +} diff --git a/src/server/web/assets/error.jpg b/src/server/web/assets/error.jpg Binary files differnew file mode 100644 index 0000000000..872b1a3f5d --- /dev/null +++ b/src/server/web/assets/error.jpg diff --git a/src/server/web/assets/favicon.ico b/src/server/web/assets/favicon.ico Binary files differnew file mode 100644 index 0000000000..ed9820d5f4 --- /dev/null +++ b/src/server/web/assets/favicon.ico diff --git a/src/server/web/assets/label.svg b/src/server/web/assets/label.svg new file mode 100644 index 0000000000..b1f85f3c07 --- /dev/null +++ b/src/server/web/assets/label.svg @@ -0,0 +1,6 @@ +<?xml version="1.0" encoding="utf-8"?>
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px"
+ y="0px" width="96px" height="96px" viewBox="0 0 96 96" enable-background="new 0 0 96 96" xml:space="preserve">
+<polygon fill="#0B8AEA" points="0,45.255 45.254,0 84.854,0 0,84.854 "/>
+</svg>
diff --git a/src/server/web/assets/manifest.json b/src/server/web/assets/manifest.json new file mode 100644 index 0000000000..783d0539ac --- /dev/null +++ b/src/server/web/assets/manifest.json @@ -0,0 +1,7 @@ +{ + "short_name": "Misskey", + "name": "Misskey", + "start_url": "/", + "display": "standalone", + "background_color": "#313a42" +} diff --git a/src/server/web/assets/message.mp3 b/src/server/web/assets/message.mp3 Binary files differnew file mode 100644 index 0000000000..6427744475 --- /dev/null +++ b/src/server/web/assets/message.mp3 diff --git a/src/server/web/assets/othello-put-me.mp3 b/src/server/web/assets/othello-put-me.mp3 Binary files differnew file mode 100644 index 0000000000..4e0e72091c --- /dev/null +++ b/src/server/web/assets/othello-put-me.mp3 diff --git a/src/server/web/assets/othello-put-you.mp3 b/src/server/web/assets/othello-put-you.mp3 Binary files differnew file mode 100644 index 0000000000..9244189c2d --- /dev/null +++ b/src/server/web/assets/othello-put-you.mp3 diff --git a/src/server/web/assets/post.mp3 b/src/server/web/assets/post.mp3 Binary files differnew file mode 100644 index 0000000000..d3da88a933 --- /dev/null +++ b/src/server/web/assets/post.mp3 diff --git a/src/server/web/assets/reactions/angry.png b/src/server/web/assets/reactions/angry.png Binary files differnew file mode 100644 index 0000000000..d81c431a25 --- /dev/null +++ b/src/server/web/assets/reactions/angry.png diff --git a/src/server/web/assets/reactions/confused.png b/src/server/web/assets/reactions/confused.png Binary files differnew file mode 100644 index 0000000000..cfaa60146f --- /dev/null +++ b/src/server/web/assets/reactions/confused.png diff --git a/src/server/web/assets/reactions/congrats.png b/src/server/web/assets/reactions/congrats.png Binary files differnew file mode 100644 index 0000000000..350adda322 --- /dev/null +++ b/src/server/web/assets/reactions/congrats.png diff --git a/src/server/web/assets/reactions/hmm.png b/src/server/web/assets/reactions/hmm.png Binary files differnew file mode 100644 index 0000000000..a9a7e9ac88 --- /dev/null +++ b/src/server/web/assets/reactions/hmm.png diff --git a/src/server/web/assets/reactions/laugh.png b/src/server/web/assets/reactions/laugh.png Binary files differnew file mode 100644 index 0000000000..cd2225ffe1 --- /dev/null +++ b/src/server/web/assets/reactions/laugh.png diff --git a/src/server/web/assets/reactions/like.png b/src/server/web/assets/reactions/like.png Binary files differnew file mode 100644 index 0000000000..9fe67c9109 --- /dev/null +++ b/src/server/web/assets/reactions/like.png diff --git a/src/server/web/assets/reactions/love.png b/src/server/web/assets/reactions/love.png Binary files differnew file mode 100644 index 0000000000..b8a7532ef0 --- /dev/null +++ b/src/server/web/assets/reactions/love.png diff --git a/src/server/web/assets/reactions/pudding.png b/src/server/web/assets/reactions/pudding.png Binary files differnew file mode 100644 index 0000000000..27a6b048e8 --- /dev/null +++ b/src/server/web/assets/reactions/pudding.png diff --git a/src/server/web/assets/reactions/surprise.png b/src/server/web/assets/reactions/surprise.png Binary files differnew file mode 100644 index 0000000000..5904cb2c6c --- /dev/null +++ b/src/server/web/assets/reactions/surprise.png diff --git a/src/server/web/assets/recover.html b/src/server/web/assets/recover.html new file mode 100644 index 0000000000..4922b68d35 --- /dev/null +++ b/src/server/web/assets/recover.html @@ -0,0 +1,36 @@ +<!DOCTYPE html> + +<html> + <head> + <meta charset="utf-8"> + <title>Misskeyのリカバリ</title> + <script> + + const yn = window.confirm('キャッシュをクリアしますか?(他のタブでMisskeyを開いている状態だと正常にクリアできないので、他のMisskeyのタブをすべて閉じてから行ってください)\n\nDo you want to clear caches?'); + + if (yn) { + try { + navigator.serviceWorker.controller.postMessage('clear'); + + navigator.serviceWorker.getRegistrations().then(registrations => { + registrations.forEach(registration => registration.unregister()); + }); + + } catch (e) { + console.error(e); + } + + alert('キャッシュをクリアしました。'); + + alert('まもなくページを再度読み込みします。再度読み込みが終わると、再度キャッシュをクリアするか尋ねられるので、「キャンセル」を選択して抜けてください。'); + + setTimeout(() => { + location.reload(true); + }, 100); + } else { + location.href = '/'; + } + + </script> + </head> +</html> diff --git a/src/server/web/assets/title.svg b/src/server/web/assets/title.svg new file mode 100644 index 0000000000..747fcd38b1 --- /dev/null +++ b/src/server/web/assets/title.svg @@ -0,0 +1,25 @@ +<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 16.0.3, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg version="1.1" id="レイヤー_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px"
+ y="0px" width="256px" height="256px" viewBox="0 0 256 256" enable-background="new 0 0 256 256" xml:space="preserve">
+<circle fill="#2B2F2D" cx="128" cy="153.6" r="19.201"/>
+<circle fill="#2B2F2D" cx="51.2" cy="153.6" r="19.2"/>
+<circle fill="#2B2F2D" cx="204.8" cy="153.6" r="19.2"/>
+<polyline fill="none" stroke="#2B2F2D" stroke-width="16" stroke-linejoin="round" stroke-miterlimit="10" points="51.2,153.6
+ 89.601,102.4 128,153.6 166.4,102.4 204.799,153.6 "/>
+<circle fill="#2B2F2D" cx="89.6" cy="102.4" r="19.2"/>
+<circle fill="#2B2F2D" cx="166.4" cy="102.4" r="19.199"/>
+<g>
+</g>
+<g>
+</g>
+<g>
+</g>
+<g>
+</g>
+<g>
+</g>
+<g>
+</g>
+</svg>
diff --git a/src/server/web/assets/unread.svg b/src/server/web/assets/unread.svg new file mode 100644 index 0000000000..8c3cc9f475 --- /dev/null +++ b/src/server/web/assets/unread.svg @@ -0,0 +1,7 @@ +<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 16.0.3, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg version="1.1" id="レイヤー_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px"
+ y="0px" width="32px" height="32px" viewBox="0 0 32 32" enable-background="new 0 0 32 32" xml:space="preserve">
+<circle fill="#3AA2DC" cx="16.5" cy="16.5" r="6"/>
+</svg>
diff --git a/src/server/web/assets/welcome-bg.svg b/src/server/web/assets/welcome-bg.svg new file mode 100644 index 0000000000..ba8cd8dc0a --- /dev/null +++ b/src/server/web/assets/welcome-bg.svg @@ -0,0 +1,579 @@ +<?xml version="1.0" encoding="UTF-8" standalone="no"?> +<!-- Created with Inkscape (http://www.inkscape.org/) --> + +<svg + xmlns:osb="http://www.openswatchbook.org/uri/2009/osb" + 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:xlink="http://www.w3.org/1999/xlink" + xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" + xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" + width="1920" + height="1080" + viewBox="0 0 507.99999 285.75001" + version="1.1" + id="svg8" + inkscape:version="0.92.1 r15371" + sodipodi:docname="welcome-bg.svg"> + <defs + id="defs2"> + <pattern + inkscape:collect="always" + xlink:href="#Checkerboard" + id="pattern7194" + patternTransform="scale(1.3152942)" /> + <linearGradient + id="linearGradient7169" + inkscape:collect="always"> + <stop + id="stop7165" + offset="0" + style="stop-color:#eaeaea;stop-opacity:1" /> + <stop + id="stop7167" + offset="1" + style="stop-color:#000000;stop-opacity:1" /> + </linearGradient> + <linearGradient + inkscape:collect="always" + id="linearGradient7044"> + <stop + style="stop-color:#000000;stop-opacity:1;" + offset="0" + id="stop7040" /> + <stop + style="stop-color:#ffffff;stop-opacity:1" + offset="1" + id="stop7042" /> + </linearGradient> + <pattern + inkscape:collect="always" + xlink:href="#Checkerboard" + id="pattern7010" + patternTransform="matrix(1.673813,0,0,1.673813,-177.6001,-146.38611)" /> + <pattern + inkscape:stockid="Checkerboard" + id="Checkerboard" + patternTransform="translate(0,0) scale(10,10)" + height="2" + width="2" + patternUnits="userSpaceOnUse" + inkscape:collect="always" + inkscape:isstock="true"> + <rect + id="rect6201" + height="1" + width="1" + y="0" + x="0" + style="fill:black;stroke:none" /> + <rect + id="rect6203" + height="1" + width="1" + y="1" + x="1" + style="fill:black;stroke:none" /> + </pattern> + <linearGradient + id="linearGradient5406" + osb:paint="solid"> + <stop + style="stop-color:#000000;stop-opacity:1;" + offset="0" + id="stop5404" /> + </linearGradient> + <pattern + patternUnits="userSpaceOnUse" + width="15.999999" + height="16.000025" + patternTransform="matrix(0.26458333,0,0,0.26458333,-16.933332,263.1333)" + id="pattern6465"> + <path + d="m 8.0000542,8.0000126 h 7.9998878 c 3e-5,0 5.7e-5,3.78e-5 5.7e-5,3.78e-5 V 15.99995 c 0,3.7e-5 -2.7e-5,7.5e-5 -5.7e-5,7.5e-5 H 8.0000542 c -3.03e-5,0 -5.67e-5,-3.8e-5 -5.67e-5,-7.5e-5 V 8.0000504 c 0,0 2.64e-5,-3.78e-5 5.67e-5,-3.78e-5 z M 5.6692913e-5,0 H 7.9999408 c 3.02e-5,0 5.67e-5,3.7795275e-5 5.67e-5,7.5590551e-5 V 7.999937 c 0,3.78e-5 -2.65e-5,7.56e-5 -5.67e-5,7.56e-5 H 5.6692913e-5 C 2.2677165e-5,8.0000126 0,7.9999748 0,7.999937 V 7.5590551e-5 C 0,3.7795276e-5 2.2677165e-5,0 5.6692913e-5,0 Z" + style="opacity:1;fill:#db1545;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:15.99999905;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" + id="rect6445-2" + inkscape:connector-curvature="0" /> + </pattern> + <linearGradient + inkscape:collect="always" + xlink:href="#linearGradient7044" + id="linearGradient6476" + gradientUnits="userSpaceOnUse" + gradientTransform="matrix(3.223659,0,0,2.5556636,-579.27357,808.39)" + x1="86.490868" + y1="-216.62756" + x2="176.77992" + y2="-216.62756" /> + <mask + maskUnits="userSpaceOnUse" + id="mask6472"> + <rect + transform="rotate(-90)" + y="-0.91986513" + x="-300.45657" + height="511.36566" + width="291.06116" + id="rect6474" + style="opacity:1;fill:url(#linearGradient6476);fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.92238116;stroke-miterlimit:4;stroke-dasharray:none" /> + </mask> + <pattern + patternUnits="userSpaceOnUse" + width="2340.7208" + height="2340.7236" + patternTransform="matrix(0.26458333,0,0,0.26458333,-63.499801,-58.601683)" + id="pattern7142"> + <path + d="m 1170.3684,1170.3628 h 1170.3448 c 0,0 0.01,0 0.01,0 v 1170.3457 c 0,0 0,0.011 -0.01,0.011 H 1170.3684 c 0,0 -0.01,0 -0.01,-0.011 v -1170.344 c 0,0 0,0 0.01,0 z M 0.00869291,1.1338583e-5 H 1170.352 c 0,0 0.01,0.0052913414 0.01,0.01096063142 V 1170.3511 c 0,0 0,0.011 -0.01,0.011 H 0.00869291 C 0.00340157,1170.3625 0,1170.3549 0,1170.3511 V 0.01096063 C 0,0.00566929 0.00312945,0 0.00869291,0 Z" + style="opacity:1;fill:#763971;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:2340.72119141;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" + id="path7135" + inkscape:connector-curvature="0" /> + </pattern> + <linearGradient + inkscape:collect="always" + xlink:href="#linearGradient7169" + id="linearGradient7157" + x1="-3.631536" + y1="155.11069" + x2="511.52777" + y2="155.11069" + gradientUnits="userSpaceOnUse" + gradientTransform="matrix(2.184742,0,0,6.5696504,-17.948376,-1979.8074)" /> + <linearGradient + inkscape:collect="always" + xlink:href="#linearGradient7169" + id="linearGradient7200" + gradientUnits="userSpaceOnUse" + gradientTransform="matrix(0.57804632,0,0,1.73822,6.5011419,-523.82404)" + x1="-3.631536" + y1="155.11069" + x2="511.52777" + y2="155.11069" /> + <mask + maskUnits="userSpaceOnUse" + id="mask7196"> + <rect + transform="rotate(90)" + y="-512.56537" + x="4.4019437" + height="516.7157" + width="297.78595" + id="rect7198" + style="opacity:1;fill:url(#linearGradient7200);fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:2.1217103;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" /> + </mask> + </defs> + <sodipodi:namedview + id="base" + pagecolor="#1e1d65" + bordercolor="#666666" + borderopacity="1.0" + inkscape:pageopacity="0.84705882" + inkscape:pageshadow="2" + inkscape:zoom="0.79170474" + inkscape:cx="1093.7227" + inkscape:cy="695.27372" + inkscape:document-units="mm" + inkscape:current-layer="layer5" + showgrid="true" + units="px" + inkscape:pagecheckerboard="false" + inkscape:window-width="1920" + inkscape:window-height="1017" + inkscape:window-x="-8" + inkscape:window-y="1072" + inkscape:window-maximized="1" + objecttolerance="1" + guidetolerance="10000" + gridtolerance="10000" + inkscape:snap-bbox="true" + inkscape:bbox-paths="true" + inkscape:bbox-nodes="true" + inkscape:snap-bbox-edge-midpoints="true" + inkscape:snap-bbox-midpoints="true" + showguides="false" + inkscape:lockguides="true"> + <inkscape:grid + type="xygrid" + id="grid6443" + spacingx="2.1166667" + spacingy="2.1166667" + empspacing="4" + color="#3f3fff" + opacity="0.1254902" + enabled="false" /> + <sodipodi:guide + position="-69.219003,3.872392" + orientation="1,0" + id="guide6508" + inkscape:locked="true" /> + </sodipodi:namedview> + <metadata + id="metadata5"> + <rdf:RDF> + <cc:Work + rdf:about=""> + <dc:format>image/svg+xml</dc:format> + <dc:type + rdf:resource="http://purl.org/dc/dcmitype/StillImage" /> + <dc:title /> + </cc:Work> + </rdf:RDF> + </metadata> + <g + inkscape:label="レイヤー 1" + inkscape:groupmode="layer" + id="layer1" + transform="translate(0,-11.249983)" + style="display:inline" + sodipodi:insensitive="true"> + <rect + style="display:inline;opacity:0.2;fill:url(#pattern7194);fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:2.11666656;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" + id="rect7178" + width="568.07599" + height="367.82269" + x="-37.871731" + y="-52.665051" + mask="url(#mask7196)" /> + </g> + <g + inkscape:groupmode="layer" + id="layer2" + inkscape:label="レイヤー 2" + style="display:inline"> + <rect + transform="translate(0,-11.249983)" + style="opacity:0.5;fill:none;stroke:none;stroke-width:140.99996948" + width="596.8999" + height="596.90082" + x="-63.49987" + y="-58.600021" + id="rect6468" + mask="url(#mask6472)" /> + <path + transform="translate(0,-11.249983)" + sodipodi:type="star" + style="fill:#000000;fill-opacity:0.09661835;fill-rule:nonzero;stroke:none;stroke-width:0.26499999;stroke-miterlimit:4;stroke-dasharray:none" + id="path6921" + sodipodi:sides="4" + sodipodi:cx="117.63232" + sodipodi:cy="102.13793" + sodipodi:r1="5.7652407" + sodipodi:r2="2.8826203" + sodipodi:arg1="1.4464413" + sodipodi:arg2="2.2318395" + inkscape:flatsided="false" + inkscape:rounded="0" + inkscape:randomized="0" + d="m 118.34741,107.85865 -2.48485,-3.44532 -3.95096,-1.56031 3.44531,-2.48485 1.56032,-3.950959 2.48484,3.445318 3.95097,1.560311 -3.44532,2.48485 z" + inkscape:transform-center-x="1.481982e-006" + inkscape:transform-center-y="-1.1450451e-006" /> + <path + transform="translate(0,-11.249983)" + sodipodi:type="star" + style="fill:#000000;fill-opacity:0.09661835;fill-rule:nonzero;stroke:none;stroke-width:0.26499999;stroke-miterlimit:4;stroke-dasharray:none" + id="path6923" + sodipodi:sides="4" + sodipodi:cx="317.5" + sodipodi:cy="75.679596" + sodipodi:r1="3.949214" + sodipodi:r2="1.974607" + sodipodi:arg1="1.6614562" + sodipodi:arg2="2.4468544" + inkscape:flatsided="false" + inkscape:rounded="0" + inkscape:randomized="0" + d="m 317.14246,79.612591 -1.1594,-2.668882 -2.41606,-1.621658 2.66889,-1.15939 1.62165,-2.41606 1.1594,2.668882 2.41606,1.621658 -2.66889,1.15939 z" + inkscape:transform-center-x="4.0000001e-006" /> + <path + transform="translate(0,-11.249983)" + sodipodi:type="star" + style="fill:#000000;fill-opacity:0.09661835;fill-rule:nonzero;stroke:none;stroke-width:0.26499999;stroke-miterlimit:4;stroke-dasharray:none" + id="path6925" + sodipodi:sides="4" + sodipodi:cx="230.97409" + sodipodi:cy="57.802349" + sodipodi:r1="2.2613134" + sodipodi:r2="1.1306567" + sodipodi:arg1="1.2490458" + sodipodi:arg2="2.0344439" + inkscape:flatsided="false" + inkscape:rounded="0" + inkscape:randomized="0" + d="m 231.68918,59.947619 -1.22073,-1.13398 -1.63963,-0.2962 1.13398,-1.220735 0.2962,-1.639625 1.22074,1.13398 1.63962,0.2962 -1.13398,1.220735 z" + inkscape:transform-center-x="2.9099099e-006" /> + <path + transform="translate(0,-11.249983)" + sodipodi:type="star" + style="fill:#000000;fill-opacity:0.09661835;fill-rule:nonzero;stroke:none;stroke-width:0.26499999;stroke-miterlimit:4;stroke-dasharray:none" + id="path6927" + sodipodi:sides="4" + sodipodi:cx="260.65033" + sodipodi:cy="106.42847" + sodipodi:r1="1.59899" + sodipodi:r2="0.79949504" + sodipodi:arg1="2.0344439" + sodipodi:arg2="2.8198421" + inkscape:flatsided="false" + inkscape:rounded="0" + inkscape:randomized="0" + d="m 259.93524,107.85865 -0.0434,-1.17736 -0.67171,-0.96791 1.17736,-0.0434 0.96791,-0.67171 0.0434,1.17735 0.67171,0.96792 -1.17736,0.0434 z" + inkscape:transform-center-x="3.2837838e-006" + inkscape:transform-center-y="-1.1990991e-006" /> + <path + sodipodi:type="star" + style="fill:#000000;fill-opacity:0.09661835;fill-rule:nonzero;stroke:none;stroke-width:0.26499999;stroke-miterlimit:4;stroke-dasharray:none" + id="path6925-2" + sodipodi:sides="4" + sodipodi:cx="87.956078" + sodipodi:cy="127.16609" + sodipodi:r1="2.2613134" + sodipodi:r2="1.1306567" + sodipodi:arg1="1.2490458" + sodipodi:arg2="2.0344439" + inkscape:flatsided="false" + inkscape:rounded="0" + inkscape:randomized="0" + d="m 88.671168,129.31136 -1.220735,-1.13398 -1.639626,-0.2962 1.13398,-1.22073 0.296201,-1.63963 1.220735,1.13398 1.639625,0.2962 -1.13398,1.22074 z" + inkscape:transform-center-x="2.4830149e-006" + transform="matrix(0.91666666,0,0,1,7.1509006,-11.249983)" /> + <ellipse + style="opacity:0.68000034;fill:#6e76a3;fill-opacity:1;stroke:none;stroke-width:0.06383465" + id="path5313-3-7" + cx="178.44102" + cy="110.95996" + rx="21.691566" + ry="5.0825601" + transform="rotate(-1.570553,-410.38805,-5.6250559)" /> + <ellipse + style="opacity:0.68000034;fill:#6e76a3;fill-opacity:1;stroke:none;stroke-width:0.08063243" + id="path5313-3-7-5" + cx="200.1326" + cy="116.80371" + rx="27.399597" + ry="6.4200115" + transform="rotate(-1.570553,-410.38805,-5.6250559)" /> + <ellipse + style="opacity:0.68000034;fill:#6e76a3;fill-opacity:1;stroke:none;stroke-width:0.06734787" + id="path5313-3-7-2" + cx="-429.23041" + cy="90.631134" + rx="24.144913" + ry="5.0825605" + transform="matrix(-0.99537478,-0.09606802,-0.09606802,0.99537478,0,-11.249983)" /> + <ellipse + style="opacity:0.68000034;fill:#6e76a3;fill-opacity:1;stroke:none;stroke-width:0.08507013" + id="path5313-3-7-5-9" + cx="-405.08548" + cy="96.474884" + rx="30.498529" + ry="6.4200115" + transform="matrix(-0.99537478,-0.09606802,-0.09606802,0.99537478,0,-11.249983)" /> + <ellipse + style="opacity:0.68000034;fill:#6e76a3;fill-opacity:1;stroke:none;stroke-width:0.05208009" + id="path5313-3-7-2-9" + cx="-46.428764" + cy="163.90004" + rx="18.893074" + ry="3.884198" + transform="matrix(-0.99073724,0.13579293,0.14607844,0.98927301,0,-11.249983)" /> + <ellipse + style="opacity:0.68000034;fill:#6e76a3;fill-opacity:1;stroke:none;stroke-width:0.06578472" + id="path5313-3-7-5-9-1" + cx="-27.535677" + cy="168.36595" + rx="23.864695" + ry="4.9063048" + transform="matrix(-0.99073724,0.13579293,0.14607844,0.98927301,0,-11.249983)" /> + <path + transform="translate(0,-11.249983)" + sodipodi:type="star" + style="fill:#000000;fill-opacity:0.09661835;fill-rule:nonzero;stroke:none;stroke-width:0.26499999;stroke-miterlimit:4;stroke-dasharray:none" + id="path6923-9" + sodipodi:sides="4" + sodipodi:cx="459.82239" + sodipodi:cy="139.8455" + sodipodi:r1="3.949214" + sodipodi:r2="1.9746071" + sodipodi:arg1="1.6614562" + sodipodi:arg2="2.4468544" + inkscape:flatsided="false" + inkscape:rounded="0" + inkscape:randomized="0" + d="m 459.46484,143.7785 -1.15939,-2.66888 -2.41606,-1.62166 2.66889,-1.15939 1.62165,-2.41606 1.15939,2.66888 2.41606,1.62166 -2.66888,1.15939 z" + inkscape:transform-center-x="4.0000001e-006" /> + <circle + transform="translate(0,-11.249983)" + style="opacity:0.1;fill:none;fill-opacity:1;fill-rule:nonzero;stroke:#ffffff;stroke-width:0.81509405;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" + id="path5229" + cx="192.18326" + cy="74.677902" + r="2.7216933" /> + <path + sodipodi:type="star" + style="fill:#ffffff;fill-opacity:0.09661835;fill-rule:nonzero;stroke:none;stroke-width:0.26499999;stroke-miterlimit:4;stroke-dasharray:none" + id="path6923-8" + sodipodi:sides="4" + sodipodi:cx="53.989292" + sodipodi:cy="88.908768" + sodipodi:r1="3.949214" + sodipodi:r2="1.9746071" + sodipodi:arg1="1.6614562" + sodipodi:arg2="2.4468544" + inkscape:flatsided="false" + inkscape:rounded="0" + inkscape:randomized="0" + d="m 53.631747,92.841763 -1.15939,-2.668883 -2.41606,-1.621657 2.668883,-1.159391 1.621657,-2.41606 1.15939,2.668883 2.416061,1.621658 -2.668883,1.15939 z" + inkscape:transform-center-x="2.0634674e-006" + transform="matrix(0.61390676,-0.48689202,0.48689202,0.61390676,-23.159158,48.648961)" + inkscape:transform-center-y="1.4320049e-006" /> + <path + sodipodi:type="star" + style="fill:#ffffff;fill-opacity:0.09661835;fill-rule:nonzero;stroke:none;stroke-width:0.26499999;stroke-miterlimit:4;stroke-dasharray:none" + id="path6923-8-3" + sodipodi:sides="4" + sodipodi:cx="53.989292" + sodipodi:cy="88.908768" + sodipodi:r1="3.949214" + sodipodi:r2="1.9746071" + sodipodi:arg1="1.6614562" + sodipodi:arg2="2.4468544" + inkscape:flatsided="false" + inkscape:rounded="0" + inkscape:randomized="0" + d="m 53.631747,92.841763 -1.15939,-2.668883 -2.41606,-1.621657 2.668883,-1.159391 1.621657,-2.41606 1.15939,2.668883 2.416061,1.621658 -2.668883,1.15939 z" + inkscape:transform-center-x="3.0260172e-006" + transform="matrix(0.58032639,0.43093706,-0.43093706,0.58032639,446.58431,23.35553)" + inkscape:transform-center-y="-1.3594204e-006" /> + <circle + transform="translate(0,-11.249983)" + style="opacity:0.1;fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.28035584;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" + id="path5229-6" + cx="347.17841" + cy="36.709366" + r="0.9361406" /> + <circle + transform="translate(0,-11.249983)" + style="opacity:0.1;fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.28035584;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" + id="path5229-6-5" + cx="116.0927" + cy="42.136036" + r="0.9361406" /> + <circle + transform="translate(0,-11.249983)" + style="opacity:0.15;fill:none;fill-opacity:1;fill-rule:nonzero;stroke:#000000;stroke-width:0.55002564;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" + id="path5229-0" + cx="456.28247" + cy="47.488548" + r="1.8365992" /> + </g> + <g + inkscape:groupmode="layer" + id="layer5" + inkscape:label="レイヤー 4" + style="display:none"> + <path + transform="translate(0,-11.249983)" + style="display:inline;fill:#ffff7c;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.26499999;stroke-miterlimit:4;stroke-dasharray:none" + d="m 377.25876,69.781182 a 18.234796,18.234796 0 0 1 8.1747,15.19442 18.234796,18.234796 0 0 1 -18.23455,18.235058 18.234796,18.234796 0 0 1 -10.14098,-3.08921 20.380066,20.380066 0 0 0 17.64905,10.2402 20.380066,20.380066 0 0 0 20.38015,-20.380152 20.380066,20.380066 0 0 0 -17.82837,-20.200316 z" + id="path6914" + inkscape:connector-curvature="0" /> + </g> + <g + inkscape:groupmode="layer" + id="layer4" + inkscape:label="レイヤー 3" + style="display:none"> + <circle + style="display:inline;opacity:0.1;fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:#000000;stroke-width:1.36438358;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" + id="path5306" + cx="168.31279" + cy="2.1908164" + r="36.253109" /> + <path + style="display:inline;opacity:0.1;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1.39123487px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" + d="m 201.1259,19.428383 2.66976,2.617062 1.21734,-1.978474 -0.34264,5.194221 -4.15215,2.110811 1.0283,-1.928856 -2.76172,-2.210044 z" + id="path5168" + inkscape:connector-curvature="0" /> + <path + style="display:inline;opacity:0.1;fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.89719725px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" + d="m 196.25421,26.631949 6.0286,8.817373 -3.70059,3.384671 -1.84127,-4.638447 -2.48924,2.916491 -2.23471,-6.507119 z" + id="path5174" + inkscape:connector-curvature="0" /> + <path + style="display:inline;opacity:0.1;fill:#000500;fill-opacity:1;stroke:none;stroke-width:0.05121958px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" + d="m 187.00695,34.050482 1.26268,2.214392 1.44195,-0.54357 1.31981,0.86123 0.21375,1.739039 -1.36828,1.61618 -1.80409,0.265403 -1.1589,-1.059687 -0.23516,-1.721875 1.11047,-0.916698 -0.43413,-0.680502 -0.4102,0.997264 0.74387,1.070883 -0.49255,1.027197 -1.26776,0.228606 -0.5501,-0.871237 0.15467,-0.82956 0.93559,-0.424446 0.58058,-1.450625 -0.75664,-1.131455 z" + id="path6985" + inkscape:connector-curvature="0" + sodipodi:nodetypes="ccccccccccccccccccccc" /> + <path + style="display:inline;opacity:0.1;fill:#000016;fill-opacity:1;stroke:none;stroke-width:0.04695854px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" + d="m 203.23593,14.367789 4.43345,3.766934 0.87976,-0.995725 0.46812,0.475437 -0.80488,0.995031 0.83731,0.705238 0.86731,-0.962102 0.50998,0.516259 -0.87206,0.921255 0.99505,0.941692 -0.44277,0.42746 -0.91483,-0.900095 -0.8367,0.879711 -0.43031,-0.474867 0.78065,-0.831436 -0.86665,-0.779727 -0.81136,0.912638 -0.55866,-0.483362 0.8179,-0.927279 -4.48211,-3.638676 z" + id="path6891-8" + inkscape:connector-curvature="0" + sodipodi:nodetypes="ccccccccccccccccccccc" /> + <path + style="display:inline;opacity:0.05;fill:#000016;fill-opacity:1;stroke:none;stroke-width:0.58045781px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" + d="m 204.43932,-5.3971152 6.34563,7.5781721 -3.73895,4.9604312 0.33681,4.6546149 -5.20345,5.793617 c 2.83273,-8.049795 3.31033,-11.8140092 3.09986,-18.9271334 z" + id="path5208" + inkscape:connector-curvature="0" + sodipodi:nodetypes="ccccccc" /> + <path + style="display:inline;opacity:0.1;fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.11183073px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" + d="m 205.60259,0.56695919 1.24493,0.127049 0.0916,-0.59592195 0.28719,0.07174803 -0.065,0.56786179 0.62071,0.0788993 -0.0423,0.36840374 -0.62423,-0.048236 -0.0804,0.8381885 0.52004,0.075191 -0.0192,0.3709729 -0.5764,-0.058257 -0.10087,0.8125312 0.54747,0.039404 -0.04,0.4153104 -0.5593,-0.071919 -0.0636,0.6224815 -0.3736,0.00386 0.0816,-0.6437327 -1.20305,-0.1533942 0.0499,-0.3674909 1.2006,0.1064631 0.11092,-0.7647515 -1.19622,-0.1448386 0.027,-0.3701253 1.23042,0.1176518 0.12327,-0.8721654 -1.26199,-0.1134749 z" + id="path7229" + inkscape:connector-curvature="0" + sodipodi:nodetypes="ccccccccccccccccccccccccccccc" /> + <path + style="display:inline;opacity:0.1;fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.16325578px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" + d="m 204.68821,9.1424652 1.78173,-0.049987 -1.44996,0.7563273 1.12166,0.7127945 -1.34099,0.0029 0.93885,1.309289 -1.59949,-0.942185 z" + id="path7212-4-6" + inkscape:connector-curvature="0" /> + <path + style="display:inline;opacity:0.05;fill:#000016;fill-opacity:1;stroke:none;stroke-width:0.71902335px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" + d="m 180.87434,36.932251 -8.12162,8.095249 -6.61262,-3.934427 -5.68596,1.043018 -7.6496,-6.371879 c 10.33078,4.527622 19.43137,4.062311 28.0698,1.168039 z" + id="path5208-6" + inkscape:connector-curvature="0" + sodipodi:nodetypes="cccccc" /> + <path + style="display:inline;opacity:0.1;fill:#000016;fill-opacity:1;stroke:none;stroke-width:0.04569969px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" + d="m 156.79314,37.138611 -0.83209,5.600235 1.27513,0.214749 -0.15211,0.631281 -1.23602,-0.153244 -0.15211,1.0545 1.24093,0.221743 -0.16427,0.686859 -1.20964,-0.246683 -0.26626,1.306416 -0.58089,-0.145968 0.27316,-1.218758 -1.15712,-0.238846 0.17092,-0.599741 1.08842,0.21735 0.19853,-1.117028 -1.17126,-0.200972 0.11204,-0.710141 1.18676,0.198837 0.70106,-5.574493 z" + id="path6891-8-9" + inkscape:connector-curvature="0" + sodipodi:nodetypes="ccccccccccccccccccccc" /> + <path + style="display:inline;opacity:0.1;fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.84177661px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" + d="m 143.96364,29.933272 -4.59686,9.216397 3.65156,2.834687 1.22043,-4.692866 2.51661,2.524357 1.39851,-6.542721 z" + id="path5174-1" + inkscape:connector-curvature="0" /> + <path + style="display:inline;opacity:0.1;fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.56489706px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" + d="m 142.60658,28.70585 -2.96842,6.930652 -3.79379,-3.925042 4.56394,-5.124749 z" + id="path5285" + inkscape:connector-curvature="0" /> + <path + style="display:inline;opacity:0.1;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1.35393918px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" + d="m 137.9306,23.319484 -3.42616,1.224261 1.2143,1.906916 -4.40128,-2.508612 -0.0822,-4.53226 1.25123,1.720316 3.10894,-1.477793 z" + id="path5168-0" + inkscape:connector-curvature="0" /> + <path + style="display:inline;opacity:0.1;fill:#000500;fill-opacity:1;stroke:none;stroke-width:0.0498465px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" + d="m 132.55595,11.444656 -2.31852,0.882408 0.30663,1.468015 -1.02588,1.140069 -1.70428,-0.05499 -1.34908,-1.557886 0.015,-1.774566 1.1926,-0.955614 1.69096,0.03182 0.7151,1.205156 0.71942,-0.315492 -0.89748,-0.543864 -1.14121,0.554849 -0.91394,-0.627513 -0.0299,-1.2533405 0.92017,-0.3984462 0.77453,0.2730438 0.26797,0.9632459 1.30792,0.775623 1.20137,-0.558052 z" + id="path6985-7" + inkscape:connector-curvature="0" + sodipodi:nodetypes="ccccccccccccccccccccc" /> + <path + style="display:inline;opacity:0.1;fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.15882961px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" + d="m 131.32384,2.4817954 -1.6313,-0.4305236 1.16551,1.0474206 -1.19547,0.453907 1.23564,0.290212 -1.16202,1.0740836 1.68796,-0.5749329 z" + id="path7212-4-6-8" + inkscape:connector-curvature="0" /> + <path + style="display:inline;opacity:0.05;fill:#000016;fill-opacity:1;stroke:none;stroke-width:0.55575538px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" + d="m 137.04207,-21.420699 -7.13207,5.035868 1.31743,5.70794 -2.10914,4.1341529 2.26645,6.93249012 c 0.67636,-8.23493742 2.69888,-15.39599902 5.65733,-21.81045102 z" + id="path5208-4" + inkscape:connector-curvature="0" + sodipodi:nodetypes="cccccc" /> + </g> +</svg> diff --git a/src/server/web/assets/welcome-fg.svg b/src/server/web/assets/welcome-fg.svg new file mode 100644 index 0000000000..5c795c3027 --- /dev/null +++ b/src/server/web/assets/welcome-fg.svg @@ -0,0 +1,380 @@ +<?xml version="1.0" encoding="UTF-8" standalone="no"?> +<!-- Created with Inkscape (http://www.inkscape.org/) --> + +<svg + xmlns:osb="http://www.openswatchbook.org/uri/2009/osb" + 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:xlink="http://www.w3.org/1999/xlink" + xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" + xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" + width="1920" + height="1080" + viewBox="0 0 507.99999 285.75001" + version="1.1" + id="svg8" + inkscape:version="0.92.1 r15371" + sodipodi:docname="welcome-fg.svg"> + <defs + id="defs2"> + <linearGradient + inkscape:collect="always" + id="linearGradient7044"> + <stop + style="stop-color:#000000;stop-opacity:1;" + offset="0" + id="stop7040" /> + <stop + style="stop-color:#ffffff;stop-opacity:1" + offset="1" + id="stop7042" /> + </linearGradient> + <pattern + inkscape:collect="always" + xlink:href="#Checkerboard" + id="pattern7010" + patternTransform="matrix(1.673813,0,0,1.673813,-177.6001,-146.38611)" /> + <pattern + inkscape:stockid="Checkerboard" + id="Checkerboard" + patternTransform="translate(0,0) scale(10,10)" + height="2" + width="2" + patternUnits="userSpaceOnUse" + inkscape:collect="always"> + <rect + id="rect6201" + height="1" + width="1" + y="0" + x="0" + style="fill:black;stroke:none" /> + <rect + id="rect6203" + height="1" + width="1" + y="1" + x="1" + style="fill:black;stroke:none" /> + </pattern> + <linearGradient + id="linearGradient5406" + osb:paint="solid"> + <stop + style="stop-color:#000000;stop-opacity:1;" + offset="0" + id="stop5404" /> + </linearGradient> + <pattern + patternUnits="userSpaceOnUse" + width="15.999999" + height="16.000025" + patternTransform="matrix(0.26458333,0,0,0.26458333,-16.933332,263.1333)" + id="pattern6465"> + <path + d="m 8.0000542,8.0000126 h 7.9998878 c 3e-5,0 5.7e-5,3.78e-5 5.7e-5,3.78e-5 V 15.99995 c 0,3.7e-5 -2.7e-5,7.5e-5 -5.7e-5,7.5e-5 H 8.0000542 c -3.03e-5,0 -5.67e-5,-3.8e-5 -5.67e-5,-7.5e-5 V 8.0000504 c 0,0 2.64e-5,-3.78e-5 5.67e-5,-3.78e-5 z M 5.6692913e-5,0 H 7.9999408 c 3.02e-5,0 5.67e-5,3.7795275e-5 5.67e-5,7.5590551e-5 V 7.999937 c 0,3.78e-5 -2.65e-5,7.56e-5 -5.67e-5,7.56e-5 H 5.6692913e-5 C 2.2677165e-5,8.0000126 0,7.9999748 0,7.999937 V 7.5590551e-5 C 0,3.7795276e-5 2.2677165e-5,0 5.6692913e-5,0 Z" + style="opacity:1;fill:#db1545;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:15.99999905;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" + id="rect6445-2" + inkscape:connector-curvature="0" /> + </pattern> + <linearGradient + inkscape:collect="always" + xlink:href="#linearGradient7044" + id="linearGradient6476" + gradientUnits="userSpaceOnUse" + gradientTransform="matrix(3.223659,0,0,2.5556636,-579.27357,808.39)" + x1="86.490868" + y1="-216.62756" + x2="176.77992" + y2="-216.62756" /> + <mask + maskUnits="userSpaceOnUse" + id="mask6472"> + <rect + transform="rotate(-90)" + y="-0.91986513" + x="-300.45657" + height="511.36566" + width="291.06116" + id="rect6474" + style="opacity:1;fill:url(#linearGradient6476);fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.92238116;stroke-miterlimit:4;stroke-dasharray:none" /> + </mask> + </defs> + <sodipodi:namedview + id="base" + pagecolor="#1e1d65" + bordercolor="#666666" + borderopacity="1.0" + inkscape:pageopacity="0.84705882" + inkscape:pageshadow="2" + inkscape:zoom="0.6363961" + inkscape:cx="720.54406" + inkscape:cy="371.58659" + inkscape:document-units="mm" + inkscape:current-layer="layer1" + showgrid="true" + units="px" + inkscape:pagecheckerboard="true" + inkscape:window-width="1920" + inkscape:window-height="1057" + inkscape:window-x="1912" + inkscape:window-y="1143" + inkscape:window-maximized="1" + objecttolerance="1" + guidetolerance="10000" + gridtolerance="10000" + inkscape:snap-bbox="true" + inkscape:bbox-paths="true" + inkscape:bbox-nodes="true" + inkscape:snap-bbox-edge-midpoints="true" + inkscape:snap-bbox-midpoints="true" + showguides="false"> + <inkscape:grid + type="xygrid" + id="grid6443" + spacingx="2.1166667" + spacingy="2.1166667" + empspacing="4" + color="#3f3fff" + opacity="0.1254902" + enabled="false" /> + <sodipodi:guide + position="-69.219003,3.872392" + orientation="1,0" + id="guide6508" + inkscape:locked="false" /> + </sodipodi:namedview> + <metadata + id="metadata5"> + <rdf:RDF> + <cc:Work + rdf:about=""> + <dc:format>image/svg+xml</dc:format> + <dc:type + rdf:resource="http://purl.org/dc/dcmitype/StillImage" /> + <dc:title /> + </cc:Work> + </rdf:RDF> + </metadata> + <g + inkscape:groupmode="layer" + id="layer2" + inkscape:label="Back" + style="display:inline"> + <path + style="fill:#253276;fill-opacity:1;stroke:none;stroke-width:0.99999994px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" + d="m 500.58203,825.29688 -54.2207,18.9121 18.91406,56.74219 -45.39258,10.08594 -11.34765,-39.08789 -46.6543,12.60937 13.87109,34.04493 -55.48047,15.13086 -12.60937,-44.13086 -47.91406,13.86914 13.86914,44.13086 -32.78321,11.3496 17.65235,35.30469 278.66211,-63.04492 z m -11.0957,26.45312 0.44726,11.5918 -12.03711,2.67382 -3.5664,-9.80664 z m 4.90429,24.51953 0.89258,9.80859 -9.36328,2.67383 -4.45703,-9.36133 z m -201.5,32.09766 v 11.14453 l -8.4707,1.7832 -4.9043,-8.91601 z" + id="path4522" + inkscape:connector-curvature="0" + transform="scale(0.26458333)" /> + <path + transform="translate(0,-11.249983)" + style="fill:#253276;fill-opacity:1;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" + d="m 381.65643,238.28361 -47.37344,16.34717 116.09827,29.02457 -14.01186,-23.68672 -31.02626,-0.33362 z" + id="path4520" + inkscape:connector-curvature="0" /> + </g> + <g + inkscape:label="Ground" + inkscape:groupmode="layer" + id="layer1" + transform="translate(0,-11.249983)" + style="display:inline"> + <circle + style="fill:#172062;fill-opacity:1;stroke:none;stroke-width:1.99730551" + id="path5392" + cx="253.06117" + cy="887.61829" + r="642.68146" /> + </g> + <g + inkscape:groupmode="layer" + id="layer3" + inkscape:label="Front"> + <path + style="fill:#172062;fill-opacity:1;stroke:none;stroke-width:1.00157475;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" + d="m 565.38867,666.80078 -115.20508,24.36914 70.24414,231.09766 121.20118,-18.97656 8.61523,-148.01368 -76.28906,21.625 z m -30.15234,38.82813 3.09765,47.0625 -11.44531,2.49414 -9.14062,-46.10743 z m -26.41211,5.20898 10.30664,46.03906 -9.47852,2.06641 -17.14257,-44.88672 z m 41.45508,65.93945 2.80078,44.04493 -12.50391,3.40234 L 532.1543,781.75 Z m -25.15039,6.90039 9.4414,42.18165 -9.54297,2.59765 -13.99804,-40.91015 z m 85.48242,50.83789 1,42.35938 -22.15235,4.89648 -4.53906,-41.66406 z m -54.21485,10.16797 4.54102,41.66211 -7.67188,1.89649 -8.07421,-40.73047 z m -16.66992,4.20899 9.05469,40.45703 -8.88477,2.19727 -12.02734,-39.66016 z" + id="path5398" + transform="scale(0.26458333)" + inkscape:connector-curvature="0" /> + <path + transform="translate(0,-11.249983)" + style="fill:#172062;fill-opacity:1;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" + d="m 329.51477,199.15082 -32.04286,18.26817 12.8142,1.28619 -6.02656,28.18505 32.94792,3.49531 0.51681,-27.76301 11.91226,1.00737 z m -14.10711,25.93826 6.27123,0.90288 -1.15019,5.4805 -6.00929,-0.898 z m 13.58524,2.09643 0.42171,5.50053 -6.35262,-0.44337 1.22618,-5.67857 z m -15.04127,5.73678 6.21844,0.90138 -1.87301,4.94347 -5.07899,-0.81761 z m 8.80707,1.53673 6.3403,1.10313 0.43128,4.98637 -7.83808,-1.19409 z" + id="path6874" + inkscape:connector-curvature="0" + sodipodi:nodetypes="cccccccccccccccccccccccccccc" /> + <path + transform="translate(0,-11.249983)" + style="fill:#172062;fill-opacity:1;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" + d="m 366.28967,254.78298 7.49431,-30.40441 -7.41388,-2.66046 1.18763,-3.36104 7.21205,2.27141 1.38362,-5.73044 -7.20912,-2.66047 1.28561,-3.65794 7.01313,2.7643 2.17341,-7.01022 3.35519,1.48161 -2.1734,6.51147 6.70747,2.66046 -1.28564,3.16213 -6.31255,-2.46154 -1.68638,6.02735 6.80837,2.46447 -0.9887,3.84808 -6.90052,-2.47031 -6.71038,30.41026 z" + id="path6891" + inkscape:connector-curvature="0" + sodipodi:nodetypes="ccccccccccccccccccccc" /> + <path + transform="translate(0,-11.249983)" + style="fill:#172062;fill-opacity:1;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" + d="m 74.047433,217.56203 -1.20251,0.65577 2.314585,6.84299 -4.564578,1.31517 13.625009,41.10395 21.186821,-5.50251 -7.183542,-43.56323 -22.044649,6.35259 z m 16.734379,10.06088 1.478463,10.23607 -8.339026,1.96939 -3.82509,-9.42992 z m 3.780131,14.55519 0.781863,9.82627 -7.001121,1.81797 -3.593063,-9.29297 z" + id="path6944" + inkscape:connector-curvature="0" /> + <path + transform="translate(0,-11.249983)" + style="fill:#172062;fill-opacity:1;stroke:none;stroke-width:0.24600939px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" + d="m 43.603475,280.06036 -10.564819,-28.58824 -6.574764,2.28618 -0.916385,-3.37337 6.23111,-2.47535 -2.011396,-5.37101 -6.431418,2.16468 -1.002197,-3.66725 6.348194,-1.96596 -2.123972,-6.85578 3.11982,-0.81419 1.86458,6.45975 6.080155,-1.86705 0.744318,3.27357 -5.700174,1.79072 1.953823,5.78639 6.048884,-2.08256 1.308957,3.64208 -6.116434,2.13257 11.116753,28.12778 z" + id="path6891-8" + inkscape:connector-curvature="0" + sodipodi:nodetypes="ccccccccccccccccccccc" /> + <path + transform="translate(0,-11.249983)" + style="fill:#172062;fill-opacity:1;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" + d="m 411.98753,264.70523 3.91734,-12.57157 -7.13355,-3.53259 -1.396,-8.02014 5.81668,-6.93436 10.92618,-0.52461 7.35863,5.88054 0.0806,8.11138 -5.67524,6.95564 -7.37536,-0.96565 -1.04168,4.03744 5.21293,-1.96321 1.42492,-6.58308 5.61592,-1.7579 5.33002,3.98422 -1.35343,5.14755 -3.67857,2.33882 -4.89966,-2.03926 -7.52592,2.91667 -1.60892,6.84465 z" + id="path6985" + inkscape:connector-curvature="0" + sodipodi:nodetypes="ccccccccccccccccccccc" /> + <path + transform="translate(0,-11.249983)" + style="fill:#172062;fill-opacity:1;stroke:none;stroke-width:0.27861062px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" + d="m 438.77767,272.41521 -0.009,-2.99656 1.24656,2.44908 1.28337,-1.87551 -0.0534,2.25473 2.30831,-1.55949 -1.70125,2.67579 z" + id="path7212" + inkscape:connector-curvature="0" /> + <path + transform="translate(0,-11.249983)" + style="fill:#172062;fill-opacity:1;stroke:none;stroke-width:0.29395995px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" + d="m 387.1467,259.13862 -0.3913,-3.17093 1.60741,2.46066 1.09423,-2.12083 0.23196,2.39229 2.19942,-1.8946 -1.42637,3.01207 z" + id="path7212-4" + inkscape:connector-curvature="0" /> + <path + transform="translate(0,-11.249983)" + style="fill:#172062;fill-opacity:1;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" + d="m 457.96894,278.42384 1.02302,-2.77836 -1.31183,-0.56021 0.33336,-0.616 1.26318,0.48291 0.54568,-1.37607 0.81934,0.31324 -0.47741,1.4022 1.87364,0.67714 0.47795,-1.14765 0.83893,0.26207 -0.47245,1.28672 1.80283,0.70884 0.41215,-1.23149 0.92825,0.33529 -0.49337,1.23952 1.38917,0.51162 -0.21081,0.85845 -1.42731,-0.56527 -1.05878,2.6669 -0.81279,-0.33034 0.94975,-2.68892 -1.68742,-0.7038 -1.03512,2.65627 -0.83236,-0.27915 0.99293,-2.75061 -1.92628,-0.79522 -1.00194,2.82543 z" + id="path7229" + inkscape:connector-curvature="0" + sodipodi:nodetypes="ccccccccccccccccccccccccccccc" /> + <path + transform="translate(0,-11.249983)" + id="path7233" + style="fill:#172062;fill-opacity:1;stroke:none;stroke-width:0.3185696px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" + d="m 73.482785,265.42476 4.944364,-1.72314 -0.207904,-0.52164 -2.012479,0.86151 -0.0213,-0.63037 -0.837931,0.3339 0.324488,0.46118 -2.371778,0.68852 z m 0.497305,0.21764 4.223597,-1.35549 0.556753,4.37406 -2.879727,0.92419 z" + inkscape:connector-curvature="0" + sodipodi:nodetypes="cccccccccccccc" /> + <path + transform="translate(0,-11.249983)" + style="fill:#172062;fill-opacity:1;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" + d="m 156.55184,206.61884 0.47605,-0.20403 1.0201,8.90891 -0.47605,0.20402 z" + id="path7236" + inkscape:connector-curvature="0" /> + <path + transform="translate(0,-11.249983)" + style="fill:#172062;fill-opacity:1;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" + d="m 160.97229,209.47512 0.20402,4.96451 0.47605,-0.068 0.068,-5.03251 z" + id="path7238" + inkscape:connector-curvature="0" /> + <path + transform="translate(0,-11.249983)" + style="fill:#172062;fill-opacity:1;stroke:none;stroke-width:0.34364724px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" + d="m 23.838748,287.33572 -2.186787,-3.04882 3.027872,1.63785 -0.07842,-2.79635 1.585239,2.33549 1.177306,-3.18042 0.241718,3.90016 z" + id="path7212-4-6" + inkscape:connector-curvature="0" /> + <circle + transform="translate(0,-11.249983)" + style="opacity:0.5;fill:#ff0016;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:2.11666656;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" + id="path4535" + cx="120.03474" + cy="193.66763" + r="2.5126758" /> + <circle + transform="translate(0,-11.249983)" + style="opacity:0.5;fill:#ff0016;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:2.11666656;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" + id="path4535-2" + cx="97.333473" + cy="218.84901" + r="2.5126758" /> + <circle + transform="translate(0,-11.249983)" + style="opacity:0.5;fill:#ff0016;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:2.11666656;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" + id="path4535-24" + cx="70.128021" + cy="226.19046" + r="2.5126758" /> + <circle + transform="translate(0,-11.249983)" + style="opacity:0.25;fill:#ff0016;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:1.41842699;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" + id="path4535-25" + cx="118.05532" + cy="234.83446" + r="1.6838019" /> + <circle + transform="translate(0,-11.249983)" + style="opacity:0.5;fill:#ffbe16;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:1.3186785;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" + id="path4535-9" + cx="110.59546" + cy="252.2408" + r="1.5653913" /> + <circle + transform="translate(0,-11.249983)" + style="opacity:0.5;fill:#ffbe16;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:1.3186785;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" + id="path4535-9-7" + cx="122.43651" + cy="242.53113" + r="1.5653913" /> + <circle + transform="translate(0,-11.249983)" + style="opacity:0.5;fill:#ffbe16;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:1.3186785;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" + id="path4535-9-2" + cx="64.415337" + cy="265.26596" + r="1.5653913" /> + <circle + transform="translate(0,-11.249983)" + style="opacity:0.1;fill:#ff0016;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:6.44323444;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" + id="path4535-24-4" + cx="69.61615" + cy="226.18503" + r="7.648705" /> + <circle + transform="translate(0,-11.249983)" + style="opacity:0.1;fill:#ff0016;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:6.44323444;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" + id="path4535-24-4-4" + cx="97.333473" + cy="218.84901" + r="7.648705" /> + <circle + transform="translate(0,-11.249983)" + style="opacity:0.1;fill:#ff0016;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:6.44323444;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" + id="path4535-24-4-2" + cx="119.52941" + cy="193.50121" + r="7.648705" /> + <circle + transform="translate(0,-11.249983)" + style="opacity:0.02999998;fill:#ffbe16;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:4.13750315;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" + id="path4535-9-2-6" + cx="64.415337" + cy="265.26596" + r="4.9115925" /> + <circle + transform="translate(0,-11.249983)" + style="opacity:0.02999998;fill:#ffbe16;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:4.13750315;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" + id="path4535-9-2-6-7" + cx="110.59546" + cy="252.2408" + r="4.9115925" /> + <circle + transform="translate(0,-11.249983)" + style="opacity:0.02999998;fill:#ffbe16;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:4.13750315;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" + id="path4535-9-2-6-3" + cx="122.43651" + cy="242.53113" + r="4.9115925" /> + <circle + transform="translate(0,-11.249983)" + style="opacity:0.05;fill:#ff0016;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:6.44323444;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" + id="path4535-24-4-4-8" + cx="117.52492" + cy="234.88242" + r="7.648705" /> + </g> +</svg> diff --git a/src/server/web/const.styl b/src/server/web/const.styl new file mode 100644 index 0000000000..f16e077828 --- /dev/null +++ b/src/server/web/const.styl @@ -0,0 +1,4 @@ +json('../../const.json') + +$theme-color = themeColor +$theme-color-foreground = themeColorForeground diff --git a/src/server/web/docs/about.en.pug b/src/server/web/docs/about.en.pug new file mode 100644 index 0000000000..893d9dd6a1 --- /dev/null +++ b/src/server/web/docs/about.en.pug @@ -0,0 +1,3 @@ +h1 About Misskey + +p Misskey is a mini blog SNS. diff --git a/src/server/web/docs/about.ja.pug b/src/server/web/docs/about.ja.pug new file mode 100644 index 0000000000..fec933b0c6 --- /dev/null +++ b/src/server/web/docs/about.ja.pug @@ -0,0 +1,3 @@ +h1 Misskeyについて + +p MisskeyはミニブログSNSです。 diff --git a/src/server/web/docs/api.ja.pug b/src/server/web/docs/api.ja.pug new file mode 100644 index 0000000000..2bb08f7f32 --- /dev/null +++ b/src/server/web/docs/api.ja.pug @@ -0,0 +1,103 @@ +h1 Misskey API + +p MisskeyはWeb APIを公開しており、様々な操作をプログラム上から行うことができます。 +p APIを自分のアカウントから利用する場合(自分のアカウントのみ操作したい場合)と、アプリケーションから利用する場合(不特定のアカウントを操作したい場合)とで利用手順が異なりますので、それぞれのケースについて説明します。 + +section + h2 自分の所有するアカウントからAPIにアクセスする場合 + p 「設定 > API」で、APIにアクセスするのに必要なAPIキーを取得してください。 + p APIにアクセスする際には、リクエストにAPIキーを「i」というパラメータ名で含めます。 + div.ui.info.warn: p %fa:exclamation-triangle%アカウントを不正利用される可能性があるため、このトークンは第三者に教えないでください(アプリなどにも入力しないでください)。 + p APIの詳しい使用法は「Misskey APIの利用」セクションをご覧ください。 + +section + h2 アプリケーションからAPIにアクセスする場合 + p + | 直接ユーザーのAPIキーをアプリケーションが扱うのは危険なので、 + | アプリケーションからAPIを利用する際には、アプリケーションとアプリケーションを利用するユーザーが結び付けられた専用のトークン(アクセストークン)をMisskeyに発行してもらい、 + | そのトークンをリクエストのパラメータに含める必要があります。 + div.ui.info: p %fa:info-circle%アクセストークンは、ユーザーが自分のアカウントにあなたのアプリケーションがアクセスすることを許可した場合のみ発行されます + + p それでは、アクセストークンを取得するまでの流れを説明します。 + + section + h3 1.アプリケーションを登録する + p まず、あなたのアプリケーションやWebサービス(以後、あなたのアプリと呼びます)をMisskeyに登録します。 + p + a(href=common.config.dev_url, target="_blank") デベロッパーセンター + | にアクセスし、「アプリ > アプリ作成」に進みます。 + | フォームに必要事項を記入し、アプリを作成してください。フォームの記入欄の説明は以下の通りです: + + table + thead + tr + th 名前 + th 説明 + tbody + tr + td アプリケーション名 + td あなたのアプリの名称。 + tr + td アプリの概要 + td あなたのアプリの簡単な説明や紹介。 + tr + td コールバックURL + td ユーザーが後述する認証フォームで認証を終えた際にリダイレクトするURLを設定できます。あなたのアプリがWebサービスである場合に有用です。 + tr + td 権限 + td あなたのアプリが要求する権限。ここで要求した機能だけがAPIからアクセスできます。 + + p 登録が済むとあなたのアプリのシークレットキーが入手できます。このシークレットキーは後で使用します。 + div.ui.info.warn: p %fa:exclamation-triangle%アプリに成りすまされる可能性があるため、極力このシークレットキーは公開しないようにしてください。 + + section + h3 2.ユーザーに認証させる + p あなたのアプリを使ってもらうには、ユーザーにアカウントへのアクセスの許可をもらう必要があります。 + p + | 認証セッションを開始するには、#{common.config.api_url}/auth/session/generate へパラメータに app_secret としてシークレットキーを含めたリクエストを送信します。 + | リクエスト形式はJSONで、メソッドはPOSTです。 + | レスポンスとして認証セッションのトークンや認証フォームのURLが取得できるので、認証フォームのURLをブラウザで表示し、ユーザーにフォームを提示してください。 + + p + | あなたのアプリがコールバックURLを設定している場合、 + | ユーザーがあなたのアプリの連携を許可すると設定しているコールバックURLに token という名前でセッションのトークンが含まれたクエリを付けてリダイレクトします。 + + p + | あなたのアプリがコールバックURLを設定していない場合、ユーザーがあなたのアプリの連携を許可したことを(何らかの方法で(たとえばボタンを押させるなど))確認出来るようにしてください。 + + section + h3 3.ユーザーのアクセストークンを取得する + p ユーザーが連携を許可したら、#{common.config.api_url}/auth/session/userkey へ次のパラメータを含むリクエストを送信します: + table + thead + tr + th 名前 + th 型 + th 説明 + tbody + tr + td app_secret + td string + td あなたのアプリのシークレットキー + tr + td token + td string + td セッションのトークン + p 上手くいけば、認証したユーザーのアクセストークンがレスポンスとして取得できます。おめでとうございます! + + p アクセストークンが取得できたら、「ユーザーのアクセストークン+あなたのアプリのシークレットキーをsha256したもの」を「i」というパラメータでリクエストに含めると、APIにアクセスすることができます。 + + p 「i」パラメータの生成方法を擬似コードで表すと次のようになります: + pre: code + | const i = sha256(accessToken + secretKey); + + p APIの詳しい使用法は「Misskey APIの利用」セクションをご覧ください。 + +section + h2 Misskey APIの利用 + p APIはすべてリクエストのパラメータ・レスポンスともにJSON形式です。また、すべてのエンドポイントはPOSTメソッドのみ受け付けます。 + p APIリファレンスもご確認ください。 + + section + h3 レートリミット + p Misskey APIにはレートリミットがあり、短時間のうちに多数のリクエストを送信すると、一定時間APIを利用することができなくなることがあります。 diff --git a/src/server/web/docs/api/endpoints/posts/create.yaml b/src/server/web/docs/api/endpoints/posts/create.yaml new file mode 100644 index 0000000000..5e2307dab4 --- /dev/null +++ b/src/server/web/docs/api/endpoints/posts/create.yaml @@ -0,0 +1,53 @@ +endpoint: "posts/create" + +desc: + ja: "投稿します。" + en: "Compose new post." + +params: + - name: "text" + type: "string" + optional: true + desc: + ja: "投稿の本文" + en: "The text of your post" + - name: "media_ids" + type: "id(DriveFile)[]" + optional: true + desc: + ja: "添付するメディア(1~4つ)" + en: "Media you want to attach (1~4)" + - name: "reply_id" + type: "id(Post)" + optional: true + desc: + ja: "返信する投稿" + en: "The post you want to reply" + - name: "repost_id" + type: "id(Post)" + optional: true + desc: + ja: "引用する投稿" + en: "The post you want to quote" + - name: "poll" + type: "object" + optional: true + desc: + ja: "投票" + en: "The poll" + defName: "poll" + def: + - name: "choices" + type: "string[]" + optional: false + desc: + ja: "投票の選択肢" + en: "Choices of a poll" + +res: + - name: "created_post" + type: "entity(Post)" + optional: false + desc: + ja: "作成した投稿" + en: "A post that created" diff --git a/src/server/web/docs/api/endpoints/posts/timeline.yaml b/src/server/web/docs/api/endpoints/posts/timeline.yaml new file mode 100644 index 0000000000..01976b0611 --- /dev/null +++ b/src/server/web/docs/api/endpoints/posts/timeline.yaml @@ -0,0 +1,32 @@ +endpoint: "posts/timeline" + +desc: + ja: "タイムラインを取得します。" + en: "Get your timeline." + +params: + - name: "limit" + type: "number" + optional: true + desc: + ja: "取得する最大の数" + - name: "since_id" + type: "id(Post)" + optional: true + desc: + ja: "指定すると、この投稿を基点としてより新しい投稿を取得します" + - name: "until_id" + type: "id(Post)" + optional: true + desc: + ja: "指定すると、この投稿を基点としてより古い投稿を取得します" + - name: "since_date" + type: "number" + optional: true + desc: + ja: "指定した時間を基点としてより新しい投稿を取得します。数値は、1970 年 1 月 1 日 00:00:00 UTC から指定した日時までの経過時間をミリ秒単位で表します。" + - name: "until_date" + type: "number" + optional: true + desc: + ja: "指定した時間を基点としてより古い投稿を取得します。数値は、1970 年 1 月 1 日 00:00:00 UTC から指定した日時までの経過時間をミリ秒単位で表します。" diff --git a/src/server/web/docs/api/endpoints/style.styl b/src/server/web/docs/api/endpoints/style.styl new file mode 100644 index 0000000000..2af9fe9a77 --- /dev/null +++ b/src/server/web/docs/api/endpoints/style.styl @@ -0,0 +1,21 @@ +@import "../style" + +#url + padding 8px 12px 8px 8px + font-family Consolas, 'Courier New', Courier, Monaco, monospace + color #fff + background #222e40 + border-radius 4px + + > .method + display inline-block + margin 0 8px 0 0 + padding 0 6px + color #f4fcff + background #17afc7 + border-radius 4px + user-select none + pointer-events none + + > .host + opacity 0.7 diff --git a/src/server/web/docs/api/endpoints/view.pug b/src/server/web/docs/api/endpoints/view.pug new file mode 100644 index 0000000000..d271a5517a --- /dev/null +++ b/src/server/web/docs/api/endpoints/view.pug @@ -0,0 +1,32 @@ +extends ../../layout.pug +include ../mixins + +block meta + link(rel="stylesheet" href="/assets/api/endpoints/style.css") + +block main + h1= endpoint + + p#url + span.method POST + span.host + = url.host + | / + span.path= url.path + + p#desc= desc[lang] || desc['ja'] + + section + h2 %i18n:docs.api.endpoints.params% + +propTable(params) + + if paramDefs + each paramDef in paramDefs + section(id= paramDef.name) + h3= paramDef.name + +propTable(paramDef.params) + + if res + section + h2 %i18n:docs.api.endpoints.res% + +propTable(res) diff --git a/src/server/web/docs/api/entities/drive-file.yaml b/src/server/web/docs/api/entities/drive-file.yaml new file mode 100644 index 0000000000..2ebbb089ab --- /dev/null +++ b/src/server/web/docs/api/entities/drive-file.yaml @@ -0,0 +1,73 @@ +name: "DriveFile" + +desc: + ja: "ドライブのファイル。" + en: "A file of Drive." + +props: + - name: "id" + type: "id" + optional: false + desc: + ja: "ファイルID" + en: "The ID of this file" + - name: "created_at" + type: "date" + optional: false + desc: + ja: "アップロード日時" + en: "The upload date of this file" + - name: "user_id" + type: "id(User)" + optional: false + desc: + ja: "所有者ID" + en: "The ID of the owner of this file" + - name: "user" + type: "entity(User)" + optional: true + desc: + ja: "所有者" + en: "The owner of this file" + - name: "name" + type: "string" + optional: false + desc: + ja: "ファイル名" + en: "The name of this file" + - name: "md5" + type: "string" + optional: false + desc: + ja: "ファイルのMD5ハッシュ値" + en: "The md5 hash value of this file" + - name: "type" + type: "string" + optional: false + desc: + ja: "ファイルの種類" + en: "The type of this file" + - name: "datasize" + type: "number" + optional: false + desc: + ja: "ファイルサイズ(bytes)" + en: "The size of this file (bytes)" + - name: "url" + type: "string" + optional: false + desc: + ja: "ファイルのURL" + en: "The URL of this file" + - name: "folder_id" + type: "id(DriveFolder)" + optional: true + desc: + ja: "フォルダID" + en: "The ID of the folder of this file" + - name: "folder" + type: "entity(DriveFolder)" + optional: true + desc: + ja: "フォルダ" + en: "The folder of this file" diff --git a/src/server/web/docs/api/entities/post.yaml b/src/server/web/docs/api/entities/post.yaml new file mode 100644 index 0000000000..f780263144 --- /dev/null +++ b/src/server/web/docs/api/entities/post.yaml @@ -0,0 +1,173 @@ +name: "Post" + +desc: + ja: "投稿。" + en: "A post." + +props: + - name: "id" + type: "id" + optional: false + desc: + ja: "投稿ID" + en: "The ID of this post" + - name: "created_at" + type: "date" + optional: false + desc: + ja: "投稿日時" + en: "The posted date of this post" + - name: "via_mobile" + type: "boolean" + optional: true + desc: + ja: "モバイル端末から投稿したか否か(自己申告であることに留意)" + en: "Whether this post sent via a mobile device" + - name: "text" + type: "string" + optional: true + desc: + ja: "投稿の本文" + en: "The text of this post" + - name: "media_ids" + type: "id(DriveFile)[]" + optional: true + desc: + ja: "添付されているメディアのID" + en: "The IDs of the attached media" + - name: "media" + type: "entity(DriveFile)[]" + optional: true + desc: + ja: "添付されているメディア" + en: "The attached media" + - name: "user_id" + type: "id(User)" + optional: false + desc: + ja: "投稿者ID" + en: "The ID of author of this post" + - name: "user" + type: "entity(User)" + optional: true + desc: + ja: "投稿者" + en: "The author of this post" + - name: "my_reaction" + type: "string" + optional: true + desc: + ja: "この投稿に対する自分の<a href='/docs/api/reactions'>リアクション</a>" + en: "The your <a href='/docs/api/reactions'>reaction</a> of this post" + - name: "reaction_counts" + type: "object" + optional: false + desc: + ja: "<a href='/docs/api/reactions'>リアクション</a>をキーとし、この投稿に対するそのリアクションの数を値としたオブジェクト" + - name: "reply_id" + type: "id(Post)" + optional: true + desc: + ja: "返信した投稿のID" + en: "The ID of the replyed post" + - name: "reply" + type: "entity(Post)" + optional: true + desc: + ja: "返信した投稿" + en: "The replyed post" + - name: "repost_id" + type: "id(Post)" + optional: true + desc: + ja: "引用した投稿のID" + en: "The ID of the quoted post" + - name: "repost" + type: "entity(Post)" + optional: true + desc: + ja: "引用した投稿" + en: "The quoted post" + - name: "poll" + type: "object" + optional: true + desc: + ja: "投票" + en: "The poll" + defName: "poll" + def: + - name: "choices" + type: "object[]" + optional: false + desc: + ja: "投票の選択肢" + en: "The choices of this poll" + defName: "choice" + def: + - name: "id" + type: "number" + optional: false + desc: + ja: "選択肢ID" + en: "The ID of this choice" + - name: "is_voted" + type: "boolean" + optional: true + desc: + ja: "自分がこの選択肢に投票したかどうか" + en: "Whether you voted to this choice" + - name: "text" + type: "string" + optional: false + desc: + ja: "選択肢本文" + en: "The text of this choice" + - name: "votes" + type: "number" + optional: false + desc: + ja: "この選択肢に投票された数" + en: "The number voted for this choice" + - name: "geo" + type: "object" + optional: true + desc: + ja: "位置情報" + en: "Geo location" + defName: "geo" + def: + - name: "latitude" + type: "number" + optional: false + desc: + ja: "緯度。-90〜90で表す。" + - name: "longitude" + type: "number" + optional: false + desc: + ja: "経度。-180〜180で表す。" + - name: "altitude" + type: "number" + optional: false + desc: + ja: "高度。メートル単位で表す。" + - name: "accuracy" + type: "number" + optional: false + desc: + ja: "緯度、経度の精度。メートル単位で表す。" + - name: "altitudeAccuracy" + type: "number" + optional: false + desc: + ja: "高度の精度。メートル単位で表す。" + - name: "heading" + type: "number" + optional: false + desc: + ja: "方角。0〜360の角度で表す。0が北、90が東、180が南、270が西。" + - name: "speed" + type: "number" + optional: false + desc: + ja: "速度。メートル / 秒数で表す。" diff --git a/src/server/web/docs/api/entities/style.styl b/src/server/web/docs/api/entities/style.styl new file mode 100644 index 0000000000..bddf0f53ab --- /dev/null +++ b/src/server/web/docs/api/entities/style.styl @@ -0,0 +1 @@ +@import "../style" diff --git a/src/server/web/docs/api/entities/user.yaml b/src/server/web/docs/api/entities/user.yaml new file mode 100644 index 0000000000..a451a40807 --- /dev/null +++ b/src/server/web/docs/api/entities/user.yaml @@ -0,0 +1,173 @@ +name: "User" + +desc: + ja: "ユーザー。" + en: "A user." + +props: + - name: "id" + type: "id" + optional: false + desc: + ja: "ユーザーID" + en: "The ID of this user" + - name: "created_at" + type: "date" + optional: false + desc: + ja: "アカウント作成日時" + en: "The registered date of this user" + - name: "username" + type: "string" + optional: false + desc: + ja: "ユーザー名" + en: "The username of this user" + - name: "description" + type: "string" + optional: false + desc: + ja: "アカウントの説明(自己紹介)" + en: "The description of this user" + - name: "avatar_id" + type: "id(DriveFile)" + optional: true + desc: + ja: "アバターのID" + en: "The ID of the avatar of this user" + - name: "avatar_url" + type: "string" + optional: false + desc: + ja: "アバターのURL" + en: "The URL of the avatar of this user" + - name: "banner_id" + type: "id(DriveFile)" + optional: true + desc: + ja: "バナーのID" + en: "The ID of the banner of this user" + - name: "banner_url" + type: "string" + optional: false + desc: + ja: "バナーのURL" + en: "The URL of the banner of this user" + - name: "followers_count" + type: "number" + optional: false + desc: + ja: "フォロワーの数" + en: "The number of the followers for this user" + - name: "following_count" + type: "number" + optional: false + desc: + ja: "フォローしているユーザーの数" + en: "The number of the following users for this user" + - name: "is_following" + type: "boolean" + optional: true + desc: + ja: "自分がこのユーザーをフォローしているか" + - name: "is_followed" + type: "boolean" + optional: true + desc: + ja: "自分がこのユーザーにフォローされているか" + - name: "is_muted" + type: "boolean" + optional: true + desc: + ja: "自分がこのユーザーをミュートしているか" + en: "Whether you muted this user" + - name: "posts_count" + type: "number" + optional: false + desc: + ja: "投稿の数" + en: "The number of the posts of this user" + - name: "pinned_post" + type: "entity(Post)" + optional: true + desc: + ja: "ピン留めされた投稿" + en: "The pinned post of this user" + - name: "pinned_post_id" + type: "id(Post)" + optional: true + desc: + ja: "ピン留めされた投稿のID" + en: "The ID of the pinned post of this user" + - name: "drive_capacity" + type: "number" + optional: false + desc: + ja: "ドライブの容量(bytes)" + en: "The capacity of drive of this user (bytes)" + - name: "host" + type: "string | null" + optional: false + desc: + ja: "ホスト (例: example.com:3000)" + en: "Host (e.g. example.com:3000)" + - name: "account" + type: "object" + optional: false + desc: + ja: "このサーバーにおけるアカウント" + en: "The account of this user on this server" + defName: "account" + def: + - name: "last_used_at" + type: "date" + optional: false + desc: + ja: "最終利用日時" + en: "The last used date of this user" + - name: "is_bot" + type: "boolean" + optional: true + desc: + ja: "botか否か(自己申告であることに留意)" + en: "Whether is bot or not" + - name: "twitter" + type: "object" + optional: true + desc: + ja: "連携されているTwitterアカウント情報" + en: "The info of the connected twitter account of this user" + defName: "twitter" + def: + - name: "user_id" + type: "string" + optional: false + desc: + ja: "ユーザーID" + en: "The user ID" + - name: "screen_name" + type: "string" + optional: false + desc: + ja: "ユーザー名" + en: "The screen name of this user" + - name: "profile" + type: "object" + optional: false + desc: + ja: "プロフィール" + en: "The profile of this user" + defName: "profile" + def: + - name: "location" + type: "string" + optional: true + desc: + ja: "場所" + en: "The location of this user" + - name: "birthday" + type: "string" + optional: true + desc: + ja: "誕生日 (YYYY-MM-DD)" + en: "The birthday of this user (YYYY-MM-DD)" diff --git a/src/server/web/docs/api/entities/view.pug b/src/server/web/docs/api/entities/view.pug new file mode 100644 index 0000000000..2156463dc7 --- /dev/null +++ b/src/server/web/docs/api/entities/view.pug @@ -0,0 +1,20 @@ +extends ../../layout.pug +include ../mixins + +block meta + link(rel="stylesheet" href="/assets/api/entities/style.css") + +block main + h1= name + + p#desc= desc[lang] || desc['ja'] + + section + h2 %i18n:docs.api.entities.properties% + +propTable(props) + + if propDefs + each propDef in propDefs + section(id= propDef.name) + h3= propDef.name + +propTable(propDef.params) diff --git a/src/server/web/docs/api/gulpfile.ts b/src/server/web/docs/api/gulpfile.ts new file mode 100644 index 0000000000..37935413de --- /dev/null +++ b/src/server/web/docs/api/gulpfile.ts @@ -0,0 +1,188 @@ +/** + * Gulp tasks + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as glob from 'glob'; +import * as gulp from 'gulp'; +import * as pug from 'pug'; +import * as yaml from 'js-yaml'; +import * as mkdirp from 'mkdirp'; + +import locales from '../../../../../locales'; +import I18nReplacer from '../../../../build/i18n'; +import fa from '../../../../build/fa'; +import config from './../../../../conf'; + +import generateVars from '../vars'; + +const langs = Object.keys(locales); + +const kebab = string => string.replace(/([a-z])([A-Z])/g, '$1-$2').replace(/\s+/g, '-').toLowerCase(); + +const parseParam = param => { + const id = param.type.match(/^id\((.+?)\)|^id/); + const entity = param.type.match(/^entity\((.+?)\)/); + const isObject = /^object/.test(param.type); + const isDate = /^date/.test(param.type); + const isArray = /\[\]$/.test(param.type); + if (id) { + param.kind = 'id'; + param.type = 'string'; + param.entity = id[1]; + if (isArray) { + param.type += '[]'; + } + } + if (entity) { + param.kind = 'entity'; + param.type = 'object'; + param.entity = entity[1]; + if (isArray) { + param.type += '[]'; + } + } + if (isObject) { + param.kind = 'object'; + } + if (isDate) { + param.kind = 'date'; + param.type = 'string'; + if (isArray) { + param.type += '[]'; + } + } + + return param; +}; + +const sortParams = params => { + params.sort((a, b) => { + if (a.name < b.name) + return -1; + if (a.name > b.name) + return 1; + return 0; + }); + return params; +}; + +const extractDefs = params => { + let defs = []; + + params.forEach(param => { + if (param.def) { + defs.push({ + name: param.defName, + params: sortParams(param.def.map(p => parseParam(p))) + }); + + const childDefs = extractDefs(param.def); + + defs = defs.concat(childDefs); + } + }); + + return sortParams(defs); +}; + +gulp.task('doc:api', [ + 'doc:api:endpoints', + 'doc:api:entities' +]); + +gulp.task('doc:api:endpoints', async () => { + const commonVars = await generateVars(); + glob('./src/server/web/docs/api/endpoints/**/*.yaml', (globErr, files) => { + if (globErr) { + console.error(globErr); + return; + } + //console.log(files); + files.forEach(file => { + const ep = yaml.safeLoad(fs.readFileSync(file, 'utf-8')); + const vars = { + endpoint: ep.endpoint, + url: { + host: config.api_url, + path: ep.endpoint + }, + desc: ep.desc, + params: sortParams(ep.params.map(p => parseParam(p))), + paramDefs: extractDefs(ep.params), + res: ep.res ? sortParams(ep.res.map(p => parseParam(p))) : null, + resDefs: ep.res ? extractDefs(ep.res) : null, + }; + langs.forEach(lang => { + pug.renderFile('./src/server/web/docs/api/endpoints/view.pug', Object.assign({}, vars, { + lang, + title: ep.endpoint, + src: `https://github.com/syuilo/misskey/tree/master/src/server/web/docs/api/endpoints/${ep.endpoint}.yaml`, + kebab, + common: commonVars + }), (renderErr, html) => { + if (renderErr) { + console.error(renderErr); + return; + } + const i18n = new I18nReplacer(lang); + html = html.replace(i18n.pattern, i18n.replacement); + html = fa(html); + const htmlPath = `./built/server/web/docs/${lang}/api/endpoints/${ep.endpoint}.html`; + mkdirp(path.dirname(htmlPath), (mkdirErr) => { + if (mkdirErr) { + console.error(mkdirErr); + return; + } + fs.writeFileSync(htmlPath, html, 'utf-8'); + }); + }); + }); + }); + }); +}); + +gulp.task('doc:api:entities', async () => { + const commonVars = await generateVars(); + glob('./src/server/web/docs/api/entities/**/*.yaml', (globErr, files) => { + if (globErr) { + console.error(globErr); + return; + } + files.forEach(file => { + const entity = yaml.safeLoad(fs.readFileSync(file, 'utf-8')); + const vars = { + name: entity.name, + desc: entity.desc, + props: sortParams(entity.props.map(p => parseParam(p))), + propDefs: extractDefs(entity.props), + }; + langs.forEach(lang => { + pug.renderFile('./src/server/web/docs/api/entities/view.pug', Object.assign({}, vars, { + lang, + title: entity.name, + src: `https://github.com/syuilo/misskey/tree/master/src/server/web/docs/api/entities/${kebab(entity.name)}.yaml`, + kebab, + common: commonVars + }), (renderErr, html) => { + if (renderErr) { + console.error(renderErr); + return; + } + const i18n = new I18nReplacer(lang); + html = html.replace(i18n.pattern, i18n.replacement); + html = fa(html); + const htmlPath = `./built/server/web/docs/${lang}/api/entities/${kebab(entity.name)}.html`; + mkdirp(path.dirname(htmlPath), (mkdirErr) => { + if (mkdirErr) { + console.error(mkdirErr); + return; + } + fs.writeFileSync(htmlPath, html, 'utf-8'); + }); + }); + }); + }); + }); +}); diff --git a/src/server/web/docs/api/mixins.pug b/src/server/web/docs/api/mixins.pug new file mode 100644 index 0000000000..686bf6a2b6 --- /dev/null +++ b/src/server/web/docs/api/mixins.pug @@ -0,0 +1,37 @@ +mixin propTable(props) + table.props + thead: tr + th %i18n:docs.api.props.name% + th %i18n:docs.api.props.type% + th %i18n:docs.api.props.optional% + th %i18n:docs.api.props.description% + tbody + each prop in props + tr + td.name= prop.name + td.type + i= prop.type + if prop.kind == 'id' + if prop.entity + | ( + a(href=`/${lang}/api/entities/${kebab(prop.entity)}`)= prop.entity + | ID) + else + | (ID) + else if prop.kind == 'entity' + | ( + a(href=`/${lang}/api/entities/${kebab(prop.entity)}`)= prop.entity + | ) + else if prop.kind == 'object' + if prop.def + | ( + a(href=`#${prop.defName}`)= prop.defName + | ) + else if prop.kind == 'date' + | (Date) + td.optional + if prop.optional + | %i18n:docs.api.props.yes% + else + | %i18n:docs.api.props.no% + td.desc!= prop.desc[lang] || prop.desc['ja'] diff --git a/src/server/web/docs/api/style.styl b/src/server/web/docs/api/style.styl new file mode 100644 index 0000000000..3675a4da6f --- /dev/null +++ b/src/server/web/docs/api/style.styl @@ -0,0 +1,11 @@ +@import "../style" + +table.props + .name + font-weight bold + + .name + .type + .optional + font-family Consolas, 'Courier New', Courier, Monaco, monospace + diff --git a/src/server/web/docs/gulpfile.ts b/src/server/web/docs/gulpfile.ts new file mode 100644 index 0000000000..7b36cf6675 --- /dev/null +++ b/src/server/web/docs/gulpfile.ts @@ -0,0 +1,77 @@ +/** + * Gulp tasks + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as glob from 'glob'; +import * as gulp from 'gulp'; +import * as pug from 'pug'; +import * as mkdirp from 'mkdirp'; +import stylus = require('gulp-stylus'); +import cssnano = require('gulp-cssnano'); + +import I18nReplacer from '../../../build/i18n'; +import fa from '../../../build/fa'; +import generateVars from './vars'; + +require('./api/gulpfile.ts'); + +gulp.task('doc', [ + 'doc:docs', + 'doc:api', + 'doc:styles' +]); + +gulp.task('doc:docs', async () => { + const commonVars = await generateVars(); + + glob('./src/server/web/docs/**/*.*.pug', (globErr, files) => { + if (globErr) { + console.error(globErr); + return; + } + files.forEach(file => { + const [, name, lang] = file.match(/docs\/(.+?)\.(.+?)\.pug$/); + const vars = { + common: commonVars, + lang: lang, + title: fs.readFileSync(file, 'utf-8').match(/^h1 (.+?)\r?\n/)[1], + src: `https://github.com/syuilo/misskey/tree/master/src/server/web/docs/${name}.${lang}.pug`, + }; + pug.renderFile(file, vars, (renderErr, content) => { + if (renderErr) { + console.error(renderErr); + return; + } + + pug.renderFile('./src/server/web/docs/layout.pug', Object.assign({}, vars, { + content + }), (renderErr2, html) => { + if (renderErr2) { + console.error(renderErr2); + return; + } + const i18n = new I18nReplacer(lang); + html = html.replace(i18n.pattern, i18n.replacement); + html = fa(html); + const htmlPath = `./built/server/web/docs/${lang}/${name}.html`; + mkdirp(path.dirname(htmlPath), (mkdirErr) => { + if (mkdirErr) { + console.error(mkdirErr); + return; + } + fs.writeFileSync(htmlPath, html, 'utf-8'); + }); + }); + }); + }); + }); +}); + +gulp.task('doc:styles', () => + gulp.src('./src/server/web/docs/**/*.styl') + .pipe(stylus()) + .pipe((cssnano as any)()) + .pipe(gulp.dest('./built/server/web/docs/assets/')) +); diff --git a/src/server/web/docs/index.en.pug b/src/server/web/docs/index.en.pug new file mode 100644 index 0000000000..1fcc870d3d --- /dev/null +++ b/src/server/web/docs/index.en.pug @@ -0,0 +1,3 @@ +h1 Misskey Docs + +p Welcome to docs of Misskey. diff --git a/src/server/web/docs/index.ja.pug b/src/server/web/docs/index.ja.pug new file mode 100644 index 0000000000..4a0bf7fa1d --- /dev/null +++ b/src/server/web/docs/index.ja.pug @@ -0,0 +1,3 @@ +h1 Misskey ドキュメント + +p Misskeyのドキュメントへようこそ diff --git a/src/server/web/docs/layout.pug b/src/server/web/docs/layout.pug new file mode 100644 index 0000000000..9dfd0ab7af --- /dev/null +++ b/src/server/web/docs/layout.pug @@ -0,0 +1,41 @@ +doctype html + +html(lang= lang) + head + meta(charset="UTF-8") + meta(name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1, user-scalable=no") + title + | #{title} | Misskey Docs + link(rel="stylesheet" href="/assets/style.css") + block meta + + //- FontAwesome style + style #{common.facss} + + body + nav + ul + each doc in common.docs + li: a(href=`/${lang}/${doc.name}`)= doc.title[lang] || doc.title['ja'] + section + h2 API + ul + li Entities + ul + each entity in common.entities + li: a(href=`/${lang}/api/entities/${common.kebab(entity)}`)= entity + li Endpoints + ul + each endpoint in common.endpoints + li: a(href=`/${lang}/api/endpoints/${common.kebab(endpoint)}`)= endpoint + main + article + block main + if content + | !{content} + + footer + p + | %i18n:docs.edit-this-page-on-github% + a(href=src target="_blank") %i18n:docs.edit-this-page-on-github-link% + small= common.copyright diff --git a/src/server/web/docs/license.en.pug b/src/server/web/docs/license.en.pug new file mode 100644 index 0000000000..45d8b76473 --- /dev/null +++ b/src/server/web/docs/license.en.pug @@ -0,0 +1,17 @@ +h1 License + +div!= common.license + +details + summary Libraries + + section + h2 Libraries + + each dependency, name in common.dependencies + details + summary= name + + section + h3= name + pre= dependency.licenseText diff --git a/src/server/web/docs/license.ja.pug b/src/server/web/docs/license.ja.pug new file mode 100644 index 0000000000..6eb9ac308e --- /dev/null +++ b/src/server/web/docs/license.ja.pug @@ -0,0 +1,17 @@ +h1 ライセンス + +div!= common.license + +details + summary サードパーティ + + section + h2 サードパーティ + + each dependency, name in common.dependencies + details + summary= name + + section + h3= name + pre= dependency.licenseText diff --git a/src/server/web/docs/mute.ja.pug b/src/server/web/docs/mute.ja.pug new file mode 100644 index 0000000000..5e79af5f8c --- /dev/null +++ b/src/server/web/docs/mute.ja.pug @@ -0,0 +1,13 @@ +h1 ミュート + +p ユーザーページから、そのユーザーをミュートすることができます。 + +p ユーザーをミュートすると、そのユーザーに関する次のコンテンツがMisskeyに表示されなくなります: +ul + li タイムラインや投稿の検索結果内の、そのユーザーの投稿(およびそれらの投稿に対する返信やRepost) + li そのユーザーからの通知 + li メッセージ履歴一覧内の、そのユーザーとのメッセージ履歴 + +p ミュートを行ったことは相手に通知されず、ミュートされていることを知ることもできません。 + +p 設定>ミュート から、自分がミュートしているユーザー一覧を確認することができます。 diff --git a/src/server/web/docs/search.ja.pug b/src/server/web/docs/search.ja.pug new file mode 100644 index 0000000000..e14e8c867e --- /dev/null +++ b/src/server/web/docs/search.ja.pug @@ -0,0 +1,120 @@ +h1 検索 + +p 投稿を検索することができます。 +p + | キーワードを半角スペースで区切ると、and検索になります。 + | 例えば、「git コミット」と検索すると、「gitで編集したファイルの特定の行だけコミットする方法がわからない」などがマッチします。 + +section + h2 キーワードの除外 + p キーワードの前に「-」(ハイフン)をプリフィクスすると、そのキーワードを含まない投稿に限定します。 + p 例えば、「gitというキーワードを含むが、コミットというキーワードは含まない投稿」を検索したい場合、クエリは以下のようになります: + code git -コミット + +section + h2 完全一致 + p テキストを「"""」で囲むと、そのテキストと完全に一致する投稿を検索します。 + p 例えば、「"""にゃーん"""」と検索すると、「にゃーん」という投稿のみがヒットし、「にゃーん…」という投稿はヒットしません。 + +section + h2 タグ + p キーワードの前に「#」(シャープ)をプリフィクスすると、そのキーワードと一致するタグを持つ投稿に限定します。 + +section + h2 オプション + p + | オプションを使用して、より高度な検索を行えます。 + | オプションを指定するには、「オプション名:値」という形式でクエリに含めます。 + p 利用可能なオプション一覧です: + + table + thead + tr + th 名前 + th 説明 + tbody + tr + td user + td + | 指定されたユーザー名のユーザーの投稿に限定します。 + | 「,」(カンマ)で区切って、複数ユーザーを指定することもできます。 + br + | 例えば、 + code user:himawari,sakurako + | と検索すると「@himawariまたは@sakurakoの投稿」だけに限定します。 + | (つまりユーザーのホワイトリストです) + tr + td exclude_user + td + | 指定されたユーザー名のユーザーの投稿を除外します。 + | 「,」(カンマ)で区切って、複数ユーザーを指定することもできます。 + br + | 例えば、 + code exclude_user:akari,chinatsu + | と検索すると「@akariまたは@chinatsu以外の投稿」に限定します。 + | (つまりユーザーのブラックリストです) + tr + td follow + td + | true ... フォローしているユーザーに限定。 + br + | false ... フォローしていないユーザーに限定。 + br + | null ... 特に限定しない(デフォルト) + tr + td mute + td + | mute_all ... ミュートしているユーザーの投稿とその投稿に対する返信やRepostを除外する(デフォルト) + br + | mute_related ... ミュートしているユーザーの投稿に対する返信やRepostだけ除外する + br + | mute_direct ... ミュートしているユーザーの投稿だけ除外する + br + | disabled ... ミュートしているユーザーの投稿とその投稿に対する返信やRepostも含める + br + | direct_only ... ミュートしているユーザーの投稿だけに限定 + br + | related_only ... ミュートしているユーザーの投稿に対する返信やRepostだけに限定 + br + | all_only ... ミュートしているユーザーの投稿とその投稿に対する返信やRepostに限定 + tr + td reply + td + | true ... 返信に限定。 + br + | false ... 返信でない投稿に限定。 + br + | null ... 特に限定しない(デフォルト) + tr + td repost + td + | true ... Repostに限定。 + br + | false ... Repostでない投稿に限定。 + br + | null ... 特に限定しない(デフォルト) + tr + td media + td + | true ... メディアが添付されている投稿に限定。 + br + | false ... メディアが添付されていない投稿に限定。 + br + | null ... 特に限定しない(デフォルト) + tr + td poll + td + | true ... 投票が添付されている投稿に限定。 + br + | false ... 投票が添付されていない投稿に限定。 + br + | null ... 特に限定しない(デフォルト) + tr + td until + td 上限の日時。(YYYY-MM-DD) + tr + td since + td 下限の日時。(YYYY-MM-DD) + + p 例えば、「@syuiloの2017年11月1日から2017年12月31日までの『Misskey』というテキストを含む返信ではない投稿」を検索したい場合、クエリは以下のようになります: + code user:syuilo since:2017-11-01 until:2017-12-31 reply:false Misskey diff --git a/src/server/web/docs/server.ts b/src/server/web/docs/server.ts new file mode 100644 index 0000000000..b2e50457e5 --- /dev/null +++ b/src/server/web/docs/server.ts @@ -0,0 +1,21 @@ +/** + * Docs Server + */ + +import * as express from 'express'; + +/** + * Init app + */ +const app = express(); +app.disable('x-powered-by'); + +app.use('/assets', express.static(`${__dirname}/assets`)); + +/** + * Routing + */ +app.get(/^\/([a-z_\-\/]+?)$/, (req, res) => + res.sendFile(`${__dirname}/${req.params[0]}.html`)); + +module.exports = app; diff --git a/src/server/web/docs/style.styl b/src/server/web/docs/style.styl new file mode 100644 index 0000000000..bc165f8728 --- /dev/null +++ b/src/server/web/docs/style.styl @@ -0,0 +1,120 @@ +@import "../style" +@import "./ui" + +body + margin 0 + color #34495e + word-break break-word + +main + margin 0 0 0 256px + padding 64px + width 100% + max-width 768px + + section + margin 32px 0 + + h1 + margin 0 0 24px 0 + padding 16px 0 + font-size 1.5em + border-bottom solid 2px #eee + + h2 + margin 0 0 24px 0 + padding 0 0 16px 0 + font-size 1.4em + border-bottom solid 1px #eee + + h3 + margin 0 + padding 0 + font-size 1.25em + + h4 + margin 0 + + p + margin 1em 0 + line-height 1.6em + + footer + margin 32px 0 0 0 + border-top solid 2px #eee + + > small + margin 16px 0 0 0 + color #aaa + +nav + display block + position fixed + z-index 10000 + top 0 + left 0 + width 256px + height 100% + overflow auto + padding 32px + background #fff + border-right solid 2px #eee + +@media (max-width 1025px) + main + margin 0 + max-width 100% + + nav + position relative + width 100% + max-height 128px + background #f9f9f9 + border-right none + +@media (max-width 768px) + main + padding 32px + +@media (max-width 512px) + main + padding 16px + +table + display block + width 100% + max-width 100% + overflow auto + border-spacing 0 + border-collapse collapse + + thead + font-weight bold + border-bottom solid 2px #eee + + tr + th + text-align left + + tbody + tr + &:nth-child(odd) + background #fbfbfb + + th, td + padding 8px 16px + min-width 128px + +code + display inline-block + padding 8px 10px + font-family Consolas, 'Courier New', Courier, Monaco, monospace + color #295c92 + background #f2f2f2 + border-radius 4px + +pre + overflow auto + + > code + display block diff --git a/src/server/web/docs/tou.ja.pug b/src/server/web/docs/tou.ja.pug new file mode 100644 index 0000000000..7663258f82 --- /dev/null +++ b/src/server/web/docs/tou.ja.pug @@ -0,0 +1,3 @@ +h1 利用規約 + +p 公序良俗に反する行為はおやめください。 diff --git a/src/server/web/docs/ui.styl b/src/server/web/docs/ui.styl new file mode 100644 index 0000000000..8d5515712f --- /dev/null +++ b/src/server/web/docs/ui.styl @@ -0,0 +1,19 @@ +.ui.info + display block + margin 1em 0 + padding 0 1em + font-size 90% + color rgba(#000, 0.87) + background #f8f8f9 + border-radius 4px + overflow hidden + + > p + opacity 0.8 + + > [data-fa]:first-child + margin-right 0.25em + + &.warn + color #573a08 + background #FFFAF3 diff --git a/src/server/web/docs/vars.ts b/src/server/web/docs/vars.ts new file mode 100644 index 0000000000..5096a39c9e --- /dev/null +++ b/src/server/web/docs/vars.ts @@ -0,0 +1,64 @@ +import * as fs from 'fs'; +import * as util from 'util'; +import * as glob from 'glob'; +import * as yaml from 'js-yaml'; +import * as licenseChecker from 'license-checker'; +import * as tmp from 'tmp'; + +import { fa } from '../../../build/fa'; +import config from '../../../conf'; +import { licenseHtml } from '../../../build/license'; +const constants = require('../../../const.json'); + +export default async function(): Promise<{ [key: string]: any }> { + const vars = {} as { [key: string]: any }; + + const endpoints = glob.sync('./src/server/web/docs/api/endpoints/**/*.yaml'); + vars['endpoints'] = endpoints.map(ep => { + const _ep = yaml.safeLoad(fs.readFileSync(ep, 'utf-8')); + return _ep.endpoint; + }); + + const entities = glob.sync('./src/server/web/docs/api/entities/**/*.yaml'); + vars['entities'] = entities.map(x => { + const _x = yaml.safeLoad(fs.readFileSync(x, 'utf-8')); + return _x.name; + }); + + const docs = glob.sync('./src/server/web/docs/**/*.*.pug'); + vars['docs'] = {}; + docs.forEach(x => { + const [, name, lang] = x.match(/docs\/(.+?)\.(.+?)\.pug$/); + if (vars['docs'][name] == null) { + vars['docs'][name] = { + name, + title: {} + }; + } + vars['docs'][name]['title'][lang] = fs.readFileSync(x, 'utf-8').match(/^h1 (.+?)\r?\n/)[1]; + }); + + vars['kebab'] = string => string.replace(/([a-z])([A-Z])/g, '$1-$2').replace(/\s+/g, '-').toLowerCase(); + + vars['config'] = config; + + vars['copyright'] = constants.copyright; + + vars['facss'] = fa.dom.css(); + + vars['license'] = licenseHtml; + + const tmpObj = tmp.fileSync(); + fs.writeFileSync(tmpObj.name, JSON.stringify({ + licenseText: '' + }), 'utf-8'); + const dependencies = await util.promisify(licenseChecker.init).bind(licenseChecker)({ + start: __dirname + '/../../../../', + customPath: tmpObj.name + }); + tmpObj.removeCallback(); + + vars['dependencies'] = dependencies; + + return vars; +} diff --git a/src/server/web/element.scss b/src/server/web/element.scss new file mode 100644 index 0000000000..7e6d0e7099 --- /dev/null +++ b/src/server/web/element.scss @@ -0,0 +1,12 @@ +/* Element variable definitons */ +/* SEE: http://element.eleme.io/#/en-US/component/custom-theme */ + +@import '../../const.json'; + +/* theme color */ +$--color-primary: $themeColor; + +/* icon font path, required */ +$--font-path: '~element-ui/lib/theme-chalk/fonts'; + +@import "~element-ui/packages/theme-chalk/src/index"; diff --git a/src/server/web/server.ts b/src/server/web/server.ts new file mode 100644 index 0000000000..b117f6ae81 --- /dev/null +++ b/src/server/web/server.ts @@ -0,0 +1,77 @@ +/** + * Web Server + */ + +import * as path from 'path'; +import ms = require('ms'); + +// express modules +import * as express from 'express'; +import * as bodyParser from 'body-parser'; +import * as favicon from 'serve-favicon'; +import * as compression from 'compression'; + +/** + * Init app + */ +const app = express(); +app.disable('x-powered-by'); + +app.use('/docs', require('./docs/server')); + +app.use(bodyParser.urlencoded({ extended: true })); +app.use(bodyParser.json({ + type: ['application/json', 'text/plain'] +})); +app.use(compression()); + +/** + * Initialize requests + */ +app.use((req, res, next) => { + res.header('X-Frame-Options', 'DENY'); + next(); +}); + +/** + * Static assets + */ +app.use(favicon(`${__dirname}/assets/favicon.ico`)); +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') +})); +app.use('/assets/*.js', (req, res) => res.sendFile(`${__dirname}/assets/404.js`)); +app.use('/assets', (req, res) => { + res.sendStatus(404); +}); + +app.use('/recover', (req, res) => res.sendFile(`${__dirname}/assets/recover.html`)); + +/** + * ServiceWroker + */ +app.get(/^\/sw\.(.+?)\.js$/, (req, res) => + res.sendFile(`${__dirname}/assets/sw.${req.params[0]}.js`)); + +/** + * Manifest + */ +app.get('/manifest.json', (req, res) => + res.sendFile(`${__dirname}/assets/manifest.json`)); + +/** + * Common API + */ +app.get(/\/api:url/, require('./service/url-preview')); + +/** + * Routing + */ +app.get('*', (req, res) => { + res.sendFile(path.resolve(`${__dirname}/app/base.html`), { + maxAge: ms('7 days') + }); +}); + +module.exports = app; diff --git a/src/server/web/service/url-preview.ts b/src/server/web/service/url-preview.ts new file mode 100644 index 0000000000..0c5fd8a78e --- /dev/null +++ b/src/server/web/service/url-preview.ts @@ -0,0 +1,15 @@ +import * as express from 'express'; +import summaly from 'summaly'; + +module.exports = async (req: express.Request, res: express.Response) => { + const summary = await summaly(req.query.url); + summary.icon = wrap(summary.icon); + summary.thumbnail = wrap(summary.thumbnail); + res.send(summary); +}; + +function wrap(url: string): string { + return url != null + ? `https://images.weserv.nl/?url=${url.replace(/^https?:\/\//, '')}` + : null; +} diff --git a/src/server/web/style.styl b/src/server/web/style.styl new file mode 100644 index 0000000000..6d1e53e5a6 --- /dev/null +++ b/src/server/web/style.styl @@ -0,0 +1,37 @@ +@charset 'utf-8' + +@import "./const" + +/* + ::selection + background $theme-color + color #fff +*/ + +* + position relative + box-sizing border-box + background-clip padding-box !important + tap-highlight-color transparent + -webkit-tap-highlight-color transparent + +html, body + margin 0 + padding 0 + scroll-behavior smooth + text-size-adjust 100% + font-family sans-serif + +a + text-decoration none + color $theme-color + cursor pointer + tap-highlight-color rgba($theme-color, 0.7) !important + -webkit-tap-highlight-color rgba($theme-color, 0.7) !important + + &:hover + text-decoration underline + + * + cursor pointer + |