summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorこぴなたみぽ <Syuilotan@yahoo.co.jp>2017-11-06 19:11:23 +0900
committerGitHub <noreply@github.com>2017-11-06 19:11:23 +0900
commitcb7e70dee3aa47807d33757d4ecd07e2793540d0 (patch)
treec6795a6c0aa200195748c364d4ab990c6a160150 /src
parentchore(package): update @types/rimraf to version 2.0.2 (diff)
parentMerge pull request #871 from syuilo/greenkeeper/@types/elasticsearch-5.0.17 (diff)
downloadmisskey-cb7e70dee3aa47807d33757d4ecd07e2793540d0.tar.gz
misskey-cb7e70dee3aa47807d33757d4ecd07e2793540d0.tar.bz2
misskey-cb7e70dee3aa47807d33757d4ecd07e2793540d0.zip
Merge branch 'master' into greenkeeper/@types/rimraf-2.0.2
Diffstat (limited to 'src')
-rw-r--r--src/api/authenticate.ts20
-rw-r--r--src/api/bot/core.ts398
-rw-r--r--src/api/bot/interfaces/line.ts234
-rw-r--r--src/api/common/add-file-to-drive.ts35
-rw-r--r--src/api/common/generate-native-user-token.ts3
-rw-r--r--src/api/common/read-notification.ts52
-rw-r--r--src/api/endpoints.ts56
-rw-r--r--src/api/endpoints/aggregation/posts.ts4
-rw-r--r--src/api/endpoints/aggregation/posts/reply.ts2
-rw-r--r--src/api/endpoints/aggregation/users/activity.ts4
-rw-r--r--src/api/endpoints/aggregation/users/post.ts4
-rw-r--r--src/api/endpoints/channels.ts59
-rw-r--r--src/api/endpoints/channels/create.ts39
-rw-r--r--src/api/endpoints/channels/posts.ts79
-rw-r--r--src/api/endpoints/channels/show.ts31
-rw-r--r--src/api/endpoints/channels/unwatch.ts60
-rw-r--r--src/api/endpoints/channels/watch.ts58
-rw-r--r--src/api/endpoints/drive.ts6
-rw-r--r--src/api/endpoints/drive/files.ts25
-rw-r--r--src/api/endpoints/drive/files/find.ts10
-rw-r--r--src/api/endpoints/drive/files/show.ts20
-rw-r--r--src/api/endpoints/drive/files/update.ts18
-rw-r--r--src/api/endpoints/drive/folders/find.ts3
-rw-r--r--src/api/endpoints/drive/folders/update.ts2
-rw-r--r--src/api/endpoints/i/appdata/set.ts2
-rw-r--r--src/api/endpoints/i/change_password.ts42
-rw-r--r--src/api/endpoints/i/notifications.ts14
-rw-r--r--src/api/endpoints/i/pin.ts44
-rw-r--r--src/api/endpoints/i/regenerate_token.ts42
-rw-r--r--src/api/endpoints/messaging/messages/create.ts4
-rw-r--r--src/api/endpoints/notifications/get_unread_count.ts23
-rw-r--r--src/api/endpoints/notifications/mark_as_read.ts47
-rw-r--r--src/api/endpoints/notifications/mark_as_read_all.ts32
-rw-r--r--src/api/endpoints/posts.ts2
-rw-r--r--src/api/endpoints/posts/categorize.ts52
-rw-r--r--src/api/endpoints/posts/context.ts8
-rw-r--r--src/api/endpoints/posts/create.ts170
-rw-r--r--src/api/endpoints/posts/replies.ts2
-rw-r--r--src/api/endpoints/posts/timeline.ts58
-rw-r--r--src/api/endpoints/posts/trend.ts2
-rw-r--r--src/api/endpoints/users/get_frequently_replied_users.ts96
-rw-r--r--src/api/endpoints/users/posts.ts2
-rw-r--r--src/api/event.ts6
-rw-r--r--src/api/models/access-token.ts4
-rw-r--r--src/api/models/app.ts6
-rw-r--r--src/api/models/channel-watching.ts3
-rw-r--r--src/api/models/channel.ts14
-rw-r--r--src/api/models/drive-file.ts17
-rw-r--r--src/api/models/notification.ts5
-rw-r--r--src/api/models/post.ts15
-rw-r--r--src/api/models/user.ts53
-rw-r--r--src/api/private/signin.ts4
-rw-r--r--src/api/private/signup.ts8
-rw-r--r--src/api/serializers/channel.ts66
-rw-r--r--src/api/serializers/drive-file.ts36
-rw-r--r--src/api/serializers/drive-folder.ts2
-rw-r--r--src/api/serializers/post.ts166
-rw-r--r--src/api/serializers/user.ts111
-rw-r--r--src/api/server.ts9
-rw-r--r--src/api/service/github.ts4
-rw-r--r--src/api/stream/channel.ts12
-rw-r--r--src/api/stream/home.ts16
-rw-r--r--src/api/stream/server.ts1
-rw-r--r--src/api/streaming.ts42
-rw-r--r--src/common/get-post-summary.ts (renamed from src/web/app/common/scripts/get-post-summary.js)20
-rw-r--r--src/common/get-user-summary.ts12
-rw-r--r--src/common/othello.ts268
-rw-r--r--src/config.ts9
-rw-r--r--src/const.json5
-rw-r--r--src/db/mongodb.ts35
-rw-r--r--src/docs/api/entities/post.pug10
-rw-r--r--src/file/server.ts46
-rw-r--r--src/tools/analysis/core.ts49
-rw-r--r--src/tools/analysis/extract-user-domains.ts120
-rw-r--r--src/tools/analysis/extract-user-keywords.ts154
-rw-r--r--src/tools/analysis/mecab.js85
-rw-r--r--src/tools/analysis/naive-bayes.js302
-rw-r--r--src/tools/analysis/predict-all-post-category.ts35
-rw-r--r--src/tools/analysis/predict-user-interst.ts45
-rw-r--r--src/tsconfig.json1
-rw-r--r--src/utils/type.ts3
-rw-r--r--src/web/app/app.styl (renamed from src/web/app/base.styl)8
-rw-r--r--src/web/app/auth/style.styl3
-rw-r--r--src/web/app/ch/router.js32
-rw-r--r--src/web/app/ch/script.js18
-rw-r--r--src/web/app/ch/style.styl10
-rw-r--r--src/web/app/ch/tags/channel.tag403
-rw-r--r--src/web/app/ch/tags/header.tag20
-rw-r--r--src/web/app/ch/tags/index.js3
-rw-r--r--src/web/app/ch/tags/index.tag35
-rw-r--r--src/web/app/common/scripts/channel-stream.js16
-rw-r--r--src/web/app/common/scripts/config.js2
-rw-r--r--src/web/app/common/scripts/home-stream.js11
-rw-r--r--src/web/app/common/tags/activity-table.tag1
-rw-r--r--src/web/app/common/tags/api-info.tag27
-rw-r--r--src/web/app/common/tags/error.tag164
-rw-r--r--src/web/app/common/tags/index.js2
-rw-r--r--src/web/app/common/tags/post-menu.tag157
-rw-r--r--src/web/app/common/tags/signup.tag2
-rw-r--r--src/web/app/desktop/router.js22
-rw-r--r--src/web/app/desktop/script.js2
-rw-r--r--src/web/app/desktop/scripts/password-dialog.js11
-rw-r--r--src/web/app/desktop/style.styl6
-rw-r--r--src/web/app/desktop/tags/detailed-post-window.tag80
-rw-r--r--src/web/app/desktop/tags/dialog.tag3
-rw-r--r--src/web/app/desktop/tags/home-widgets/nav.tag2
-rw-r--r--src/web/app/desktop/tags/home-widgets/rss-reader.tag2
-rw-r--r--src/web/app/desktop/tags/home-widgets/version.tag2
-rw-r--r--src/web/app/desktop/tags/index.js12
-rw-r--r--src/web/app/desktop/tags/input-dialog.tag7
-rw-r--r--src/web/app/desktop/tags/notifications.tag8
-rw-r--r--src/web/app/desktop/tags/pages/home.tag2
-rw-r--r--src/web/app/desktop/tags/pages/post.tag34
-rw-r--r--src/web/app/desktop/tags/pages/selectdrive.tag160
-rw-r--r--src/web/app/desktop/tags/pages/user.tag2
-rw-r--r--src/web/app/desktop/tags/post-detail.tag109
-rw-r--r--src/web/app/desktop/tags/post-form.tag2
-rw-r--r--src/web/app/desktop/tags/settings.tag75
-rw-r--r--src/web/app/desktop/tags/sub-post-content.tag2
-rw-r--r--src/web/app/desktop/tags/timeline-post-sub.tag107
-rw-r--r--src/web/app/desktop/tags/timeline-post.tag487
-rw-r--r--src/web/app/desktop/tags/timeline.tag643
-rw-r--r--src/web/app/desktop/tags/ui-header-account.tag214
-rw-r--r--src/web/app/desktop/tags/ui-header-clock.tag86
-rw-r--r--src/web/app/desktop/tags/ui-header-nav.tag133
-rw-r--r--src/web/app/desktop/tags/ui-header-notifications.tag108
-rw-r--r--src/web/app/desktop/tags/ui-header-post-button.tag42
-rw-r--r--src/web/app/desktop/tags/ui-header-search.tag42
-rw-r--r--src/web/app/desktop/tags/ui-header.tag86
-rw-r--r--src/web/app/desktop/tags/ui-notification.tag51
-rw-r--r--src/web/app/desktop/tags/ui.tag784
-rw-r--r--src/web/app/dev/style.styl3
-rw-r--r--src/web/app/init.js17
-rw-r--r--src/web/app/mobile/router.js13
-rw-r--r--src/web/app/mobile/style.styl3
-rw-r--r--src/web/app/mobile/tags/drive-selector.tag7
-rw-r--r--src/web/app/mobile/tags/drive.tag32
-rw-r--r--src/web/app/mobile/tags/home-timeline.tag8
-rw-r--r--src/web/app/mobile/tags/home.tag1
-rw-r--r--src/web/app/mobile/tags/index.js7
-rw-r--r--src/web/app/mobile/tags/init-following.tag76
-rw-r--r--src/web/app/mobile/tags/notification-preview.tag2
-rw-r--r--src/web/app/mobile/tags/notification.tag2
-rw-r--r--src/web/app/mobile/tags/notifications.tag30
-rw-r--r--src/web/app/mobile/tags/page/drive.tag2
-rw-r--r--src/web/app/mobile/tags/page/home.tag3
-rw-r--r--src/web/app/mobile/tags/page/notifications.tag15
-rw-r--r--src/web/app/mobile/tags/page/post.tag58
-rw-r--r--src/web/app/mobile/tags/page/search.tag1
-rw-r--r--src/web/app/mobile/tags/page/selectdrive.tag87
-rw-r--r--src/web/app/mobile/tags/page/settings.tag94
-rw-r--r--src/web/app/mobile/tags/page/settings/api.tag21
-rw-r--r--src/web/app/mobile/tags/page/settings/authorized-apps.tag2
-rw-r--r--src/web/app/mobile/tags/page/settings/profile.tag247
-rw-r--r--src/web/app/mobile/tags/page/settings/signin.tag2
-rw-r--r--src/web/app/mobile/tags/page/settings/twitter.tag2
-rw-r--r--src/web/app/mobile/tags/page/user-followers.tag1
-rw-r--r--src/web/app/mobile/tags/page/user-following.tag1
-rw-r--r--src/web/app/mobile/tags/page/user.tag1
-rw-r--r--src/web/app/mobile/tags/post-detail.tag585
-rw-r--r--src/web/app/mobile/tags/post-form.tag73
-rw-r--r--src/web/app/mobile/tags/search-posts.tag8
-rw-r--r--src/web/app/mobile/tags/sub-post-content.tag2
-rw-r--r--src/web/app/mobile/tags/timeline-post-sub.tag101
-rw-r--r--src/web/app/mobile/tags/timeline-post.tag414
-rw-r--r--src/web/app/mobile/tags/timeline.tag566
-rw-r--r--src/web/app/mobile/tags/ui-header.tag156
-rw-r--r--src/web/app/mobile/tags/ui-nav.tag169
-rw-r--r--src/web/app/mobile/tags/ui.tag371
-rw-r--r--src/web/app/mobile/tags/user-card.tag55
-rw-r--r--src/web/app/mobile/tags/user-timeline.tag2
-rw-r--r--src/web/app/mobile/tags/user.tag554
-rw-r--r--src/web/app/mobile/tags/users-list.tag20
-rw-r--r--src/web/app/reset.styl14
-rw-r--r--src/web/app/safe.js5
-rw-r--r--src/web/app/stats/style.styl3
-rw-r--r--src/web/app/status/style.styl3
177 files changed, 8562 insertions, 3180 deletions
diff --git a/src/api/authenticate.ts b/src/api/authenticate.ts
index d4cc3fc41f..b289959ac1 100644
--- a/src/api/authenticate.ts
+++ b/src/api/authenticate.ts
@@ -1,6 +1,6 @@
import * as express from 'express';
import App from './models/app';
-import User from './models/user';
+import { default as User, IUser } from './models/user';
import AccessToken from './models/access-token';
import isNativeToken from './common/is-native-token';
@@ -13,10 +13,10 @@ export interface IAuthContext {
/**
* Authenticated user
*/
- user: any;
+ user: IUser;
/**
- * Weather if the request is via the User-Native Token or not
+ * Whether requested with a User-Native Token
*/
isSecure: boolean;
}
@@ -25,11 +25,15 @@ export default (req: express.Request) => new Promise<IAuthContext>(async (resolv
const token = req.body['i'] as string;
if (token == null) {
- return resolve({ app: null, user: null, isSecure: false });
+ return resolve({
+ app: null,
+ user: null,
+ isSecure: false
+ });
}
if (isNativeToken(token)) {
- const user = await User
+ const user: IUser = await User
.findOne({ token: token });
if (user === null) {
@@ -56,6 +60,10 @@ export default (req: express.Request) => new Promise<IAuthContext>(async (resolv
const user = await User
.findOne({ _id: accessToken.user_id });
- return resolve({ app: app, user: user, isSecure: false });
+ return resolve({
+ app: app,
+ user: user,
+ isSecure: false
+ });
}
});
diff --git a/src/api/bot/core.ts b/src/api/bot/core.ts
new file mode 100644
index 0000000000..53fb18119e
--- /dev/null
+++ b/src/api/bot/core.ts
@@ -0,0 +1,398 @@
+import * as EventEmitter from 'events';
+import * as bcrypt from 'bcryptjs';
+
+import User, { IUser, init as initUser } from '../models/user';
+
+import getPostSummary from '../../common/get-post-summary';
+import getUserSummary from '../../common/get-user-summary';
+
+import Othello, { ai as othelloAi } from '../../common/othello';
+
+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 | void> {
+ 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' +
+ '@<ユーザー名>: ユーザーを表示します';
+
+ 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 'タイムライン':
+ return await this.tlCommand();
+
+ case 'guessing-game':
+ case '数当てゲーム':
+ this.setContext(new GuessingGameContext(this));
+ return await this.context.greet();
+
+ case 'othello':
+ case 'オセロ':
+ this.setContext(new OthelloContext(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 tlCommand(): Promise<string | void> {
+ if (this.user == null) return 'まずサインインしてください。';
+
+ const tl = await require('../endpoints/posts/timeline')({
+ limit: 5
+ }, this.user);
+
+ const text = tl
+ .map(post => getPostSummary(post))
+ .join('\n-----\n');
+
+ return text;
+ }
+
+ public async showUserCommand(q: string): Promise<string | void> {
+ try {
+ const user = await require('../endpoints/users/show')({
+ username: 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 == 'othello') return OthelloContext.import(bot, data.content);
+ if (data.type == 'post') return PostContext.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()
+ }, {
+ fields: {
+ data: false
+ }
+ });
+
+ if (user === null) {
+ return `${query}というユーザーは存在しませんでした... もう一度教えてください:`;
+ } else {
+ this.temporaryUser = user;
+ this.emit('updated');
+ return `パスワードを教えてください:`;
+ }
+ } else {
+ // Compare password
+ const same = bcrypt.compareSync(query, this.temporaryUser.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 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;
+ }
+}
+
+class OthelloContext extends Context {
+ private othello: Othello = null;
+
+ constructor(bot: BotCore) {
+ super(bot);
+
+ this.othello = new Othello();
+ }
+
+ public async greet(): Promise<string> {
+ return this.othello.toPatternString('black');
+ }
+
+ public async q(query: string): Promise<string> {
+ if (query == 'やめる') {
+ this.bot.clearContext();
+ return 'オセロをやめました。';
+ }
+
+ const n = parseInt(query, 10);
+
+ if (isNaN(n)) {
+ return '番号で指定してください。「やめる」と言うとゲームをやめます。';
+ }
+
+ this.othello.setByNumber('black', n);
+ const s = this.othello.toString() + '\n\n...(AI)...\n\n';
+ othelloAi('white', this.othello);
+ if (this.othello.getPattern('black').length === 0) {
+ this.bot.clearContext();
+ const blackCount = this.othello.board.map(row => row.filter(s => s == 'black').length).reduce((a, b) => a + b);
+ const whiteCount = this.othello.board.map(row => row.filter(s => s == 'white').length).reduce((a, b) => a + b);
+ const winner = blackCount == whiteCount ? '引き分け' : blackCount > whiteCount ? '黒の勝ち' : '白の勝ち';
+ return this.othello.toString() + `\n\n~終了~\n\n黒${blackCount}、白${whiteCount}で${winner}です。`;
+ } else {
+ this.emit('updated');
+ return s + this.othello.toPatternString('black');
+ }
+ }
+
+ public export() {
+ return {
+ type: 'othello',
+ content: {
+ board: this.othello.board
+ }
+ };
+ }
+
+ public static import(bot: BotCore, data: any) {
+ const context = new OthelloContext(bot);
+ context.othello = new Othello();
+ context.othello.board = data.board;
+ return context;
+ }
+}
diff --git a/src/api/bot/interfaces/line.ts b/src/api/bot/interfaces/line.ts
new file mode 100644
index 0000000000..0caa71ed2b
--- /dev/null
+++ b/src/api/bot/interfaces/line.ts
@@ -0,0 +1,234 @@
+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 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')({
+ username: q.substr(1)
+ }, this.user);
+
+ const actions = [];
+
+ actions.push({
+ type: 'postback',
+ label: 'タイムラインを見る',
+ data: `showtl|${user.id}`
+ });
+
+ if (user.twitter) {
+ actions.push({
+ type: 'uri',
+ label: 'Twitterアカウントを見る',
+ uri: `https://twitter.com/${user.twitter.screen_name}`
+ });
+ }
+
+ actions.push({
+ type: 'uri',
+ label: 'Webで見る',
+ uri: `${config.url}/${user.username}`
+ });
+
+ this.reply([{
+ type: 'template',
+ altText: await super.showUserCommand(q),
+ template: {
+ type: 'buttons',
+ thumbnailImageUrl: `${user.avatar_url}?thumbnail&size=1024`,
+ title: `${user.name} (@${user.username})`,
+ text: user.description || '(no description)',
+ actions: actions
+ }
+ }]);
+ }
+
+ 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({
+ line: {
+ user_id: sourceId
+ }
+ });
+
+ bot = new LineBot(user);
+
+ bot.on('signin', user => {
+ User.update(user._id, {
+ $set: {
+ line: {
+ user_id: sourceId
+ }
+ }
+ });
+ });
+
+ bot.on('signout', user => {
+ User.update(user._id, {
+ $set: {
+ 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/api/common/add-file-to-drive.ts b/src/api/common/add-file-to-drive.ts
index 714eeb520d..f9c22ccacd 100644
--- a/src/api/common/add-file-to-drive.ts
+++ b/src/api/common/add-file-to-drive.ts
@@ -4,14 +4,27 @@ import * as gm from 'gm';
import * as debug from 'debug';
import fileType = require('file-type');
import prominence = require('prominence');
-import DriveFile from '../models/drive-file';
+import DriveFile, { getGridFSBucket } from '../models/drive-file';
import DriveFolder from '../models/drive-folder';
import serialize from '../serializers/drive-file';
import event from '../event';
import config from '../../conf';
+import { Duplex } from 'stream';
const log = debug('misskey:register-drive-file');
+const addToGridFS = (name, binary, metadata): Promise<any> => new Promise(async (resolve, reject) => {
+ const dataStream = new Duplex();
+ dataStream.push(binary);
+ dataStream.push(null);
+
+ const bucket = await getGridFSBucket();
+ const writeStream = bucket.openUploadStream(name, { metadata });
+ writeStream.once('finish', (doc) => { resolve(doc); });
+ writeStream.on('error', reject);
+ dataStream.pipe(writeStream);
+});
+
/**
* Add file to drive
*
@@ -58,7 +71,7 @@ export default (
// Generate hash
const hash = crypto
- .createHash('sha256')
+ .createHash('md5')
.update(data)
.digest('hex') as string;
@@ -67,8 +80,8 @@ export default (
if (!force) {
// Check if there is a file with the same hash
const much = await DriveFile.findOne({
- user_id: user._id,
- hash: hash
+ md5: hash,
+ 'metadata.user_id': user._id
});
if (much !== null) {
@@ -82,13 +95,13 @@ export default (
// Calculate drive usage
const usage = ((await DriveFile
.aggregate([
- { $match: { user_id: user._id } },
+ { $match: { 'metadata.user_id': user._id } },
{ $project: {
- datasize: true
+ length: true
}},
{ $group: {
_id: null,
- usage: { $sum: '$datasize' }
+ usage: { $sum: '$length' }
}}
]))[0] || {
usage: 0
@@ -131,21 +144,15 @@ export default (
}
// Create DriveFile document
- const file = await DriveFile.insert({
- created_at: new Date(),
+ const file = await addToGridFS(`${user._id}/${name}`, data, {
user_id: user._id,
folder_id: folder !== null ? folder._id : null,
- data: data,
- datasize: size,
type: mime,
name: name,
comment: comment,
- hash: hash,
properties: properties
});
- delete file.data;
-
log(`drive file has been created ${file._id}`);
resolve(file);
diff --git a/src/api/common/generate-native-user-token.ts b/src/api/common/generate-native-user-token.ts
new file mode 100644
index 0000000000..2082b89a5a
--- /dev/null
+++ b/src/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/api/common/read-notification.ts b/src/api/common/read-notification.ts
new file mode 100644
index 0000000000..3009cc5d08
--- /dev/null
+++ b/src/api/common/read-notification.ts
@@ -0,0 +1,52 @@
+import * as mongo from 'mongodb';
+import { default as Notification, INotification } from '../models/notification';
+import publishUserStream from '../event';
+
+/**
+ * Mark as read notification(s)
+ */
+export default (
+ user: string | mongo.ObjectID,
+ message: string | string[] | INotification | INotification[] | mongo.ObjectID | mongo.ObjectID[]
+) => new Promise<any>(async (resolve, reject) => {
+
+ const userId = mongo.ObjectID.prototype.isPrototypeOf(user)
+ ? user
+ : new mongo.ObjectID(user);
+
+ const ids: mongo.ObjectID[] = Array.isArray(message)
+ ? mongo.ObjectID.prototype.isPrototypeOf(message[0])
+ ? (message as mongo.ObjectID[])
+ : typeof message[0] === 'string'
+ ? (message as string[]).map(m => new mongo.ObjectID(m))
+ : (message as INotification[]).map(m => m._id)
+ : mongo.ObjectID.prototype.isPrototypeOf(message)
+ ? [(message as mongo.ObjectID)]
+ : typeof message === 'string'
+ ? [new mongo.ObjectID(message)]
+ : [(message as INotification)._id];
+
+ // Update documents
+ await Notification.update({
+ _id: { $in: ids },
+ is_read: false
+ }, {
+ $set: {
+ is_read: true
+ }
+ }, {
+ multi: true
+ });
+
+ // Calc count of my unread notifications
+ const count = await Notification
+ .count({
+ notifiee_id: userId,
+ is_read: false
+ });
+
+ if (count == 0) {
+ // 全ての(いままで未読だった)通知を(これで)読みましたよというイベントを発行
+ publishUserStream(userId, 'read_all_notifications');
+ }
+});
diff --git a/src/api/endpoints.ts b/src/api/endpoints.ts
index 5bbc480a8e..afefce39e5 100644
--- a/src/api/endpoints.ts
+++ b/src/api/endpoints.ts
@@ -160,6 +160,18 @@ const endpoints: Endpoint[] = [
kind: 'account-write'
},
{
+ name: 'i/change_password',
+ withCredential: true
+ },
+ {
+ name: 'i/regenerate_token',
+ withCredential: true
+ },
+ {
+ name: 'i/pin',
+ kind: 'account-write'
+ },
+ {
name: 'i/appdata/get',
withCredential: true
},
@@ -184,17 +196,17 @@ const endpoints: Endpoint[] = [
kind: 'notification-read'
},
{
- name: 'notifications/delete',
+ name: 'notifications/get_unread_count',
withCredential: true,
- kind: 'notification-write'
+ kind: 'notification-read'
},
{
- name: 'notifications/delete_all',
+ name: 'notifications/delete',
withCredential: true,
kind: 'notification-write'
},
{
- name: 'notifications/mark_as_read',
+ name: 'notifications/delete_all',
withCredential: true,
kind: 'notification-write'
},
@@ -314,6 +326,9 @@ const endpoints: Endpoint[] = [
withCredential: true,
kind: 'account-read'
},
+ {
+ name: 'users/get_frequently_replied_users'
+ },
{
name: 'following/create',
@@ -383,6 +398,10 @@ const endpoints: Endpoint[] = [
withCredential: true
},
{
+ name: 'posts/categorize',
+ withCredential: true
+ },
+ {
name: 'posts/reactions',
withCredential: true
},
@@ -455,8 +474,33 @@ const endpoints: Endpoint[] = [
name: 'messaging/messages/create',
withCredential: true,
kind: 'messaging-write'
- }
-
+ },
+ {
+ name: 'channels/create',
+ withCredential: true,
+ limit: {
+ duration: ms('1hour'),
+ max: 3,
+ minInterval: ms('10seconds')
+ }
+ },
+ {
+ name: 'channels/show'
+ },
+ {
+ name: 'channels/posts'
+ },
+ {
+ name: 'channels/watch',
+ withCredential: true
+ },
+ {
+ name: 'channels/unwatch',
+ withCredential: true
+ },
+ {
+ name: 'channels'
+ },
];
export default endpoints;
diff --git a/src/api/endpoints/aggregation/posts.ts b/src/api/endpoints/aggregation/posts.ts
index 48ee225129..9d8bccbdb2 100644
--- a/src/api/endpoints/aggregation/posts.ts
+++ b/src/api/endpoints/aggregation/posts.ts
@@ -19,7 +19,7 @@ module.exports = params => new Promise(async (res, rej) => {
.aggregate([
{ $project: {
repost_id: '$repost_id',
- reply_to_id: '$reply_to_id',
+ reply_id: '$reply_id',
created_at: { $add: ['$created_at', 9 * 60 * 60 * 1000] } // Convert into JST
}},
{ $project: {
@@ -34,7 +34,7 @@ module.exports = params => new Promise(async (res, rej) => {
then: 'repost',
else: {
$cond: {
- if: { $ne: ['$reply_to_id', null] },
+ if: { $ne: ['$reply_id', null] },
then: 'reply',
else: 'post'
}
diff --git a/src/api/endpoints/aggregation/posts/reply.ts b/src/api/endpoints/aggregation/posts/reply.ts
index 02a60c8969..b114c34e1e 100644
--- a/src/api/endpoints/aggregation/posts/reply.ts
+++ b/src/api/endpoints/aggregation/posts/reply.ts
@@ -26,7 +26,7 @@ module.exports = (params) => new Promise(async (res, rej) => {
const datas = await Post
.aggregate([
- { $match: { reply_to: post._id } },
+ { $match: { reply: post._id } },
{ $project: {
created_at: { $add: ['$created_at', 9 * 60 * 60 * 1000] } // Convert into JST
}},
diff --git a/src/api/endpoints/aggregation/users/activity.ts b/src/api/endpoints/aggregation/users/activity.ts
index 5a3e78c441..102a71d7cb 100644
--- a/src/api/endpoints/aggregation/users/activity.ts
+++ b/src/api/endpoints/aggregation/users/activity.ts
@@ -40,7 +40,7 @@ module.exports = (params) => new Promise(async (res, rej) => {
{ $match: { user_id: user._id } },
{ $project: {
repost_id: '$repost_id',
- reply_to_id: '$reply_to_id',
+ reply_id: '$reply_id',
created_at: { $add: ['$created_at', 9 * 60 * 60 * 1000] } // Convert into JST
}},
{ $project: {
@@ -55,7 +55,7 @@ module.exports = (params) => new Promise(async (res, rej) => {
then: 'repost',
else: {
$cond: {
- if: { $ne: ['$reply_to_id', null] },
+ if: { $ne: ['$reply_id', null] },
then: 'reply',
else: 'post'
}
diff --git a/src/api/endpoints/aggregation/users/post.ts b/src/api/endpoints/aggregation/users/post.ts
index c964815a0c..c6a75eee39 100644
--- a/src/api/endpoints/aggregation/users/post.ts
+++ b/src/api/endpoints/aggregation/users/post.ts
@@ -34,7 +34,7 @@ module.exports = (params) => new Promise(async (res, rej) => {
{ $match: { user_id: user._id } },
{ $project: {
repost_id: '$repost_id',
- reply_to_id: '$reply_to_id',
+ reply_id: '$reply_id',
created_at: { $add: ['$created_at', 9 * 60 * 60 * 1000] } // Convert into JST
}},
{ $project: {
@@ -49,7 +49,7 @@ module.exports = (params) => new Promise(async (res, rej) => {
then: 'repost',
else: {
$cond: {
- if: { $ne: ['$reply_to_id', null] },
+ if: { $ne: ['$reply_id', null] },
then: 'reply',
else: 'post'
}
diff --git a/src/api/endpoints/channels.ts b/src/api/endpoints/channels.ts
new file mode 100644
index 0000000000..e10c943896
--- /dev/null
+++ b/src/api/endpoints/channels.ts
@@ -0,0 +1,59 @@
+/**
+ * Module dependencies
+ */
+import $ from 'cafy';
+import Channel from '../models/channel';
+import serialize from '../serializers/channel';
+
+/**
+ * Get all channels
+ *
+ * @param {any} params
+ * @param {any} me
+ * @return {Promise<any>}
+ */
+module.exports = (params, me) => new Promise(async (res, rej) => {
+ // Get 'limit' parameter
+ const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$;
+ if (limitErr) return rej('invalid limit param');
+
+ // Get 'since_id' parameter
+ const [sinceId, sinceIdErr] = $(params.since_id).optional.id().$;
+ if (sinceIdErr) return rej('invalid since_id param');
+
+ // Get 'max_id' parameter
+ const [maxId, maxIdErr] = $(params.max_id).optional.id().$;
+ if (maxIdErr) return rej('invalid max_id param');
+
+ // Check if both of since_id and max_id is specified
+ if (sinceId && maxId) {
+ return rej('cannot set since_id and max_id');
+ }
+
+ // Construct query
+ const sort = {
+ _id: -1
+ };
+ const query = {} as any;
+ if (sinceId) {
+ sort._id = 1;
+ query._id = {
+ $gt: sinceId
+ };
+ } else if (maxId) {
+ query._id = {
+ $lt: maxId
+ };
+ }
+
+ // Issue query
+ const channels = await Channel
+ .find(query, {
+ limit: limit,
+ sort: sort
+ });
+
+ // Serialize
+ res(await Promise.all(channels.map(async channel =>
+ await serialize(channel, me))));
+});
diff --git a/src/api/endpoints/channels/create.ts b/src/api/endpoints/channels/create.ts
new file mode 100644
index 0000000000..a8d7c29dc1
--- /dev/null
+++ b/src/api/endpoints/channels/create.ts
@@ -0,0 +1,39 @@
+/**
+ * Module dependencies
+ */
+import $ from 'cafy';
+import Channel from '../../models/channel';
+import Watching from '../../models/channel-watching';
+import serialize from '../../serializers/channel';
+
+/**
+ * Create a channel
+ *
+ * @param {any} params
+ * @param {any} user
+ * @return {Promise<any>}
+ */
+module.exports = async (params, user) => new Promise(async (res, rej) => {
+ // Get 'title' parameter
+ const [title, titleErr] = $(params.title).string().range(1, 100).$;
+ if (titleErr) return rej('invalid title param');
+
+ // Create a channel
+ const channel = await Channel.insert({
+ created_at: new Date(),
+ user_id: user._id,
+ title: title,
+ index: 0,
+ watching_count: 1
+ });
+
+ // Response
+ res(await serialize(channel));
+
+ // Create Watching
+ await Watching.insert({
+ created_at: new Date(),
+ user_id: user._id,
+ channel_id: channel._id
+ });
+});
diff --git a/src/api/endpoints/channels/posts.ts b/src/api/endpoints/channels/posts.ts
new file mode 100644
index 0000000000..fa91fb93ee
--- /dev/null
+++ b/src/api/endpoints/channels/posts.ts
@@ -0,0 +1,79 @@
+/**
+ * Module dependencies
+ */
+import $ from 'cafy';
+import { default as Channel, IChannel } from '../../models/channel';
+import { default as Post, IPost } from '../../models/post';
+import serialize from '../../serializers/post';
+
+/**
+ * Show a posts of a channel
+ *
+ * @param {any} params
+ * @param {any} user
+ * @return {Promise<any>}
+ */
+module.exports = (params, user) => new Promise(async (res, rej) => {
+ // Get 'limit' parameter
+ const [limit = 1000, limitErr] = $(params.limit).optional.number().range(1, 1000).$;
+ if (limitErr) return rej('invalid limit param');
+
+ // Get 'since_id' parameter
+ const [sinceId, sinceIdErr] = $(params.since_id).optional.id().$;
+ if (sinceIdErr) return rej('invalid since_id param');
+
+ // Get 'max_id' parameter
+ const [maxId, maxIdErr] = $(params.max_id).optional.id().$;
+ if (maxIdErr) return rej('invalid max_id param');
+
+ // Check if both of since_id and max_id is specified
+ if (sinceId && maxId) {
+ return rej('cannot set since_id and max_id');
+ }
+
+ // Get 'channel_id' parameter
+ const [channelId, channelIdErr] = $(params.channel_id).id().$;
+ if (channelIdErr) return rej('invalid channel_id param');
+
+ // Fetch channel
+ const channel: IChannel = await Channel.findOne({
+ _id: channelId
+ });
+
+ if (channel === null) {
+ return rej('channel not found');
+ }
+
+ //#region Construct query
+ const sort = {
+ _id: -1
+ };
+
+ const query = {
+ channel_id: channel._id
+ } as any;
+
+ if (sinceId) {
+ sort._id = 1;
+ query._id = {
+ $gt: sinceId
+ };
+ } else if (maxId) {
+ query._id = {
+ $lt: maxId
+ };
+ }
+ //#endregion Construct query
+
+ // Issue query
+ const posts = await Post
+ .find(query, {
+ limit: limit,
+ sort: sort
+ });
+
+ // Serialize
+ res(await Promise.all(posts.map(async (post) =>
+ await serialize(post, user)
+ )));
+});
diff --git a/src/api/endpoints/channels/show.ts b/src/api/endpoints/channels/show.ts
new file mode 100644
index 0000000000..8861e54594
--- /dev/null
+++ b/src/api/endpoints/channels/show.ts
@@ -0,0 +1,31 @@
+/**
+ * Module dependencies
+ */
+import $ from 'cafy';
+import { default as Channel, IChannel } from '../../models/channel';
+import serialize from '../../serializers/channel';
+
+/**
+ * Show a channel
+ *
+ * @param {any} params
+ * @param {any} user
+ * @return {Promise<any>}
+ */
+module.exports = (params, user) => new Promise(async (res, rej) => {
+ // Get 'channel_id' parameter
+ const [channelId, channelIdErr] = $(params.channel_id).id().$;
+ if (channelIdErr) return rej('invalid channel_id param');
+
+ // Fetch channel
+ const channel: IChannel = await Channel.findOne({
+ _id: channelId
+ });
+
+ if (channel === null) {
+ return rej('channel not found');
+ }
+
+ // Serialize
+ res(await serialize(channel, user));
+});
diff --git a/src/api/endpoints/channels/unwatch.ts b/src/api/endpoints/channels/unwatch.ts
new file mode 100644
index 0000000000..19d3be118a
--- /dev/null
+++ b/src/api/endpoints/channels/unwatch.ts
@@ -0,0 +1,60 @@
+/**
+ * Module dependencies
+ */
+import $ from 'cafy';
+import Channel from '../../models/channel';
+import Watching from '../../models/channel-watching';
+
+/**
+ * Unwatch a channel
+ *
+ * @param {any} params
+ * @param {any} user
+ * @return {Promise<any>}
+ */
+module.exports = (params, user) => new Promise(async (res, rej) => {
+ // Get 'channel_id' parameter
+ const [channelId, channelIdErr] = $(params.channel_id).id().$;
+ if (channelIdErr) return rej('invalid channel_id param');
+
+ //#region Fetch channel
+ const channel = await Channel.findOne({
+ _id: channelId
+ });
+
+ if (channel === null) {
+ return rej('channel not found');
+ }
+ //#endregion
+
+ //#region Check whether not watching
+ const exist = await Watching.findOne({
+ user_id: user._id,
+ channel_id: channel._id,
+ deleted_at: { $exists: false }
+ });
+
+ if (exist === null) {
+ return rej('already not watching');
+ }
+ //#endregion
+
+ // Delete watching
+ await Watching.update({
+ _id: exist._id
+ }, {
+ $set: {
+ deleted_at: new Date()
+ }
+ });
+
+ // Send response
+ res();
+
+ // Decrement watching count
+ Channel.update(channel._id, {
+ $inc: {
+ watching_count: -1
+ }
+ });
+});
diff --git a/src/api/endpoints/channels/watch.ts b/src/api/endpoints/channels/watch.ts
new file mode 100644
index 0000000000..030e0dd411
--- /dev/null
+++ b/src/api/endpoints/channels/watch.ts
@@ -0,0 +1,58 @@
+/**
+ * Module dependencies
+ */
+import $ from 'cafy';
+import Channel from '../../models/channel';
+import Watching from '../../models/channel-watching';
+
+/**
+ * Watch a channel
+ *
+ * @param {any} params
+ * @param {any} user
+ * @return {Promise<any>}
+ */
+module.exports = (params, user) => new Promise(async (res, rej) => {
+ // Get 'channel_id' parameter
+ const [channelId, channelIdErr] = $(params.channel_id).id().$;
+ if (channelIdErr) return rej('invalid channel_id param');
+
+ //#region Fetch channel
+ const channel = await Channel.findOne({
+ _id: channelId
+ });
+
+ if (channel === null) {
+ return rej('channel not found');
+ }
+ //#endregion
+
+ //#region Check whether already watching
+ const exist = await Watching.findOne({
+ user_id: user._id,
+ channel_id: channel._id,
+ deleted_at: { $exists: false }
+ });
+
+ if (exist !== null) {
+ return rej('already watching');
+ }
+ //#endregion
+
+ // Create Watching
+ await Watching.insert({
+ created_at: new Date(),
+ user_id: user._id,
+ channel_id: channel._id
+ });
+
+ // Send response
+ res();
+
+ // Increment watching count
+ Channel.update(channel._id, {
+ $inc: {
+ watching_count: 1
+ }
+ });
+});
diff --git a/src/api/endpoints/drive.ts b/src/api/endpoints/drive.ts
index 41ad6301d7..d92473633a 100644
--- a/src/api/endpoints/drive.ts
+++ b/src/api/endpoints/drive.ts
@@ -14,16 +14,16 @@ module.exports = (params, user) => new Promise(async (res, rej) => {
// Calculate drive usage
const usage = ((await DriveFile
.aggregate([
- { $match: { user_id: user._id } },
+ { $match: { 'metadata.user_id': user._id } },
{
$project: {
- datasize: true
+ length: true
}
},
{
$group: {
_id: null,
- usage: { $sum: '$datasize' }
+ usage: { $sum: '$length' }
}
}
]))[0] || {
diff --git a/src/api/endpoints/drive/files.ts b/src/api/endpoints/drive/files.ts
index a68ae34817..53b48a8bec 100644
--- a/src/api/endpoints/drive/files.ts
+++ b/src/api/endpoints/drive/files.ts
@@ -13,35 +13,35 @@ import serialize from '../../serializers/drive-file';
* @param {any} app
* @return {Promise<any>}
*/
-module.exports = (params, user, app) => new Promise(async (res, rej) => {
+module.exports = async (params, user, app) => {
// Get 'limit' parameter
const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$;
- if (limitErr) return rej('invalid limit param');
+ if (limitErr) throw 'invalid limit param';
// Get 'since_id' parameter
const [sinceId, sinceIdErr] = $(params.since_id).optional.id().$;
- if (sinceIdErr) return rej('invalid since_id param');
+ if (sinceIdErr) throw 'invalid since_id param';
// Get 'max_id' parameter
const [maxId, maxIdErr] = $(params.max_id).optional.id().$;
- if (maxIdErr) return rej('invalid max_id param');
+ if (maxIdErr) throw 'invalid max_id param';
// Check if both of since_id and max_id is specified
if (sinceId && maxId) {
- return rej('cannot set since_id and max_id');
+ throw 'cannot set since_id and max_id';
}
// Get 'folder_id' parameter
const [folderId = null, folderIdErr] = $(params.folder_id).optional.nullable.id().$;
- if (folderIdErr) return rej('invalid folder_id param');
+ if (folderIdErr) throw 'invalid folder_id param';
// Construct query
const sort = {
_id: -1
};
const query = {
- user_id: user._id,
- folder_id: folderId
+ 'metadata.user_id': user._id,
+ 'metadata.folder_id': folderId
} as any;
if (sinceId) {
sort._id = 1;
@@ -57,14 +57,11 @@ module.exports = (params, user, app) => new Promise(async (res, rej) => {
// Issue query
const files = await DriveFile
.find(query, {
- fields: {
- data: false
- },
limit: limit,
sort: sort
});
// Serialize
- res(await Promise.all(files.map(async file =>
- await serialize(file))));
-});
+ const _files = await Promise.all(files.map(file => serialize(file)));
+ return _files;
+};
diff --git a/src/api/endpoints/drive/files/find.ts b/src/api/endpoints/drive/files/find.ts
index cd0b33f2ca..1c818131d7 100644
--- a/src/api/endpoints/drive/files/find.ts
+++ b/src/api/endpoints/drive/files/find.ts
@@ -24,13 +24,9 @@ module.exports = (params, user) => new Promise(async (res, rej) => {
// Issue query
const files = await DriveFile
.find({
- name: name,
- user_id: user._id,
- folder_id: folderId
- }, {
- fields: {
- data: false
- }
+ 'metadata.name': name,
+ 'metadata.user_id': user._id,
+ 'metadata.folder_id': folderId
});
// Serialize
diff --git a/src/api/endpoints/drive/files/show.ts b/src/api/endpoints/drive/files/show.ts
index 8dbc297e4f..3c7cf774f9 100644
--- a/src/api/endpoints/drive/files/show.ts
+++ b/src/api/endpoints/drive/files/show.ts
@@ -12,28 +12,26 @@ import serialize from '../../../serializers/drive-file';
* @param {any} user
* @return {Promise<any>}
*/
-module.exports = (params, user) => new Promise(async (res, rej) => {
+module.exports = async (params, user) => {
// Get 'file_id' parameter
const [fileId, fileIdErr] = $(params.file_id).id().$;
- if (fileIdErr) return rej('invalid file_id param');
+ if (fileIdErr) throw 'invalid file_id param';
// Fetch file
const file = await DriveFile
.findOne({
_id: fileId,
- user_id: user._id
- }, {
- fields: {
- data: false
- }
+ 'metadata.user_id': user._id
});
if (file === null) {
- return rej('file-not-found');
+ throw 'file-not-found';
}
// Serialize
- res(await serialize(file, {
+ const _file = await serialize(file, {
detail: true
- }));
-});
+ });
+
+ return _file;
+};
diff --git a/src/api/endpoints/drive/files/update.ts b/src/api/endpoints/drive/files/update.ts
index 1cfbdd8f0b..d7b858c2ba 100644
--- a/src/api/endpoints/drive/files/update.ts
+++ b/src/api/endpoints/drive/files/update.ts
@@ -24,11 +24,7 @@ module.exports = (params, user) => new Promise(async (res, rej) => {
const file = await DriveFile
.findOne({
_id: fileId,
- user_id: user._id
- }, {
- fields: {
- data: false
- }
+ 'metadata.user_id': user._id
});
if (file === null) {
@@ -38,7 +34,7 @@ module.exports = (params, user) => new Promise(async (res, rej) => {
// Get 'name' parameter
const [name, nameErr] = $(params.name).optional.string().pipe(validateFileName).$;
if (nameErr) return rej('invalid name param');
- if (name) file.name = name;
+ if (name) file.metadata.name = name;
// Get 'folder_id' parameter
const [folderId, folderIdErr] = $(params.folder_id).optional.nullable.id().$;
@@ -46,7 +42,7 @@ module.exports = (params, user) => new Promise(async (res, rej) => {
if (folderId !== undefined) {
if (folderId === null) {
- file.folder_id = null;
+ file.metadata.folder_id = null;
} else {
// Fetch folder
const folder = await DriveFolder
@@ -59,14 +55,14 @@ module.exports = (params, user) => new Promise(async (res, rej) => {
return rej('folder-not-found');
}
- file.folder_id = folder._id;
+ file.metadata.folder_id = folder._id;
}
}
- DriveFile.update(file._id, {
+ await DriveFile.update(file._id, {
$set: {
- name: file.name,
- folder_id: file.folder_id
+ 'metadata.name': file.metadata.name,
+ 'metadata.folder_id': file.metadata.folder_id
}
});
diff --git a/src/api/endpoints/drive/folders/find.ts b/src/api/endpoints/drive/folders/find.ts
index cdf055839a..a5eb8e015d 100644
--- a/src/api/endpoints/drive/folders/find.ts
+++ b/src/api/endpoints/drive/folders/find.ts
@@ -30,6 +30,5 @@ module.exports = (params, user) => new Promise(async (res, rej) => {
});
// Serialize
- res(await Promise.all(folders.map(async folder =>
- await serialize(folder))));
+ res(await Promise.all(folders.map(folder => serialize(folder))));
});
diff --git a/src/api/endpoints/drive/folders/update.ts b/src/api/endpoints/drive/folders/update.ts
index eec2757878..4f2e3d2a7a 100644
--- a/src/api/endpoints/drive/folders/update.ts
+++ b/src/api/endpoints/drive/folders/update.ts
@@ -4,7 +4,7 @@
import $ from 'cafy';
import DriveFolder from '../../../models/drive-folder';
import { isValidFolderName } from '../../../models/drive-folder';
-import serialize from '../../../serializers/drive-file';
+import serialize from '../../../serializers/drive-folder';
import event from '../../../event';
/**
diff --git a/src/api/endpoints/i/appdata/set.ts b/src/api/endpoints/i/appdata/set.ts
index 24f192de6b..9c3dbe185b 100644
--- a/src/api/endpoints/i/appdata/set.ts
+++ b/src/api/endpoints/i/appdata/set.ts
@@ -21,7 +21,7 @@ module.exports = (params, user, app, isSecure) => new Promise(async (res, rej) =
const [data, dataError] = $(params.data).optional.object()
.pipe(obj => {
const hasInvalidData = Object.entries(obj).some(([k, v]) =>
- $(k).string().match(/^[a-z_]+$/).isNg() && $(v).string().isNg());
+ $(k).string().match(/^[a-z_]+$/).nok() && $(v).string().nok());
return !hasInvalidData;
}).$;
if (dataError) return rej('invalid data param');
diff --git a/src/api/endpoints/i/change_password.ts b/src/api/endpoints/i/change_password.ts
new file mode 100644
index 0000000000..faceded29d
--- /dev/null
+++ b/src/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 = bcrypt.compareSync(currentPassword, user.password);
+
+ if (!same) {
+ return rej('incorrect password');
+ }
+
+ // Generate hash of password
+ const salt = bcrypt.genSaltSync(8);
+ const hash = bcrypt.hashSync(newPassword, salt);
+
+ await User.update(user._id, {
+ $set: {
+ password: hash
+ }
+ });
+
+ res();
+});
diff --git a/src/api/endpoints/i/notifications.ts b/src/api/endpoints/i/notifications.ts
index 5575fb7412..607e0768a4 100644
--- a/src/api/endpoints/i/notifications.ts
+++ b/src/api/endpoints/i/notifications.ts
@@ -5,6 +5,7 @@ import $ from 'cafy';
import Notification from '../../models/notification';
import serialize from '../../serializers/notification';
import getFriends from '../../common/get-friends';
+import read from '../../common/read-notification';
/**
* Get notifications
@@ -91,17 +92,6 @@ module.exports = (params, user) => new Promise(async (res, rej) => {
// Mark as read all
if (notifications.length > 0 && markAsRead) {
- const ids = notifications
- .filter(x => x.is_read == false)
- .map(x => x._id);
-
- // Update documents
- await Notification.update({
- _id: { $in: ids }
- }, {
- $set: { is_read: true }
- }, {
- multi: true
- });
+ read(user._id, notifications);
}
});
diff --git a/src/api/endpoints/i/pin.ts b/src/api/endpoints/i/pin.ts
new file mode 100644
index 0000000000..a94950d22b
--- /dev/null
+++ b/src/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 serialize from '../../serializers/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 serialize(user, user, {
+ detail: true
+ });
+
+ // Send response
+ res(iObj);
+});
diff --git a/src/api/endpoints/i/regenerate_token.ts b/src/api/endpoints/i/regenerate_token.ts
new file mode 100644
index 0000000000..f96d10ebfc
--- /dev/null
+++ b/src/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 = bcrypt.compareSync(password, user.password);
+
+ if (!same) {
+ return rej('incorrect password');
+ }
+
+ // Generate secret
+ const secret = generateUserToken();
+
+ await User.update(user._id, {
+ $set: {
+ token: secret
+ }
+ });
+
+ res();
+
+ // Publish event
+ event(user._id, 'my_token_regenerated');
+});
diff --git a/src/api/endpoints/messaging/messages/create.ts b/src/api/endpoints/messaging/messages/create.ts
index 8af55d850c..149852c093 100644
--- a/src/api/endpoints/messaging/messages/create.ts
+++ b/src/api/endpoints/messaging/messages/create.ts
@@ -54,9 +54,7 @@ module.exports = (params, user) => new Promise(async (res, rej) => {
if (fileId !== undefined) {
file = await DriveFile.findOne({
_id: fileId,
- user_id: user._id
- }, {
- data: false
+ 'metadata.user_id': user._id
});
if (file === null) {
diff --git a/src/api/endpoints/notifications/get_unread_count.ts b/src/api/endpoints/notifications/get_unread_count.ts
new file mode 100644
index 0000000000..9514e78713
--- /dev/null
+++ b/src/api/endpoints/notifications/get_unread_count.ts
@@ -0,0 +1,23 @@
+/**
+ * Module dependencies
+ */
+import Notification from '../../models/notification';
+
+/**
+ * Get count of unread notifications
+ *
+ * @param {any} params
+ * @param {any} user
+ * @return {Promise<any>}
+ */
+module.exports = (params, user) => new Promise(async (res, rej) => {
+ const count = await Notification
+ .count({
+ notifiee_id: user._id,
+ is_read: false
+ });
+
+ res({
+ count: count
+ });
+});
diff --git a/src/api/endpoints/notifications/mark_as_read.ts b/src/api/endpoints/notifications/mark_as_read.ts
deleted file mode 100644
index 5cce33e850..0000000000
--- a/src/api/endpoints/notifications/mark_as_read.ts
+++ /dev/null
@@ -1,47 +0,0 @@
-/**
- * Module dependencies
- */
-import $ from 'cafy';
-import Notification from '../../models/notification';
-import serialize from '../../serializers/notification';
-import event from '../../event';
-
-/**
- * Mark as read a notification
- *
- * @param {any} params
- * @param {any} user
- * @return {Promise<any>}
- */
-module.exports = (params, user) => new Promise(async (res, rej) => {
- const [notificationId, notificationIdErr] = $(params.notification_id).id().$;
- if (notificationIdErr) return rej('invalid notification_id param');
-
- // Get notification
- const notification = await Notification
- .findOne({
- _id: notificationId,
- i: user._id
- });
-
- if (notification === null) {
- return rej('notification-not-found');
- }
-
- // Update
- notification.is_read = true;
- Notification.update({ _id: notification._id }, {
- $set: {
- is_read: true
- }
- });
-
- // Response
- res();
-
- // Serialize
- const notificationObj = await serialize(notification);
-
- // Publish read_notification event
- event(user._id, 'read_notification', notificationObj);
-});
diff --git a/src/api/endpoints/notifications/mark_as_read_all.ts b/src/api/endpoints/notifications/mark_as_read_all.ts
new file mode 100644
index 0000000000..3550e344c4
--- /dev/null
+++ b/src/api/endpoints/notifications/mark_as_read_all.ts
@@ -0,0 +1,32 @@
+/**
+ * Module dependencies
+ */
+import Notification from '../../models/notification';
+import event from '../../event';
+
+/**
+ * Mark as read all notifications
+ *
+ * @param {any} params
+ * @param {any} user
+ * @return {Promise<any>}
+ */
+module.exports = (params, user) => new Promise(async (res, rej) => {
+ // Update documents
+ await Notification.update({
+ notifiee_id: user._id,
+ is_read: false
+ }, {
+ $set: {
+ is_read: true
+ }
+ }, {
+ multi: true
+ });
+
+ // Response
+ res();
+
+ // 全ての通知を読みましたよというイベントを発行
+ event(user._id, 'read_all_notifications');
+});
diff --git a/src/api/endpoints/posts.ts b/src/api/endpoints/posts.ts
index 23b9bd0b66..f6efcc108d 100644
--- a/src/api/endpoints/posts.ts
+++ b/src/api/endpoints/posts.ts
@@ -62,7 +62,7 @@ module.exports = (params) => new Promise(async (res, rej) => {
}
if (reply != undefined) {
- query.reply_to_id = reply ? { $exists: true, $ne: null } : null;
+ query.reply_id = reply ? { $exists: true, $ne: null } : null;
}
if (repost != undefined) {
diff --git a/src/api/endpoints/posts/categorize.ts b/src/api/endpoints/posts/categorize.ts
new file mode 100644
index 0000000000..3530ba6bc4
--- /dev/null
+++ b/src/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.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/api/endpoints/posts/context.ts b/src/api/endpoints/posts/context.ts
index cd5f15f481..bad59a6bee 100644
--- a/src/api/endpoints/posts/context.ts
+++ b/src/api/endpoints/posts/context.ts
@@ -49,13 +49,13 @@ module.exports = (params, user) => new Promise(async (res, rej) => {
return;
}
- if (p.reply_to_id) {
- await get(p.reply_to_id);
+ if (p.reply_id) {
+ await get(p.reply_id);
}
}
- if (post.reply_to_id) {
- await get(post.reply_to_id);
+ if (post.reply_id) {
+ await get(post.reply_id);
}
// Serialize
diff --git a/src/api/endpoints/posts/create.ts b/src/api/endpoints/posts/create.ts
index eb979402c4..4f4b7e2e83 100644
--- a/src/api/endpoints/posts/create.ts
+++ b/src/api/endpoints/posts/create.ts
@@ -4,16 +4,17 @@
import $ from 'cafy';
import deepEqual = require('deep-equal');
import parse from '../../common/text';
-import Post from '../../models/post';
-import { isValidText } from '../../models/post';
-import User from '../../models/user';
+import { default as Post, IPost, isValidText } from '../../models/post';
+import { default as User, IUser } from '../../models/user';
+import { default as Channel, IChannel } from '../../models/channel';
import Following from '../../models/following';
import DriveFile from '../../models/drive-file';
import Watching from '../../models/post-watching';
+import ChannelWatching from '../../models/channel-watching';
import serialize from '../../serializers/post';
import notify from '../../common/notify';
import watch from '../../common/watch-post';
-import event from '../../event';
+import { default as event, publishChannelStream } from '../../event';
import config from '../../../conf';
/**
@@ -24,7 +25,7 @@ import config from '../../../conf';
* @param {any} app
* @return {Promise<any>}
*/
-module.exports = (params, user, app) => new Promise(async (res, rej) => {
+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');
@@ -43,9 +44,7 @@ module.exports = (params, user, app) => new Promise(async (res, rej) => {
// SELECT _id
const entity = await DriveFile.findOne({
_id: mediaId,
- user_id: user._id
- }, {
- _id: true
+ 'metadata.user_id': user._id
});
if (entity === null) {
@@ -62,7 +61,8 @@ module.exports = (params, user, app) => new Promise(async (res, rej) => {
const [repostId, repostIdErr] = $(params.repost_id).optional.id().$;
if (repostIdErr) return rej('invalid repost_id');
- let repost = null;
+ let repost: IPost = null;
+ let isQuote = false;
if (repostId !== undefined) {
// Fetch repost to post
repost = await Post.findOne({
@@ -84,43 +84,86 @@ module.exports = (params, user, app) => new Promise(async (res, rej) => {
}
});
+ isQuote = text != null || files != null;
+
// 直近と同じRepost対象かつ引用じゃなかったらエラー
if (latestPost &&
latestPost.repost_id &&
latestPost.repost_id.equals(repost._id) &&
- text === undefined && files === null) {
+ !isQuote) {
return rej('cannot repost same post that already reposted in your latest post');
}
// 直近がRepost対象かつ引用じゃなかったらエラー
if (latestPost &&
latestPost._id.equals(repost._id) &&
- text === undefined && files === null) {
+ !isQuote) {
return rej('cannot repost your latest post');
}
}
- // Get 'in_reply_to_post_id' parameter
- const [inReplyToPostId, inReplyToPostIdErr] = $(params.reply_to_id).optional.id().$;
- if (inReplyToPostIdErr) return rej('invalid in_reply_to_post_id');
+ // Get 'reply_id' parameter
+ const [replyId, replyIdErr] = $(params.reply_id).optional.id().$;
+ if (replyIdErr) return rej('invalid reply_id');
- let inReplyToPost = null;
- if (inReplyToPostId !== undefined) {
+ let reply: IPost = null;
+ if (replyId !== undefined) {
// Fetch reply
- inReplyToPost = await Post.findOne({
- _id: inReplyToPostId
+ reply = await Post.findOne({
+ _id: replyId
});
- if (inReplyToPost === null) {
+ if (reply === null) {
return rej('in reply to post is not found');
}
// 返信対象が引用でないRepostだったらエラー
- if (inReplyToPost.repost_id && !inReplyToPost.text && !inReplyToPost.media_ids) {
+ if (reply.repost_id && !reply.text && !reply.media_ids) {
return rej('cannot reply to repost');
}
}
+ // Get 'channel_id' parameter
+ const [channelId, channelIdErr] = $(params.channel_id).optional.id().$;
+ if (channelIdErr) return rej('invalid channel_id');
+
+ let channel: IChannel = null;
+ if (channelId !== undefined) {
+ // Fetch channel
+ channel = await Channel.findOne({
+ _id: channelId
+ });
+
+ if (channel === null) {
+ return rej('channel not found');
+ }
+
+ // 返信対象の投稿がこのチャンネルじゃなかったらダメ
+ if (reply && !channelId.equals(reply.channel_id)) {
+ return rej('チャンネル内部からチャンネル外部の投稿に返信することはできません');
+ }
+
+ // Repost対象の投稿がこのチャンネルじゃなかったらダメ
+ if (repost && !channelId.equals(repost.channel_id)) {
+ return rej('チャンネル内部からチャンネル外部の投稿をRepostすることはできません');
+ }
+
+ // 引用ではないRepostはダメ
+ if (repost && !isQuote) {
+ return rej('チャンネル内部では引用ではないRepostをすることはできません');
+ }
+ } else {
+ // 返信対象の投稿がチャンネルへの投稿だったらダメ
+ if (reply && reply.channel_id != null) {
+ return rej('チャンネル外部からチャンネル内部の投稿に返信することはできません');
+ }
+
+ // Repost対象の投稿がチャンネルへの投稿だったらダメ
+ if (repost && repost.channel_id != null) {
+ return rej('チャンネル外部からチャンネル内部の投稿をRepostすることはできません');
+ }
+ }
+
// Get 'poll' parameter
const [poll, pollErr] = $(params.poll).optional.strict.object()
.have('choices', $().array('string')
@@ -148,15 +191,15 @@ module.exports = (params, user, app) => new Promise(async (res, rej) => {
if (user.latest_post) {
if (deepEqual({
text: user.latest_post.text,
- reply: user.latest_post.reply_to_id ? user.latest_post.reply_to_id.toString() : null,
+ reply: user.latest_post.reply_id ? user.latest_post.reply_id.toString() : null,
repost: user.latest_post.repost_id ? user.latest_post.repost_id.toString() : null,
media_ids: (user.latest_post.media_ids || []).map(id => id.toString())
}, {
- text: text,
- reply: inReplyToPost ? inReplyToPost._id.toString() : null,
- repost: repost ? repost._id.toString() : null,
- media_ids: (files || []).map(file => file._id.toString())
- })) {
+ text: text,
+ reply: reply ? reply._id.toString() : null,
+ repost: repost ? repost._id.toString() : null,
+ media_ids: (files || []).map(file => file._id.toString())
+ })) {
return rej('duplicate');
}
}
@@ -164,8 +207,10 @@ module.exports = (params, user, app) => new Promise(async (res, rej) => {
// 投稿を作成
const post = await Post.insert({
created_at: new Date(),
+ channel_id: channel ? channel._id : undefined,
+ index: channel ? channel.index + 1 : undefined,
media_ids: files ? files.map(file => file._id) : undefined,
- reply_to_id: inReplyToPost ? inReplyToPost._id : undefined,
+ reply_id: reply ? reply._id : undefined,
repost_id: repost ? repost._id : undefined,
poll: poll,
text: text,
@@ -179,8 +224,7 @@ module.exports = (params, user, app) => new Promise(async (res, rej) => {
// Reponse
res(postObj);
- // -----------------------------------------------------------
- // Post processes
+ //#region Post processes
User.update({ _id: user._id }, {
$set: {
@@ -203,23 +247,51 @@ module.exports = (params, user, app) => new Promise(async (res, rej) => {
}
}
- // Publish event to myself's stream
- event(user._id, 'post', postObj);
+ // タイムラインへの投稿
+ if (!channel) {
+ // Publish event to myself's stream
+ event(user._id, 'post', postObj);
- // Fetch all followers
- const followers = await Following
- .find({
- followee_id: user._id,
+ // 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 }
- }, {
- follower_id: true,
- _id: false
});
- // Publish event to followers stream
- followers.forEach(following =>
- event(following.follower_id, 'post', postObj));
+ // チャンネルの視聴者(のタイムライン)に配信
+ watches.forEach(w => {
+ event(w.user_id, 'post', postObj);
+ });
+ }
// Increment my posts count
User.update({ _id: user._id }, {
@@ -229,23 +301,23 @@ module.exports = (params, user, app) => new Promise(async (res, rej) => {
});
// If has in reply to post
- if (inReplyToPost) {
+ if (reply) {
// Increment replies count
- Post.update({ _id: inReplyToPost._id }, {
+ Post.update({ _id: reply._id }, {
$inc: {
replies_count: 1
}
});
// 自分自身へのリプライでない限りは通知を作成
- notify(inReplyToPost.user_id, user._id, 'reply', {
+ notify(reply.user_id, user._id, 'reply', {
post_id: post._id
});
// Fetch watchers
Watching
.find({
- post_id: inReplyToPost._id,
+ post_id: reply._id,
user_id: { $ne: user._id },
// 削除されたドキュメントは除く
deleted_at: { $exists: false }
@@ -265,10 +337,10 @@ module.exports = (params, user, app) => new Promise(async (res, rej) => {
// この投稿をWatchする
// TODO: ユーザーが「返信したときに自動でWatchする」設定を
// オフにしていた場合はしない
- watch(user._id, inReplyToPost);
+ watch(user._id, reply);
// Add mention
- addMention(inReplyToPost.user_id, 'reply');
+ addMention(reply.user_id, 'reply');
}
// If it is repost
@@ -369,7 +441,7 @@ module.exports = (params, user, app) => new Promise(async (res, rej) => {
if (mentionee == null) return;
// 既に言及されたユーザーに対する返信や引用repostの場合も無視
- if (inReplyToPost && inReplyToPost.user_id.equals(mentionee._id)) return;
+ if (reply && reply.user_id.equals(mentionee._id)) return;
if (repost && repost.user_id.equals(mentionee._id)) return;
// Add mention
@@ -406,4 +478,6 @@ module.exports = (params, user, app) => new Promise(async (res, rej) => {
}
});
}
+
+ //#endregion
});
diff --git a/src/api/endpoints/posts/replies.ts b/src/api/endpoints/posts/replies.ts
index 89f4d99841..3fd6a46769 100644
--- a/src/api/endpoints/posts/replies.ts
+++ b/src/api/endpoints/posts/replies.ts
@@ -40,7 +40,7 @@ module.exports = (params, user) => new Promise(async (res, rej) => {
// Issue query
const replies = await Post
- .find({ reply_to_id: post._id }, {
+ .find({ reply_id: post._id }, {
limit: limit,
skip: offset,
sort: {
diff --git a/src/api/endpoints/posts/timeline.ts b/src/api/endpoints/posts/timeline.ts
index 314e992344..203413e23a 100644
--- a/src/api/endpoints/posts/timeline.ts
+++ b/src/api/endpoints/posts/timeline.ts
@@ -2,7 +2,9 @@
* Module dependencies
*/
import $ from 'cafy';
+import rap from '@prezzemolo/rap';
import Post from '../../models/post';
+import ChannelWatching from '../../models/channel-watching';
import getFriends from '../../common/get-friends';
import serialize from '../../serializers/post';
@@ -14,36 +16,62 @@ import serialize from '../../serializers/post';
* @param {any} app
* @return {Promise<any>}
*/
-module.exports = (params, user, app) => new Promise(async (res, rej) => {
+module.exports = async (params, user, app) => {
// Get 'limit' parameter
const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$;
- if (limitErr) return rej('invalid limit param');
+ if (limitErr) throw 'invalid limit param';
// Get 'since_id' parameter
const [sinceId, sinceIdErr] = $(params.since_id).optional.id().$;
- if (sinceIdErr) return rej('invalid since_id param');
+ if (sinceIdErr) throw 'invalid since_id param';
// Get 'max_id' parameter
const [maxId, maxIdErr] = $(params.max_id).optional.id().$;
- if (maxIdErr) return rej('invalid max_id param');
+ if (maxIdErr) throw 'invalid max_id param';
// Check if both of since_id and max_id is specified
if (sinceId && maxId) {
- return rej('cannot set since_id and max_id');
+ throw 'cannot set since_id and max_id';
}
- // ID list of the user $self and other users who the user follows
- const followingIds = await getFriends(user._id);
+ const { followingIds, watchChannelIds } = await rap({
+ // ID list of the user itself and other users who the user follows
+ followingIds: getFriends(user._id),
+ // Watchしているチャンネルを取得
+ watchChannelIds: ChannelWatching.find({
+ user_id: user._id,
+ // 削除されたドキュメントは除く
+ deleted_at: { $exists: false }
+ }).then(watches => watches.map(w => w.channel_id))
+ });
- // Construct query
+ //#region Construct query
const sort = {
_id: -1
};
+
const query = {
- user_id: {
- $in: followingIds
- }
+ $or: [{
+ // フォローしている人のタイムラインへの投稿
+ user_id: {
+ $in: followingIds
+ },
+ // 「タイムラインへの」投稿に限定するためにチャンネルが指定されていないもののみに限る
+ $or: [{
+ channel_id: {
+ $exists: false
+ }
+ }, {
+ channel_id: null
+ }]
+ }, {
+ // Watchしているチャンネルへの投稿
+ channel_id: {
+ $in: watchChannelIds
+ }
+ }]
} as any;
+
if (sinceId) {
sort._id = 1;
query._id = {
@@ -54,6 +82,7 @@ module.exports = (params, user, app) => new Promise(async (res, rej) => {
$lt: maxId
};
}
+ //#endregion
// Issue query
const timeline = await Post
@@ -63,7 +92,6 @@ module.exports = (params, user, app) => new Promise(async (res, rej) => {
});
// Serialize
- res(await Promise.all(timeline.map(async post =>
- await serialize(post, user)
- )));
-});
+ const _timeline = await Promise.all(timeline.map(post => serialize(post, user)));
+ return _timeline;
+};
diff --git a/src/api/endpoints/posts/trend.ts b/src/api/endpoints/posts/trend.ts
index 3277206d26..64a195dff1 100644
--- a/src/api/endpoints/posts/trend.ts
+++ b/src/api/endpoints/posts/trend.ts
@@ -48,7 +48,7 @@ module.exports = (params, user) => new Promise(async (res, rej) => {
} as any;
if (reply != undefined) {
- query.reply_to_id = reply ? { $exists: true, $ne: null } : null;
+ query.reply_id = reply ? { $exists: true, $ne: null } : null;
}
if (repost != undefined) {
diff --git a/src/api/endpoints/users/get_frequently_replied_users.ts b/src/api/endpoints/users/get_frequently_replied_users.ts
new file mode 100644
index 0000000000..bb0f3b4cea
--- /dev/null
+++ b/src/api/endpoints/users/get_frequently_replied_users.ts
@@ -0,0 +1,96 @@
+/**
+ * Module dependencies
+ */
+import $ from 'cafy';
+import Post from '../../models/post';
+import User from '../../models/user';
+import serialize from '../../serializers/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');
+
+ // 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]);
+
+ // Lookup top 10 replies
+ const topRepliedUsers = repliedUsersSorted.slice(0, 10);
+
+ // Make replies object (includes weights)
+ const repliesObj = await Promise.all(topRepliedUsers.map(async (user) => ({
+ user: await serialize(user, me, { detail: true }),
+ weight: repliedUsers[user] / peak
+ })));
+
+ // Response
+ res(repliesObj);
+});
diff --git a/src/api/endpoints/users/posts.ts b/src/api/endpoints/users/posts.ts
index e37b660773..d8204b8b80 100644
--- a/src/api/endpoints/users/posts.ts
+++ b/src/api/endpoints/users/posts.ts
@@ -85,7 +85,7 @@ module.exports = (params, me) => new Promise(async (res, rej) => {
}
if (!includeReplies) {
- query.reply_to_id = null;
+ query.reply_id = null;
}
if (withMedia) {
diff --git a/src/api/event.ts b/src/api/event.ts
index 9613a9f7cc..909b0d2556 100644
--- a/src/api/event.ts
+++ b/src/api/event.ts
@@ -25,6 +25,10 @@ class MisskeyEvent {
this.publish(`messaging-stream:${userId}-${otherpartyId}`, type, typeof value === 'undefined' ? null : value);
}
+ public publishChannelStream(channelId: ID, type: string, value?: any): void {
+ this.publish(`channel-stream:${channelId}`, type, typeof value === 'undefined' ? null : value);
+ }
+
private publish(channel: string, type: string, value?: any): void {
const message = value == null ?
{ type: type } :
@@ -41,3 +45,5 @@ export default ev.publishUserStream.bind(ev);
export const publishPostStream = ev.publishPostStream.bind(ev);
export const publishMessagingStream = ev.publishMessagingStream.bind(ev);
+
+export const publishChannelStream = ev.publishChannelStream.bind(ev);
diff --git a/src/api/models/access-token.ts b/src/api/models/access-token.ts
index 2a8a512ddc..9985be5013 100644
--- a/src/api/models/access-token.ts
+++ b/src/api/models/access-token.ts
@@ -2,7 +2,7 @@ import db from '../../db/mongodb';
const collection = db.get('access_tokens');
-(collection as any).index('token'); // fuck type definition
-(collection as any).index('hash'); // fuck type definition
+(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/api/models/app.ts b/src/api/models/app.ts
index bf5dc80c2c..68f2f448b0 100644
--- a/src/api/models/app.ts
+++ b/src/api/models/app.ts
@@ -2,9 +2,9 @@ import db from '../../db/mongodb';
const collection = db.get('apps');
-(collection as any).index('name_id'); // fuck type definition
-(collection as any).index('name_id_lower'); // fuck type definition
-(collection as any).index('secret'); // fuck type definition
+(collection as any).createIndex('name_id'); // fuck type definition
+(collection as any).createIndex('name_id_lower'); // fuck type definition
+(collection as any).createIndex('secret'); // fuck type definition
export default collection as any; // fuck type definition
diff --git a/src/api/models/channel-watching.ts b/src/api/models/channel-watching.ts
new file mode 100644
index 0000000000..6184ae408d
--- /dev/null
+++ b/src/api/models/channel-watching.ts
@@ -0,0 +1,3 @@
+import db from '../../db/mongodb';
+
+export default db.get('channel_watching') as any; // fuck type definition
diff --git a/src/api/models/channel.ts b/src/api/models/channel.ts
new file mode 100644
index 0000000000..c80e84dbc8
--- /dev/null
+++ b/src/api/models/channel.ts
@@ -0,0 +1,14 @@
+import * as mongo from 'mongodb';
+import db from '../../db/mongodb';
+
+const collection = db.get('channels');
+
+export default collection as any; // fuck type definition
+
+export type IChannel = {
+ _id: mongo.ObjectID;
+ created_at: Date;
+ title: string;
+ user_id: mongo.ObjectID;
+ index: number;
+};
diff --git a/src/api/models/drive-file.ts b/src/api/models/drive-file.ts
index 4c7204b1f4..8968d065cd 100644
--- a/src/api/models/drive-file.ts
+++ b/src/api/models/drive-file.ts
@@ -1,11 +1,22 @@
-import db from '../../db/mongodb';
+import * as mongodb from 'mongodb';
+import monkDb, { nativeDbConn } from '../../db/mongodb';
-const collection = db.get('drive_files');
+const collection = monkDb.get('drive_files.files');
-(collection as any).index('hash'); // fuck type definition
+(collection as any).createIndex('hash'); // fuck type definition
export default collection as any; // fuck type definition
+const getGridFSBucket = async (): Promise<mongodb.GridFSBucket> => {
+ const db = await nativeDbConn();
+ const bucket = new mongodb.GridFSBucket(db, {
+ bucketName: 'drive_files'
+ });
+ return bucket;
+};
+
+export { getGridFSBucket };
+
export function validateFileName(name: string): boolean {
return (
(name.trim().length > 0) &&
diff --git a/src/api/models/notification.ts b/src/api/models/notification.ts
index 1c1f429a0d..1065e8baaa 100644
--- a/src/api/models/notification.ts
+++ b/src/api/models/notification.ts
@@ -1,3 +1,8 @@
+import * as mongo from 'mongodb';
import db from '../../db/mongodb';
export default db.get('notifications') as any; // fuck type definition
+
+export interface INotification {
+ _id: mongo.ObjectID;
+}
diff --git a/src/api/models/post.ts b/src/api/models/post.ts
index baab63f991..7584ce182d 100644
--- a/src/api/models/post.ts
+++ b/src/api/models/post.ts
@@ -1,3 +1,5 @@
+import * as mongo from 'mongodb';
+
import db from '../../db/mongodb';
export default db.get('posts') as any; // fuck type definition
@@ -5,3 +7,16 @@ export default db.get('posts') as any; // fuck type definition
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: {}; // todo
+ text: string;
+ user_id: mongo.ObjectID;
+ app_id: mongo.ObjectID;
+};
diff --git a/src/api/models/user.ts b/src/api/models/user.ts
index cd16459891..b2f3af09fa 100644
--- a/src/api/models/user.ts
+++ b/src/api/models/user.ts
@@ -1,9 +1,12 @@
+import * as mongo from 'mongodb';
+
import db from '../../db/mongodb';
+import { IPost } from './post';
const collection = db.get('users');
-(collection as any).index('username'); // fuck type definition
-(collection as any).index('token'); // fuck type definition
+(collection as any).createIndex('username'); // fuck type definition
+(collection as any).createIndex('token'); // fuck type definition
export default collection as any; // fuck type definition
@@ -31,6 +34,50 @@ export function isValidBirthday(birthday: string): boolean {
return typeof birthday == 'string' && /^([0-9]{4})\-([0-9]{2})-([0-9]{2})$/.test(birthday);
}
-export interface IUser {
+export type IUser = {
+ _id: mongo.ObjectID;
+ created_at: Date;
+ email: string;
+ followers_count: number;
+ following_count: number;
+ links: string[];
name: string;
+ password: string;
+ posts_count: number;
+ drive_capacity: number;
+ username: string;
+ username_lower: string;
+ token: string;
+ avatar_id: mongo.ObjectID;
+ banner_id: mongo.ObjectID;
+ data: any;
+ twitter: {
+ access_token: string;
+ access_token_secret: string;
+ user_id: string;
+ screen_name: string;
+ };
+ line: {
+ user_id: string;
+ };
+ description: string;
+ profile: {
+ location: string;
+ birthday: string; // 'YYYY-MM-DD'
+ tags: string[];
+ };
+ last_used_at: Date;
+ latest_post: IPost;
+ pinned_post_id: mongo.ObjectID;
+ is_pro: boolean;
+ is_suspended: boolean;
+ keywords: string[];
+};
+
+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;
}
diff --git a/src/api/private/signin.ts b/src/api/private/signin.ts
index afa83e50c3..c7dc243980 100644
--- a/src/api/private/signin.ts
+++ b/src/api/private/signin.ts
@@ -1,6 +1,6 @@
import * as express from 'express';
import * as bcrypt from 'bcryptjs';
-import User from '../models/user';
+import { default as User, IUser } from '../models/user';
import Signin from '../models/signin';
import serialize from '../serializers/signin';
import event from '../event';
@@ -23,7 +23,7 @@ export default async (req: express.Request, res: express.Response) => {
}
// Fetch user
- const user = await User.findOne({
+ const user: IUser = await User.findOne({
username_lower: username.toLowerCase()
}, {
fields: {
diff --git a/src/api/private/signup.ts b/src/api/private/signup.ts
index 2375c22845..bcc17a876d 100644
--- a/src/api/private/signup.ts
+++ b/src/api/private/signup.ts
@@ -1,10 +1,10 @@
import * as express from 'express';
import * as bcrypt from 'bcryptjs';
-import rndstr from 'rndstr';
import recaptcha = require('recaptcha-promise');
-import User from '../models/user';
+import { default as User, IUser } from '../models/user';
import { validateUsername, validatePassword } from '../models/user';
import serialize from '../serializers/user';
+import generateUserToken from '../common/generate-native-user-token';
import config from '../../conf';
recaptcha.init({
@@ -58,10 +58,10 @@ export default async (req: express.Request, res: express.Response) => {
const hash = bcrypt.hashSync(password, salt);
// Generate secret
- const secret = `!${rndstr('a-zA-Z0-9', 32)}`;
+ const secret = generateUserToken();
// Create account
- const account = await User.insert({
+ const account: IUser = await User.insert({
token: secret,
avatar_id: null,
banner_id: null,
diff --git a/src/api/serializers/channel.ts b/src/api/serializers/channel.ts
new file mode 100644
index 0000000000..3cba39aa16
--- /dev/null
+++ b/src/api/serializers/channel.ts
@@ -0,0 +1,66 @@
+/**
+ * Module dependencies
+ */
+import * as mongo from 'mongodb';
+import deepcopy = require('deepcopy');
+import { IUser } from '../models/user';
+import { default as Channel, IChannel } from '../models/channel';
+import Watching from '../models/channel-watching';
+
+/**
+ * Serialize a channel
+ *
+ * @param channel target
+ * @param me? serializee
+ * @return response
+ */
+export default (
+ channel: string | mongo.ObjectID | IChannel,
+ me?: string | mongo.ObjectID | IUser
+) => new Promise<any>(async (resolve, reject) => {
+
+ let _channel: any;
+
+ // Populate the channel if 'channel' is ID
+ if (mongo.ObjectID.prototype.isPrototypeOf(channel)) {
+ _channel = await Channel.findOne({
+ _id: channel
+ });
+ } else if (typeof channel === 'string') {
+ _channel = await Channel.findOne({
+ _id: new mongo.ObjectID(channel)
+ });
+ } else {
+ _channel = deepcopy(channel);
+ }
+
+ // Rename _id to id
+ _channel.id = _channel._id;
+ delete _channel._id;
+
+ // Remove needless properties
+ delete _channel.user_id;
+
+ // Me
+ const meId: mongo.ObjectID = me
+ ? mongo.ObjectID.prototype.isPrototypeOf(me)
+ ? me as mongo.ObjectID
+ : typeof me === 'string'
+ ? new mongo.ObjectID(me)
+ : (me as IUser)._id
+ : null;
+
+ if (me) {
+ //#region Watchしているかどうか
+ const watch = await Watching.findOne({
+ user_id: meId,
+ channel_id: _channel.id,
+ deleted_at: { $exists: false }
+ });
+
+ _channel.is_watching = watch !== null;
+ //#endregion
+ }
+
+ resolve(_channel);
+});
diff --git a/src/api/serializers/drive-file.ts b/src/api/serializers/drive-file.ts
index b4e2ab064a..2af7db5726 100644
--- a/src/api/serializers/drive-file.ts
+++ b/src/api/serializers/drive-file.ts
@@ -31,44 +31,40 @@ export default (
if (mongo.ObjectID.prototype.isPrototypeOf(file)) {
_file = await DriveFile.findOne({
_id: file
- }, {
- fields: {
- data: false
- }
- });
+ });
} else if (typeof file === 'string') {
_file = await DriveFile.findOne({
_id: new mongo.ObjectID(file)
- }, {
- fields: {
- data: false
- }
- });
+ });
} else {
_file = deepcopy(file);
}
- // Rename _id to id
- _file.id = _file._id;
- delete _file._id;
+ if (!_file) return reject('invalid file arg.');
+
+ // rendered target
+ let _target: any = {};
+
+ _target.id = _file._id;
+ _target.created_at = _file.uploadDate;
- delete _file.data;
+ _target = Object.assign(_target, _file.metadata);
- _file.url = `${config.drive_url}/${_file.id}/${encodeURIComponent(_file.name)}`;
+ _target.url = `${config.drive_url}/${_target.id}/${encodeURIComponent(_target.name)}`;
- if (opts.detail && _file.folder_id) {
+ if (opts.detail && _target.folder_id) {
// Populate folder
- _file.folder = await serializeDriveFolder(_file.folder_id, {
+ _target.folder = await serializeDriveFolder(_target.folder_id, {
detail: true
});
}
- if (opts.detail && _file.tags) {
+ if (opts.detail && _target.tags) {
// Populate tags
- _file.tags = await _file.tags.map(async (tag: any) =>
+ _target.tags = await _target.tags.map(async (tag: any) =>
await serializeDriveTag(tag)
);
}
- resolve(_file);
+ resolve(_target);
});
diff --git a/src/api/serializers/drive-folder.ts b/src/api/serializers/drive-folder.ts
index a428464108..6ebf454a28 100644
--- a/src/api/serializers/drive-folder.ts
+++ b/src/api/serializers/drive-folder.ts
@@ -44,7 +44,7 @@ const self = (
});
const childFilesCount = await DriveFile.count({
- folder_id: _folder.id
+ 'metadata.folder_id': _folder.id
});
_folder.folders_count = childFoldersCount;
diff --git a/src/api/serializers/post.ts b/src/api/serializers/post.ts
index 3c96884dd1..03fd120772 100644
--- a/src/api/serializers/post.ts
+++ b/src/api/serializers/post.ts
@@ -3,33 +3,45 @@
*/
import * as mongo from 'mongodb';
import deepcopy = require('deepcopy');
-import Post from '../models/post';
+import { default as Post, IPost } from '../models/post';
import Reaction from '../models/post-reaction';
+import { IUser } from '../models/user';
import Vote from '../models/poll-vote';
import serializeApp from './app';
+import serializeChannel from './channel';
import serializeUser from './user';
import serializeDriveFile from './drive-file';
import parse from '../common/text';
+import rap from '@prezzemolo/rap';
/**
* Serialize a post
*
- * @param {any} post
- * @param {any} me?
- * @param {any} options?
- * @return {Promise<any>}
+ * @param post target
+ * @param me? serializee
+ * @param options? serialize options
+ * @return response
*/
-const self = (
- post: any,
- me?: any,
+const self = async (
+ post: string | mongo.ObjectID | IPost,
+ me?: string | mongo.ObjectID | IUser,
options?: {
detail: boolean
}
-) => new Promise<any>(async (resolve, reject) => {
+) => {
const opts = options || {
detail: true,
};
+ // 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
@@ -45,6 +57,8 @@ const self = (
_post = deepcopy(post);
}
+ if (!_post) throw 'invalid post arg.';
+
const id = _post._id;
// Rename _id to id
@@ -59,62 +73,120 @@ const self = (
}
// Populate user
- _post.user = await serializeUser(_post.user_id, me);
+ _post.user = serializeUser(_post.user_id, meId);
// Populate app
if (_post.app_id) {
- _post.app = await serializeApp(_post.app_id);
+ _post.app = serializeApp(_post.app_id);
}
- if (_post.media_ids) {
- // Populate media
- _post.media = await Promise.all(_post.media_ids.map(async fileId =>
- await serializeDriveFile(fileId)
- ));
+ // Populate channel
+ if (_post.channel_id) {
+ _post.channel = serializeChannel(_post.channel_id);
}
- if (_post.reply_to_id && opts.detail) {
- // Populate reply to post
- _post.reply_to = await self(_post.reply_to_id, me, {
- detail: false
- });
+ // Populate media
+ if (_post.media_ids) {
+ _post.media = Promise.all(_post.media_ids.map(fileId =>
+ serializeDriveFile(fileId)
+ ));
}
- if (_post.repost_id && opts.detail) {
- // Populate repost
- _post.repost = await self(_post.repost_id, me, {
- detail: _post.text == null
- });
- }
+ // 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;
+ })();
- // Poll
- if (me && _post.poll && opts.detail) {
- const vote = await Vote
- .findOne({
- user_id: me._id,
- post_id: id
+ // 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 (vote != null) {
- _post.poll.choices.filter(c => c.id == vote.choice)[0].is_voted = true;
+ if (_post.reply_id) {
+ // Populate reply to post
+ _post.reply = self(_post.reply_id, meId, {
+ detail: false
+ });
}
- }
- // Fetch my reaction
- if (me && opts.detail) {
- const reaction = await Reaction
- .findOne({
- user_id: me._id,
- post_id: id,
- deleted_at: { $exists: false }
+ if (_post.repost_id) {
+ // Populate repost
+ _post.repost = self(_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;
+ }
- if (reaction) {
- _post.my_reaction = reaction.reaction;
+ 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(_post);
-});
+ // resolve promises in _post object
+ _post = await rap(_post);
+
+ return _post;
+};
export default self;
diff --git a/src/api/serializers/user.ts b/src/api/serializers/user.ts
index bdbc749589..0d24d6cc04 100644
--- a/src/api/serializers/user.ts
+++ b/src/api/serializers/user.ts
@@ -3,22 +3,24 @@
*/
import * as mongo from 'mongodb';
import deepcopy = require('deepcopy');
-import User from '../models/user';
+import { default as User, IUser } from '../models/user';
+import serializePost from './post';
import Following from '../models/following';
import getFriends from '../common/get-friends';
import config from '../../conf';
+import rap from '@prezzemolo/rap';
/**
* Serialize a user
*
- * @param {any} user
- * @param {any} me?
- * @param {any} options?
- * @return {Promise<any>}
+ * @param user target
+ * @param me? serializee
+ * @param options? serialize options
+ * @return response
*/
export default (
- user: any,
- me?: any,
+ user: string | mongo.ObjectID | IUser,
+ me?: string | mongo.ObjectID | IUser,
options?: {
detail?: boolean,
includeSecrets?: boolean
@@ -36,7 +38,9 @@ export default (
data: false
} : {
data: false,
- profile: false
+ profile: false,
+ keywords: false,
+ domains: false
};
// Populate the user if 'user' is ID
@@ -52,14 +56,16 @@ export default (
_user = deepcopy(user);
}
+ if (!_user) return reject('invalid user arg.');
+
// Me
- if (me && !mongo.ObjectID.prototype.isPrototypeOf(me)) {
- if (typeof me === 'string') {
- me = new mongo.ObjectID(me);
- } else {
- me = me._id;
- }
- }
+ 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;
@@ -76,6 +82,7 @@ export default (
delete _user.twitter.access_token;
delete _user.twitter.access_token_secret;
}
+ delete _user.line;
// Visible via only the official client
if (!opts.includeSecrets) {
@@ -91,51 +98,65 @@ export default (
? `${config.drive_url}/${_user.banner_id}`
: null;
- if (!me || !me.equals(_user.id) || !opts.detail) {
+ if (!meId || !meId.equals(_user.id) || !opts.detail) {
delete _user.avatar_id;
delete _user.banner_id;
delete _user.drive_capacity;
}
- if (me && !me.equals(_user.id)) {
+ if (meId && !meId.equals(_user.id)) {
// If the user is following
- const follow = await Following.findOne({
- follower_id: me,
- followee_id: _user.id,
- deleted_at: { $exists: false }
- });
- _user.is_following = follow !== null;
+ _user.is_following = (async () => {
+ const follow = await Following.findOne({
+ follower_id: meId,
+ followee_id: _user.id,
+ deleted_at: { $exists: false }
+ });
+ return follow !== null;
+ })();
// If the user is followed
- const follow2 = await Following.findOne({
- follower_id: _user.id,
- followee_id: me,
- deleted_at: { $exists: false }
- });
- _user.is_followed = follow2 !== null;
+ _user.is_followed = (async () => {
+ const follow2 = await Following.findOne({
+ follower_id: _user.id,
+ followee_id: meId,
+ deleted_at: { $exists: false }
+ });
+ return follow2 !== null;
+ })();
}
- if (me && !me.equals(_user.id) && opts.detail) {
- const myFollowingIds = await getFriends(me);
+ if (opts.detail) {
+ if (_user.pinned_post_id) {
+ // Populate pinned post
+ _user.pinned_post = serializePost(_user.pinned_post_id, meId, {
+ detail: true
+ });
+ }
+
+ if (meId && !meId.equals(_user.id)) {
+ const myFollowingIds = await getFriends(meId);
- // Get following you know count
- const followingYouKnowCount = await Following.count({
- followee_id: { $in: myFollowingIds },
- follower_id: _user.id,
- deleted_at: { $exists: false }
- });
- _user.following_you_know_count = followingYouKnowCount;
+ // 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
- const followersYouKnowCount = await Following.count({
- followee_id: _user.id,
- follower_id: { $in: myFollowingIds },
- deleted_at: { $exists: false }
- });
- _user.followers_you_know_count = followersYouKnowCount;
+ // 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);
});
/*
diff --git a/src/api/server.ts b/src/api/server.ts
index c98167eb3e..3de32d9eab 100644
--- a/src/api/server.ts
+++ b/src/api/server.ts
@@ -19,7 +19,12 @@ app.disable('x-powered-by');
app.set('etag', false);
app.use(bodyParser.urlencoded({ extended: true }));
app.use(bodyParser.json({
- type: ['application/json', 'text/plain']
+ 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({
origin: true
@@ -54,4 +59,6 @@ app.use((req, res, next) => {
require('./service/github')(app);
require('./service/twitter')(app);
+require('./bot/interfaces/line')(app);
+
module.exports = app;
diff --git a/src/api/service/github.ts b/src/api/service/github.ts
index a631808ba5..1c78267c0f 100644
--- a/src/api/service/github.ts
+++ b/src/api/service/github.ts
@@ -111,12 +111,12 @@ module.exports = async (app: express.Application) => {
handler.on('watch', event => {
const sender = event.sender;
- post(`Starred by **${sender.login}**`);
+ post(`⭐️ Starred by **${sender.login}** ⭐️`);
});
handler.on('fork', event => {
const repo = event.forkee;
- post(`Forked:\n${repo.html_url}`);
+ post(`🍴 Forked:\n${repo.html_url} 🍴`);
});
handler.on('pull_request', event => {
diff --git a/src/api/stream/channel.ts b/src/api/stream/channel.ts
new file mode 100644
index 0000000000..d67d77cbf4
--- /dev/null
+++ b/src/api/stream/channel.ts
@@ -0,0 +1,12 @@
+import * as websocket from 'websocket';
+import * as redis from 'redis';
+
+export default function(request: websocket.request, connection: websocket.connection, subscriber: redis.RedisClient): void {
+ const channel = request.resourceURL.query.channel;
+
+ // Subscribe channel stream
+ subscriber.subscribe(`misskey:channel-stream:${channel}`);
+ subscriber.on('message', (_, data) => {
+ connection.send(data);
+ });
+}
diff --git a/src/api/stream/home.ts b/src/api/stream/home.ts
index 2ab8d3025b..7c8f3bfec8 100644
--- a/src/api/stream/home.ts
+++ b/src/api/stream/home.ts
@@ -2,7 +2,9 @@ import * as websocket from 'websocket';
import * as redis from 'redis';
import * as debug from 'debug';
+import User from '../models/user';
import serializePost from '../serializers/post';
+import readNotification from '../common/read-notification';
const log = debug('misskey');
@@ -35,6 +37,20 @@ export default function homeStream(request: websocket.request, connection: webso
const msg = JSON.parse(data.utf8Data);
switch (msg.type) {
+ case 'alive':
+ // Update lastUsedAt
+ User.update({ _id: user._id }, {
+ $set: {
+ 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;
diff --git a/src/api/stream/server.ts b/src/api/stream/server.ts
index 6de5337499..0db6643d40 100644
--- a/src/api/stream/server.ts
+++ b/src/api/stream/server.ts
@@ -14,7 +14,6 @@ export default function homeStream(request: websocket.request, connection: webso
ev.addListener('stats', onStats);
connection.on('close', () => {
- console.log('yooo');
ev.removeListener('stats', onStats);
});
}
diff --git a/src/api/streaming.ts b/src/api/streaming.ts
index c71132100c..0e512fb210 100644
--- a/src/api/streaming.ts
+++ b/src/api/streaming.ts
@@ -2,13 +2,14 @@ import * as http from 'http';
import * as websocket from 'websocket';
import * as redis from 'redis';
import config from '../conf';
-import User from './models/user';
+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 messagingStream from './stream/messaging';
import serverStream from './stream/server';
+import channelStream from './stream/channel';
module.exports = (server: http.Server) => {
/**
@@ -26,14 +27,6 @@ module.exports = (server: http.Server) => {
return;
}
- const user = await authenticate(connection, request.resourceURL.query.i);
-
- if (user == null) {
- connection.send('authentication-failed');
- connection.close();
- return;
- }
-
// Connect to Redis
const subscriber = redis.createClient(
config.redis.port, config.redis.host);
@@ -43,6 +36,19 @@ module.exports = (server: http.Server) => {
subscriber.quit();
});
+ if (request.resourceURL.pathname === '/channel') {
+ channelStream(request, connection, subscriber);
+ return;
+ }
+
+ const user = await authenticate(request.resourceURL.query.i);
+
+ if (user == null) {
+ connection.send('authentication-failed');
+ connection.close();
+ return;
+ }
+
const channel =
request.resourceURL.pathname === '/' ? homeStream :
request.resourceURL.pathname === '/messaging' ? messagingStream :
@@ -56,7 +62,11 @@ module.exports = (server: http.Server) => {
});
};
-function authenticate(connection: websocket.connection, token: string): Promise<any> {
+/**
+ * 接続してきたユーザーを取得します
+ * @param token 送信されてきたトークン
+ */
+function authenticate(token: string): Promise<IUser> {
if (token == null) {
return Promise.resolve(null);
}
@@ -64,8 +74,7 @@ function authenticate(connection: websocket.connection, token: string): Promise<
return new Promise(async (resolve, reject) => {
if (isNativeToken(token)) {
// Fetch user
- // SELECT _id
- const user = await User
+ const user: IUser = await User
.findOne({
token: token
});
@@ -81,13 +90,8 @@ function authenticate(connection: websocket.connection, token: string): Promise<
}
// Fetch user
- // SELECT _id
- const user = await User
- .findOne({ _id: accessToken.user_id }, {
- fields: {
- _id: true
- }
- });
+ const user: IUser = await User
+ .findOne({ _id: accessToken.user_id });
resolve(user);
}
diff --git a/src/web/app/common/scripts/get-post-summary.js b/src/common/get-post-summary.ts
index 83eda8f6b4..6e8f65708e 100644
--- a/src/web/app/common/scripts/get-post-summary.js
+++ b/src/common/get-post-summary.ts
@@ -1,5 +1,15 @@
-const summarize = post => {
- let summary = post.text ? post.text : '';
+/**
+ * 投稿を表す文字列を取得します。
+ * @param {*} post 投稿
+ */
+const summarize = (post: any): string => {
+ let summary = '';
+
+ // チャンネル
+ summary += post.channel ? `${post.channel.title}:` : '';
+
+ // 本文
+ summary += post.text ? post.text : '';
// メディアが添付されているとき
if (post.media) {
@@ -12,9 +22,9 @@ const summarize = post => {
}
// 返信のとき
- if (post.reply_to_id) {
- if (post.reply_to) {
- summary += ` RE: ${summarize(post.reply_to)}`;
+ if (post.reply_id) {
+ if (post.reply) {
+ summary += ` RE: ${summarize(post.reply)}`;
} else {
summary += ' RE: ...';
}
diff --git a/src/common/get-user-summary.ts b/src/common/get-user-summary.ts
new file mode 100644
index 0000000000..1bec2f9a26
--- /dev/null
+++ b/src/common/get-user-summary.ts
@@ -0,0 +1,12 @@
+import { IUser } from '../api/models/user';
+
+/**
+ * ユーザーを表す文字列を取得します。
+ * @param user ユーザー
+ */
+export default function(user: IUser): string {
+ return `${user.name} (@${user.username})\n` +
+ `${user.posts_count}投稿、${user.following_count}フォロー、${user.followers_count}フォロワー\n` +
+ `場所: ${user.profile.location}、誕生日: ${user.profile.birthday}\n` +
+ `「${user.description}」`;
+}
diff --git a/src/common/othello.ts b/src/common/othello.ts
new file mode 100644
index 0000000000..858fc33158
--- /dev/null
+++ b/src/common/othello.ts
@@ -0,0 +1,268 @@
+const BOARD_SIZE = 8;
+
+export default class Othello {
+ public board: Array<Array<'black' | 'white'>>;
+
+ /**
+ * ゲームを初期化します
+ */
+ constructor() {
+ this.board = [
+ [null, null, null, null, null, null, null, null],
+ [null, null, null, null, null, null, null, null],
+ [null, null, null, null, null, null, null, null],
+ [null, null, null, 'black', 'white', null, null, null],
+ [null, null, null, 'white', 'black', null, null, null],
+ [null, null, null, null, null, null, null, null],
+ [null, null, null, null, null, null, null, null],
+ [null, null, null, null, null, null, null, null]
+ ];
+ }
+
+ public setByNumber(color, n) {
+ const ps = this.getPattern(color);
+ this.set(color, ps[n][0], ps[n][1]);
+ }
+
+ private write(color, x, y) {
+ this.board[y][x] = color;
+ }
+
+ /**
+ * 石を配置します
+ */
+ public set(color, x, y) {
+ this.write(color, x, y);
+
+ const reverses = this.getReverse(color, x, y);
+
+ reverses.forEach(r => {
+ switch (r[0]) {
+ case 0: // 上
+ for (let c = 0, _y = y - 1; c < r[1]; c++, _y--) {
+ this.write(color, x, _y);
+ }
+ break;
+
+ case 1: // 右上
+ for (let c = 0, i = 1; c < r[1]; c++, i++) {
+ this.write(color, x + i, y - i);
+ }
+ break;
+
+ case 2: // 右
+ for (let c = 0, _x = x + 1; c < r[1]; c++, _x++) {
+ this.write(color, _x, y);
+ }
+ break;
+
+ case 3: // 右下
+ for (let c = 0, i = 1; c < r[1]; c++, i++) {
+ this.write(color, x + i, y + i);
+ }
+ break;
+
+ case 4: // 下
+ for (let c = 0, _y = y + 1; c < r[1]; c++, _y++) {
+ this.write(color, x, _y);
+ }
+ break;
+
+ case 5: // 左下
+ for (let c = 0, i = 1; c < r[1]; c++, i++) {
+ this.write(color, x - i, y + i);
+ }
+ break;
+
+ case 6: // 左
+ for (let c = 0, _x = x - 1; c < r[1]; c++, _x--) {
+ this.write(color, _x, y);
+ }
+ break;
+
+ case 7: // 左上
+ for (let c = 0, i = 1; c < r[1]; c++, i++) {
+ this.write(color, x - i, y - i);
+ }
+ break;
+ }
+ });
+ }
+
+ /**
+ * 打つことができる場所を取得します
+ */
+ public getPattern(myColor): number[][] {
+ const result = [];
+ this.board.forEach((stones, y) => stones.forEach((stone, x) => {
+ if (stone != null) return;
+ if (this.canReverse(myColor, x, y)) result.push([x, y]);
+ }));
+ return result;
+ }
+
+ /**
+ * 指定の位置に石を打つことができるかどうか(相手の石を1つでも反転させられるか)を取得します
+ */
+ public canReverse(myColor, targetx, targety): boolean {
+ return this.getReverse(myColor, targetx, targety) !== null;
+ }
+
+ private getReverse(myColor, targetx, targety): number[] {
+ const opponentColor = myColor == 'black' ? 'white' : 'black';
+
+ const createIterater = () => {
+ let opponentStoneFound = false;
+ let breaked = false;
+ return (x, y): any => {
+ if (breaked) {
+ return;
+ } else if (this.board[y][x] == myColor && opponentStoneFound) {
+ return true;
+ } else if (this.board[y][x] == myColor && !opponentStoneFound) {
+ breaked = true;
+ } else if (this.board[y][x] == opponentColor) {
+ opponentStoneFound = true;
+ } else {
+ breaked = true;
+ }
+ };
+ };
+
+ const res = [];
+
+ let iterate;
+
+ // 上
+ iterate = createIterater();
+ for (let c = 0, y = targety - 1; y >= 0; c++, y--) {
+ if (iterate(targetx, y)) {
+ res.push([0, c]);
+ break;
+ }
+ }
+
+ // 右上
+ iterate = createIterater();
+ for (let c = 0, i = 1; i <= Math.min(BOARD_SIZE - targetx, targety); c++, i++) {
+ if (iterate(targetx + i, targety - i)) {
+ res.push([1, c]);
+ break;
+ }
+ }
+
+ // 右
+ iterate = createIterater();
+ for (let c = 0, x = targetx + 1; x < BOARD_SIZE; c++, x++) {
+ if (iterate(x, targety)) {
+ res.push([2, c]);
+ break;
+ }
+ }
+
+ // 右下
+ iterate = createIterater();
+ for (let c = 0, i = 1; i < Math.min(BOARD_SIZE - targetx, BOARD_SIZE - targety); c++, i++) {
+ if (iterate(targetx + i, targety + i)) {
+ res.push([3, c]);
+ break;
+ }
+ }
+
+ // 下
+ iterate = createIterater();
+ for (let c = 0, y = targety + 1; y < BOARD_SIZE; c++, y++) {
+ if (iterate(targetx, y)) {
+ res.push([4, c]);
+ break;
+ }
+ }
+
+ // 左下
+ iterate = createIterater();
+ for (let c = 0, i = 1; i < Math.min(targetx, BOARD_SIZE - targety); c++, i++) {
+ if (iterate(targetx - i, targety + i)) {
+ res.push([5, c]);
+ break;
+ }
+ }
+
+ // 左
+ iterate = createIterater();
+ for (let c = 0, x = targetx - 1; x >= 0; c++, x--) {
+ if (iterate(x, targety)) {
+ res.push([6, c]);
+ break;
+ }
+ }
+
+ // 左上
+ iterate = createIterater();
+ for (let c = 0, i = 1; i <= Math.min(targetx, targety); c++, i++) {
+ if (iterate(targetx - i, targety - i)) {
+ res.push([7, c]);
+ break;
+ }
+ }
+
+ return res.length === 0 ? null : res;
+ }
+
+ public toString(): string {
+ //return this.board.map(row => row.map(state => state === 'black' ? '●' : state === 'white' ? '○' : '┼').join('')).join('\n');
+ return this.board.map(row => row.map(state => state === 'black' ? '⚫️' : state === 'white' ? '⚪️' : '🔹').join('')).join('\n');
+ }
+
+ public toPatternString(color): string {
+ //const num = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'];
+ const num = ['0️⃣', '1️⃣', '2️⃣', '3️⃣', '4️⃣', '5️⃣', '6️⃣', '7️⃣', '8️⃣', '9️⃣', '🔟', '🍏', '🍎', '🍐', '🍊', '🍋', '🍌', '🍉', '🍇', '🍓', '🍈', '🍒', '🍑', '🍍'];
+
+ const pattern = this.getPattern(color);
+
+ return this.board.map((row, y) => row.map((state, x) => {
+ const i = pattern.findIndex(p => p[0] == x && p[1] == y);
+ //return state === 'black' ? '●' : state === 'white' ? '○' : i != -1 ? num[i] : '┼';
+ return state === 'black' ? '⚫️' : state === 'white' ? '⚪️' : i != -1 ? num[i] : '🔹';
+ }).join('')).join('\n');
+ }
+}
+
+export function ai(color: string, othello: Othello) {
+ const opponentColor = color == 'black' ? 'white' : 'black';
+
+ function think() {
+ // 打てる場所を取得
+ const ps = othello.getPattern(color);
+
+ if (ps.length > 0) { // 打てる場所がある場合
+ // 角を取得
+ const corners = ps.filter(p =>
+ // 左上
+ (p[0] == 0 && p[1] == 0) ||
+ // 右上
+ (p[0] == (BOARD_SIZE - 1) && p[1] == 0) ||
+ // 右下
+ (p[0] == (BOARD_SIZE - 1) && p[1] == (BOARD_SIZE - 1)) ||
+ // 左下
+ (p[0] == 0 && p[1] == (BOARD_SIZE - 1))
+ );
+
+ if (corners.length > 0) { // どこかしらの角に打てる場合
+ // 打てる角からランダムに選択して打つ
+ const p = corners[Math.floor(Math.random() * corners.length)];
+ othello.set(color, p[0], p[1]);
+ } else { // 打てる角がない場合
+ // 打てる場所からランダムに選択して打つ
+ const p = ps[Math.floor(Math.random() * ps.length)];
+ othello.set(color, p[0], p[1]);
+ }
+
+ // 相手の打つ場所がない場合続けてAIのターン
+ if (othello.getPattern(opponentColor).length === 0) {
+ think();
+ }
+ }
+ }
+
+ think();
+}
diff --git a/src/config.ts b/src/config.ts
index 8f4ada5af9..d37d227a41 100644
--- a/src/config.ts
+++ b/src/config.ts
@@ -68,6 +68,13 @@ type Source = {
hook_secret: string;
username: string;
};
+ line_bot?: {
+ channel_secret: string;
+ channel_access_token: string;
+ };
+ analysis?: {
+ mecab_command?: string;
+ };
};
/**
@@ -81,6 +88,7 @@ type Mixin = {
api_url: string;
auth_url: string;
about_url: string;
+ ch_url: string;
stats_url: string;
status_url: string;
dev_url: string;
@@ -115,6 +123,7 @@ export default function load() {
mixin.secondary_scheme = config.secondary_url.substr(0, config.secondary_url.indexOf('://'));
mixin.api_url = `${mixin.scheme}://api.${mixin.host}`;
mixin.auth_url = `${mixin.scheme}://auth.${mixin.host}`;
+ mixin.ch_url = `${mixin.scheme}://ch.${mixin.host}`;
mixin.dev_url = `${mixin.scheme}://dev.${mixin.host}`;
mixin.about_url = `${mixin.scheme}://about.${mixin.host}`;
mixin.stats_url = `${mixin.scheme}://stats.${mixin.host}`;
diff --git a/src/const.json b/src/const.json
index 1032ed538f..eeb304c9f3 100644
--- a/src/const.json
+++ b/src/const.json
@@ -1,5 +1,4 @@
{
- "themeColor": "#87bb35",
- "themeColorForeground": "#fff",
- "idea": ["#f13049", "#f43636"]
+ "themeColor": "#f43636",
+ "themeColorForeground": "#fff"
}
diff --git a/src/db/mongodb.ts b/src/db/mongodb.ts
index 6ee7f4534f..c978e6460f 100644
--- a/src/db/mongodb.ts
+++ b/src/db/mongodb.ts
@@ -1,11 +1,38 @@
-import * as mongo from 'monk';
-
import config from '../conf';
const uri = config.mongodb.user && config.mongodb.pass
- ? `mongodb://${config.mongodb.user}:${config.mongodb.pass}@${config.mongodb.host}:${config.mongodb.port}/${config.mongodb.db}`
- : `mongodb://${config.mongodb.host}:${config.mongodb.port}/${config.mongodb.db}`;
+? `mongodb://${config.mongodb.user}:${config.mongodb.pass}@${config.mongodb.host}:${config.mongodb.port}/${config.mongodb.db}`
+: `mongodb://${config.mongodb.host}:${config.mongodb.port}/${config.mongodb.db}`;
+
+/**
+ * monk
+ */
+import * as mongo from 'monk';
const db = mongo(uri);
export default db;
+
+/**
+ * MongoDB native module (officialy)
+ */
+import * as mongodb from 'mongodb';
+
+let mdb: mongodb.Db;
+
+const nativeDbConn = async (): Promise<mongodb.Db> => {
+ if (mdb) return mdb;
+
+ const db = await ((): Promise<mongodb.Db> => new Promise((resolve, reject) => {
+ mongodb.MongoClient.connect(uri, (e, db) => {
+ if (e) return reject(e);
+ resolve(db);
+ });
+ }))();
+
+ mdb = db;
+
+ return db;
+};
+
+export { nativeDbConn };
diff --git a/src/docs/api/entities/post.pug b/src/docs/api/entities/post.pug
index e505d3fcb6..954f172717 100644
--- a/src/docs/api/entities/post.pug
+++ b/src/docs/api/entities/post.pug
@@ -52,11 +52,11 @@ block content
td Number
td 返信数
tr.optional
- td reply_to
+ td reply
td: a(href='./post', target='_blank') Post
td 返信先の投稿
tr.nullable
- td reply_to_id
+ td reply_id
td ID
td 返信先の投稿のID
tr.optional
@@ -90,7 +90,7 @@ block content
{
"created_at": "2016-12-10T00:28:50.114Z",
"media_ids": null,
- "reply_to_id": "584a16b15860fc52320137e3",
+ "reply_id": "584a16b15860fc52320137e3",
"repost_id": null,
"text": "小日向美穂だぞ!",
"user_id": "5848bf7764e572683f4402f8",
@@ -117,10 +117,10 @@ block content
"is_following": true,
"is_followed": true
},
- "reply_to": {
+ "reply": {
"created_at": "2016-12-09T02:28:01.563Z",
"media_ids": null,
- "reply_to_id": "5849d35e547e4249be329884",
+ "reply_id": "5849d35e547e4249be329884",
"repost_id": null,
"text": "アイコン小日向美穂?",
"user_id": "57d01a501fdf2d07be417afe",
diff --git a/src/file/server.ts b/src/file/server.ts
index ee67cf7860..375f29487d 100644
--- a/src/file/server.ts
+++ b/src/file/server.ts
@@ -9,7 +9,7 @@ import * as cors from 'cors';
import * as mongodb from 'mongodb';
import * as gm from 'gm';
-import File from '../api/models/drive-file';
+import DriveFile, { getGridFSBucket } from '../api/models/drive-file';
/**
* Init app
@@ -97,17 +97,28 @@ app.get('/:id', async (req, res) => {
return;
}
- const file = await File.findOne({ _id: new mongodb.ObjectID(req.params.id) });
+ const fileId = new mongodb.ObjectID(req.params.id);
+ const file = await DriveFile.findOne({ _id: fileId });
if (file == null) {
- res.status(404).sendFile(`${__dirname} / assets / dummy.png`);
- return;
- } else if (file.data == null) {
- res.sendStatus(400);
+ res.status(404).sendFile(`${__dirname}/assets/dummy.png`);
return;
}
- send(file.data.buffer, file.type, req, res);
+ const bucket = await getGridFSBucket();
+
+ const buffer = await ((id): Promise<Buffer> => new Promise((resolve, reject) => {
+ const chunks = [];
+ const readableStream = bucket.openDownloadStream(id);
+ readableStream.on('data', chunk => {
+ chunks.push(chunk);
+ });
+ readableStream.on('end', () => {
+ resolve(Buffer.concat(chunks));
+ });
+ }))(fileId);
+
+ send(buffer, file.metadata.type, req, res);
});
app.get('/:id/:name', async (req, res) => {
@@ -117,17 +128,28 @@ app.get('/:id/:name', async (req, res) => {
return;
}
- const file = await File.findOne({ _id: new mongodb.ObjectID(req.params.id) });
+ const fileId = new mongodb.ObjectID(req.params.id);
+ const file = await DriveFile.findOne({ _id: fileId });
if (file == null) {
res.status(404).sendFile(`${__dirname}/assets/dummy.png`);
return;
- } else if (file.data == null) {
- res.sendStatus(400);
- return;
}
- send(file.data.buffer, file.type, req, res);
+ const bucket = await getGridFSBucket();
+
+ const buffer = await ((id): Promise<Buffer> => new Promise((resolve, reject) => {
+ const chunks = [];
+ const readableStream = bucket.openDownloadStream(id);
+ readableStream.on('data', chunk => {
+ chunks.push(chunk);
+ });
+ readableStream.on('end', () => {
+ resolve(Buffer.concat(chunks));
+ });
+ }))(fileId);
+
+ send(buffer, file.metadata.type, req, res);
});
module.exports = app;
diff --git a/src/tools/analysis/core.ts b/src/tools/analysis/core.ts
new file mode 100644
index 0000000000..20e5fa6c51
--- /dev/null
+++ b/src/tools/analysis/core.ts
@@ -0,0 +1,49 @@
+const bayes = require('./naive-bayes.js');
+
+const MeCab = require('./mecab');
+import Post from '../../api/models/post';
+
+/**
+ * 投稿を学習したり与えられた投稿のカテゴリを予測します
+ */
+export default class Categorizer {
+ private classifier: any;
+ private mecab: any;
+
+ constructor() {
+ this.mecab = new MeCab();
+
+ // BIND -----------------------------------
+ this.tokenizer = this.tokenizer.bind(this);
+ }
+
+ private tokenizer(text: string) {
+ const tokens = this.mecab.parseSync(text)
+ // 名詞だけに制限
+ .filter(token => token[1] === '名詞')
+ // 取り出し
+ .map(token => token[0]);
+
+ return tokens;
+ }
+
+ public async init() {
+ this.classifier = bayes({
+ tokenizer: this.tokenizer
+ });
+
+ // 訓練データ取得
+ const verifiedPosts = await Post.find({
+ is_category_verified: true
+ });
+
+ // 学習
+ verifiedPosts.forEach(post => {
+ this.classifier.learn(post.text, post.category);
+ });
+ }
+
+ public async predict(text) {
+ return this.classifier.categorize(text);
+ }
+}
diff --git a/src/tools/analysis/extract-user-domains.ts b/src/tools/analysis/extract-user-domains.ts
new file mode 100644
index 0000000000..bc120f5c17
--- /dev/null
+++ b/src/tools/analysis/extract-user-domains.ts
@@ -0,0 +1,120 @@
+import * as URL from 'url';
+
+import Post from '../../api/models/post';
+import User from '../../api/models/user';
+import parse from '../../api/common/text';
+
+process.on('unhandledRejection', console.dir);
+
+function tokenize(text: string) {
+ if (text == null) return [];
+
+ // パース
+ const ast = parse(text);
+
+ const domains = ast
+ // URLを抽出
+ .filter(t => t.type == 'url' || t.type == 'link')
+ .map(t => URL.parse(t.url).hostname);
+
+ return domains;
+}
+
+// Fetch all users
+User.find({}, {
+ fields: {
+ _id: true
+ }
+}).then(users => {
+ let i = -1;
+
+ const x = cb => {
+ if (++i == users.length) return cb();
+ extractDomainsOne(users[i]._id).then(() => x(cb), err => {
+ console.error(err);
+ setTimeout(() => {
+ i--;
+ x(cb);
+ }, 1000);
+ });
+ };
+
+ x(() => {
+ console.log('complete');
+ });
+});
+
+function extractDomainsOne(id) {
+ return new Promise(async (resolve, reject) => {
+ process.stdout.write(`extracting domains of ${id} ...`);
+
+ // Fetch recent posts
+ const recentPosts = await Post.find({
+ user_id: id,
+ text: {
+ $exists: true
+ }
+ }, {
+ sort: {
+ _id: -1
+ },
+ limit: 10000,
+ fields: {
+ _id: false,
+ text: true
+ }
+ });
+
+ // 投稿が少なかったら中断
+ if (recentPosts.length < 100) {
+ process.stdout.write(' >>> -\n');
+ return resolve();
+ }
+
+ const domains = {};
+
+ // Extract domains from recent posts
+ recentPosts.forEach(post => {
+ const domainsOfPost = tokenize(post.text);
+
+ domainsOfPost.forEach(domain => {
+ if (domains[domain]) {
+ domains[domain]++;
+ } else {
+ domains[domain] = 1;
+ }
+ });
+ });
+
+ // Calc peak
+ let peak = 0;
+ Object.keys(domains).forEach(domain => {
+ if (domains[domain] > peak) peak = domains[domain];
+ });
+
+ // Sort domains by frequency
+ const domainsSorted = Object.keys(domains).sort((a, b) => domains[b] - domains[a]);
+
+ // Lookup top 10 domains
+ const topDomains = domainsSorted.slice(0, 10);
+
+ process.stdout.write(' >>> ' + topDomains.join(', ') + '\n');
+
+ // Make domains object (includes weights)
+ const domainsObj = topDomains.map(domain => ({
+ domain: domain,
+ weight: domains[domain] / peak
+ }));
+
+ // Save
+ User.update({ _id: id }, {
+ $set: {
+ domains: domainsObj
+ }
+ }).then(() => {
+ resolve();
+ }, err => {
+ reject(err);
+ });
+ });
+}
diff --git a/src/tools/analysis/extract-user-keywords.ts b/src/tools/analysis/extract-user-keywords.ts
new file mode 100644
index 0000000000..b99ca93211
--- /dev/null
+++ b/src/tools/analysis/extract-user-keywords.ts
@@ -0,0 +1,154 @@
+const moji = require('moji');
+
+const MeCab = require('./mecab');
+import Post from '../../api/models/post';
+import User from '../../api/models/user';
+import parse from '../../api/common/text';
+
+process.on('unhandledRejection', console.dir);
+
+const stopwords = [
+ 'ー',
+
+ 'の', 'に', 'は', 'を', 'た', 'が', 'で', 'て', 'と', 'し', 'れ', 'さ',
+ 'ある', 'いる', 'も', 'する', 'から', 'な', 'こと', 'として', 'い', 'や', 'れる',
+ 'など', 'なっ', 'ない', 'この', 'ため', 'その', 'あっ', 'よう', 'また', 'もの',
+ 'という', 'あり', 'まで', 'られ', 'なる', 'へ', 'か', 'だ', 'これ', 'によって',
+ 'により', 'おり', 'より', 'による', 'ず', 'なり', 'られる', 'において', 'ば', 'なかっ',
+ 'なく', 'しかし', 'について', 'せ', 'だっ', 'その後', 'できる', 'それ', 'う', 'ので',
+ 'なお', 'のみ', 'でき', 'き', 'つ', 'における', 'および', 'いう', 'さらに', 'でも',
+ 'ら', 'たり', 'その他', 'に関する', 'たち', 'ます', 'ん', 'なら', 'に対して', '特に',
+ 'せる', '及び', 'これら', 'とき', 'では', 'にて', 'ほか', 'ながら', 'うち', 'そして',
+ 'とともに', 'ただし', 'かつて', 'それぞれ', 'または', 'お', 'ほど', 'ものの', 'に対する',
+ 'ほとんど', 'と共に', 'といった', 'です', 'とも', 'ところ', 'ここ', '感じ', '気持ち',
+ 'あと', '自分', 'すき', '()',
+
+ 'about', 'after', 'all', 'also', 'am', 'an', 'and', 'another', 'any', 'are', 'as', 'at', 'be',
+ 'because', 'been', 'before', 'being', 'between', 'both', 'but', 'by', 'came', 'can',
+ 'come', 'could', 'did', 'do', 'each', 'for', 'from', 'get', 'got', 'has', 'had',
+ 'he', 'have', 'her', 'here', 'him', 'himself', 'his', 'how', 'if', 'in', 'into',
+ 'is', 'it', 'like', 'make', 'many', 'me', 'might', 'more', 'most', 'much', 'must',
+ 'my', 'never', 'now', 'of', 'on', 'only', 'or', 'other', 'our', 'out', 'over',
+ 'said', 'same', 'see', 'should', 'since', 'some', 'still', 'such', 'take', 'than',
+ 'that', 'the', 'their', 'them', 'then', 'there', 'these', 'they', 'this', 'those',
+ 'through', 'to', 'too', 'under', 'up', 'very', 'was', 'way', 'we', 'well', 'were',
+ 'what', 'where', 'which', 'while', 'who', 'with', 'would', 'you', 'your', 'a', 'i'
+];
+
+const mecab = new MeCab();
+
+function tokenize(text: string) {
+ if (text == null) return [];
+
+ // パース
+ const ast = parse(text);
+
+ const plain = ast
+ // テキストのみ(URLなどを除外するという意)
+ .filter(t => t.type == 'text' || t.type == 'bold')
+ .map(t => t.content)
+ .join('');
+
+ const tokens = mecab.parseSync(plain)
+ // キーワードのみ
+ .filter(token => token[1] == '名詞' && (token[2] == '固有名詞' || token[2] == '一般'))
+ // 取り出し(&整形(全角を半角にしたり大文字を小文字で統一したり))
+ .map(token => moji(token[0]).convert('ZE', 'HE').convert('HK', 'ZK').toString().toLowerCase())
+ // ストップワードなど
+ .filter(word =>
+ stopwords.indexOf(word) === -1 &&
+ word.length > 1 &&
+ word.indexOf('!') === -1 &&
+ word.indexOf('!') === -1 &&
+ word.indexOf('?') === -1 &&
+ word.indexOf('?') === -1);
+
+ return tokens;
+}
+
+// Fetch all users
+User.find({}, {
+ fields: {
+ _id: true
+ }
+}).then(users => {
+ let i = -1;
+
+ const x = cb => {
+ if (++i == users.length) return cb();
+ extractKeywordsOne(users[i]._id).then(() => x(cb), err => {
+ console.error(err);
+ setTimeout(() => {
+ i--;
+ x(cb);
+ }, 1000);
+ });
+ };
+
+ x(() => {
+ console.log('complete');
+ });
+});
+
+function extractKeywordsOne(id) {
+ return new Promise(async (resolve, reject) => {
+ process.stdout.write(`extracting keywords of ${id} ...`);
+
+ // Fetch recent posts
+ const recentPosts = await Post.find({
+ user_id: id,
+ text: {
+ $exists: true
+ }
+ }, {
+ sort: {
+ _id: -1
+ },
+ limit: 10000,
+ fields: {
+ _id: false,
+ text: true
+ }
+ });
+
+ // 投稿が少なかったら中断
+ if (recentPosts.length < 300) {
+ process.stdout.write(' >>> -\n');
+ return resolve();
+ }
+
+ const keywords = {};
+
+ // Extract keywords from recent posts
+ recentPosts.forEach(post => {
+ const keywordsOfPost = tokenize(post.text);
+
+ keywordsOfPost.forEach(keyword => {
+ if (keywords[keyword]) {
+ keywords[keyword]++;
+ } else {
+ keywords[keyword] = 1;
+ }
+ });
+ });
+
+ // Sort keywords by frequency
+ const keywordsSorted = Object.keys(keywords).sort((a, b) => keywords[b] - keywords[a]);
+
+ // Lookup top 10 keywords
+ const topKeywords = keywordsSorted.slice(0, 10);
+
+ process.stdout.write(' >>> ' + topKeywords.join(', ') + '\n');
+
+ // Save
+ User.update({ _id: id }, {
+ $set: {
+ keywords: topKeywords
+ }
+ }).then(() => {
+ resolve();
+ }, err => {
+ reject(err);
+ });
+ });
+}
diff --git a/src/tools/analysis/mecab.js b/src/tools/analysis/mecab.js
new file mode 100644
index 0000000000..82f7d6d529
--- /dev/null
+++ b/src/tools/analysis/mecab.js
@@ -0,0 +1,85 @@
+// Original source code: https://github.com/hecomi/node-mecab-async
+// CUSTOMIZED BY SYUILO
+
+var exec = require('child_process').exec;
+var execSync = require('child_process').execSync;
+var sq = require('shell-quote');
+
+const config = require('../../conf').default;
+
+// for backward compatibility
+var MeCab = function() {};
+
+MeCab.prototype = {
+ command : config.analysis.mecab_command ? config.analysis.mecab_command : 'mecab',
+ _format: function(arrayResult) {
+ var result = [];
+ if (!arrayResult) { return result; }
+ // Reference: http://mecab.googlecode.com/svn/trunk/mecab/doc/index.html
+ // 表層形\t品詞,品詞細分類1,品詞細分類2,品詞細分類3,活用形,活用型,原形,読み,発音
+ arrayResult.forEach(function(parsed) {
+ if (parsed.length <= 8) { return; }
+ result.push({
+ kanji : parsed[0],
+ lexical : parsed[1],
+ compound : parsed[2],
+ compound2 : parsed[3],
+ compound3 : parsed[4],
+ conjugation : parsed[5],
+ inflection : parsed[6],
+ original : parsed[7],
+ reading : parsed[8],
+ pronunciation : parsed[9] || ''
+ });
+ });
+ return result;
+ },
+ _shellCommand : function(str) {
+ return sq.quote(['echo', str]) + ' | ' + this.command;
+ },
+ _parseMeCabResult : function(result) {
+ return result.split('\n').map(function(line) {
+ return line.replace('\t', ',').split(',');
+ });
+ },
+ parse : function(str, callback) {
+ process.nextTick(function() { // for bug
+ exec(MeCab._shellCommand(str), function(err, result) {
+ if (err) { return callback(err); }
+ callback(err, MeCab._parseMeCabResult(result).slice(0,-2));
+ });
+ });
+ },
+ parseSync : function(str) {
+ var result = execSync(MeCab._shellCommand(str));
+ return MeCab._parseMeCabResult(String(result)).slice(0, -2);
+ },
+ parseFormat : function(str, callback) {
+ MeCab.parse(str, function(err, result) {
+ if (err) { return callback(err); }
+ callback(err, MeCab._format(result));
+ });
+ },
+ parseSyncFormat : function(str) {
+ return MeCab._format(MeCab.parseSync(str));
+ },
+ _wakatsu : function(arr) {
+ return arr.map(function(data) { return data[0]; });
+ },
+ wakachi : function(str, callback) {
+ MeCab.parse(str, function(err, arr) {
+ if (err) { return callback(err); }
+ callback(null, MeCab._wakatsu(arr));
+ });
+ },
+ wakachiSync : function(str) {
+ var arr = MeCab.parseSync(str);
+ return MeCab._wakatsu(arr);
+ }
+};
+
+for (var x in MeCab.prototype) {
+ MeCab[x] = MeCab.prototype[x];
+}
+
+module.exports = MeCab;
diff --git a/src/tools/analysis/naive-bayes.js b/src/tools/analysis/naive-bayes.js
new file mode 100644
index 0000000000..78f07153cf
--- /dev/null
+++ b/src/tools/analysis/naive-bayes.js
@@ -0,0 +1,302 @@
+// Original source code: https://github.com/ttezel/bayes/blob/master/lib/naive_bayes.js (commit: 2c20d3066e4fc786400aaedcf3e42987e52abe3c)
+// CUSTOMIZED BY SYUILO
+
+/*
+ Expose our naive-bayes generator function
+*/
+module.exports = function (options) {
+ return new Naivebayes(options)
+}
+
+// keys we use to serialize a classifier's state
+var STATE_KEYS = module.exports.STATE_KEYS = [
+ 'categories', 'docCount', 'totalDocuments', 'vocabulary', 'vocabularySize',
+ 'wordCount', 'wordFrequencyCount', 'options'
+];
+
+/**
+ * Initializes a NaiveBayes instance from a JSON state representation.
+ * Use this with classifier.toJson().
+ *
+ * @param {String} jsonStr state representation obtained by classifier.toJson()
+ * @return {NaiveBayes} Classifier
+ */
+module.exports.fromJson = function (jsonStr) {
+ var parsed;
+ try {
+ parsed = JSON.parse(jsonStr)
+ } catch (e) {
+ throw new Error('Naivebayes.fromJson expects a valid JSON string.')
+ }
+ // init a new classifier
+ var classifier = new Naivebayes(parsed.options)
+
+ // override the classifier's state
+ STATE_KEYS.forEach(function (k) {
+ if (!parsed[k]) {
+ throw new Error('Naivebayes.fromJson: JSON string is missing an expected property: `'+k+'`.')
+ }
+ classifier[k] = parsed[k]
+ })
+
+ return classifier
+}
+
+/**
+ * Given an input string, tokenize it into an array of word tokens.
+ * This is the default tokenization function used if user does not provide one in `options`.
+ *
+ * @param {String} text
+ * @return {Array}
+ */
+var defaultTokenizer = function (text) {
+ //remove punctuation from text - remove anything that isn't a word char or a space
+ var rgxPunctuation = /[^(a-zA-ZA-Яa-я0-9_)+\s]/g
+
+ var sanitized = text.replace(rgxPunctuation, ' ')
+
+ return sanitized.split(/\s+/)
+}
+
+/**
+ * Naive-Bayes Classifier
+ *
+ * This is a naive-bayes classifier that uses Laplace Smoothing.
+ *
+ * Takes an (optional) options object containing:
+ * - `tokenizer` => custom tokenization function
+ *
+ */
+function Naivebayes (options) {
+ // set options object
+ this.options = {}
+ if (typeof options !== 'undefined') {
+ if (!options || typeof options !== 'object' || Array.isArray(options)) {
+ throw TypeError('NaiveBayes got invalid `options`: `' + options + '`. Pass in an object.')
+ }
+ this.options = options
+ }
+
+ this.tokenizer = this.options.tokenizer || defaultTokenizer
+
+ //initialize our vocabulary and its size
+ this.vocabulary = {}
+ this.vocabularySize = 0
+
+ //number of documents we have learned from
+ this.totalDocuments = 0
+
+ //document frequency table for each of our categories
+ //=> for each category, how often were documents mapped to it
+ this.docCount = {}
+
+ //for each category, how many words total were mapped to it
+ this.wordCount = {}
+
+ //word frequency table for each category
+ //=> for each category, how frequent was a given word mapped to it
+ this.wordFrequencyCount = {}
+
+ //hashmap of our category names
+ this.categories = {}
+}
+
+/**
+ * Initialize each of our data structure entries for this new category
+ *
+ * @param {String} categoryName
+ */
+Naivebayes.prototype.initializeCategory = function (categoryName) {
+ if (!this.categories[categoryName]) {
+ this.docCount[categoryName] = 0
+ this.wordCount[categoryName] = 0
+ this.wordFrequencyCount[categoryName] = {}
+ this.categories[categoryName] = true
+ }
+ return this
+}
+
+/**
+ * train our naive-bayes classifier by telling it what `category`
+ * the `text` corresponds to.
+ *
+ * @param {String} text
+ * @param {String} class
+ */
+Naivebayes.prototype.learn = function (text, category) {
+ var self = this
+
+ //initialize category data structures if we've never seen this category
+ self.initializeCategory(category)
+
+ //update our count of how many documents mapped to this category
+ self.docCount[category]++
+
+ //update the total number of documents we have learned from
+ self.totalDocuments++
+
+ //normalize the text into a word array
+ var tokens = self.tokenizer(text)
+
+ //get a frequency count for each token in the text
+ var frequencyTable = self.frequencyTable(tokens)
+
+ /*
+ Update our vocabulary and our word frequency count for this category
+ */
+
+ Object
+ .keys(frequencyTable)
+ .forEach(function (token) {
+ //add this word to our vocabulary if not already existing
+ if (!self.vocabulary[token]) {
+ self.vocabulary[token] = true
+ self.vocabularySize++
+ }
+
+ var frequencyInText = frequencyTable[token]
+
+ //update the frequency information for this word in this category
+ if (!self.wordFrequencyCount[category][token])
+ self.wordFrequencyCount[category][token] = frequencyInText
+ else
+ self.wordFrequencyCount[category][token] += frequencyInText
+
+ //update the count of all words we have seen mapped to this category
+ self.wordCount[category] += frequencyInText
+ })
+
+ return self
+}
+
+/**
+ * Determine what category `text` belongs to.
+ *
+ * @param {String} text
+ * @return {String} category
+ */
+Naivebayes.prototype.categorize = function (text) {
+ var self = this
+ , maxProbability = -Infinity
+ , chosenCategory = null
+
+ var tokens = self.tokenizer(text)
+ var frequencyTable = self.frequencyTable(tokens)
+
+ //iterate thru our categories to find the one with max probability for this text
+ Object
+ .keys(self.categories)
+ .forEach(function (category) {
+
+ //start by calculating the overall probability of this category
+ //=> out of all documents we've ever looked at, how many were
+ // mapped to this category
+ var categoryProbability = self.docCount[category] / self.totalDocuments
+
+ //take the log to avoid underflow
+ var logProbability = Math.log(categoryProbability)
+
+ //now determine P( w | c ) for each word `w` in the text
+ Object
+ .keys(frequencyTable)
+ .forEach(function (token) {
+ var frequencyInText = frequencyTable[token]
+ var tokenProbability = self.tokenProbability(token, category)
+
+ // console.log('token: %s category: `%s` tokenProbability: %d', token, category, tokenProbability)
+
+ //determine the log of the P( w | c ) for this word
+ logProbability += frequencyInText * Math.log(tokenProbability)
+ })
+
+ if (logProbability > maxProbability) {
+ maxProbability = logProbability
+ chosenCategory = category
+ }
+ })
+
+ return chosenCategory
+}
+
+/**
+ * Calculate probability that a `token` belongs to a `category`
+ *
+ * @param {String} token
+ * @param {String} category
+ * @return {Number} probability
+ */
+Naivebayes.prototype.tokenProbability = function (token, category) {
+ //how many times this word has occurred in documents mapped to this category
+ var wordFrequencyCount = this.wordFrequencyCount[category][token] || 0
+
+ //what is the count of all words that have ever been mapped to this category
+ var wordCount = this.wordCount[category]
+
+ //use laplace Add-1 Smoothing equation
+ return ( wordFrequencyCount + 1 ) / ( wordCount + this.vocabularySize )
+}
+
+/**
+ * Build a frequency hashmap where
+ * - the keys are the entries in `tokens`
+ * - the values are the frequency of each entry in `tokens`
+ *
+ * @param {Array} tokens Normalized word array
+ * @return {Object}
+ */
+Naivebayes.prototype.frequencyTable = function (tokens) {
+ var frequencyTable = Object.create(null)
+
+ tokens.forEach(function (token) {
+ if (!frequencyTable[token])
+ frequencyTable[token] = 1
+ else
+ frequencyTable[token]++
+ })
+
+ return frequencyTable
+}
+
+/**
+ * Dump the classifier's state as a JSON string.
+ * @return {String} Representation of the classifier.
+ */
+Naivebayes.prototype.toJson = function () {
+ var state = {}
+ var self = this
+ STATE_KEYS.forEach(function (k) {
+ state[k] = self[k]
+ })
+
+ var jsonStr = JSON.stringify(state)
+
+ return jsonStr
+}
+
+// (original method)
+Naivebayes.prototype.export = function () {
+ var state = {}
+ var self = this
+ STATE_KEYS.forEach(function (k) {
+ state[k] = self[k]
+ })
+
+ return state
+}
+
+module.exports.import = function (data) {
+ var parsed = data
+
+ // init a new classifier
+ var classifier = new Naivebayes()
+
+ // override the classifier's state
+ STATE_KEYS.forEach(function (k) {
+ if (!parsed[k]) {
+ throw new Error('Naivebayes.import: data is missing an expected property: `'+k+'`.')
+ }
+ classifier[k] = parsed[k]
+ })
+
+ return classifier
+}
diff --git a/src/tools/analysis/predict-all-post-category.ts b/src/tools/analysis/predict-all-post-category.ts
new file mode 100644
index 0000000000..058c4f99ef
--- /dev/null
+++ b/src/tools/analysis/predict-all-post-category.ts
@@ -0,0 +1,35 @@
+import Post from '../../api/models/post';
+import Core from './core';
+
+const c = new Core();
+
+c.init().then(() => {
+ // 全ての(人間によって証明されていない)投稿を取得
+ Post.find({
+ text: {
+ $exists: true
+ },
+ is_category_verified: {
+ $ne: true
+ }
+ }, {
+ sort: {
+ _id: -1
+ },
+ fields: {
+ _id: true,
+ text: true
+ }
+ }).then(posts => {
+ posts.forEach(post => {
+ console.log(`predicting... ${post._id}`);
+ const category = c.predict(post.text);
+
+ Post.update({ _id: post._id }, {
+ $set: {
+ category: category
+ }
+ });
+ });
+ });
+});
diff --git a/src/tools/analysis/predict-user-interst.ts b/src/tools/analysis/predict-user-interst.ts
new file mode 100644
index 0000000000..99bdfa4206
--- /dev/null
+++ b/src/tools/analysis/predict-user-interst.ts
@@ -0,0 +1,45 @@
+import Post from '../../api/models/post';
+import User from '../../api/models/user';
+
+export async function predictOne(id) {
+ console.log(`predict interest of ${id} ...`);
+
+ // TODO: repostなども含める
+ const recentPosts = await Post.find({
+ user_id: id,
+ category: {
+ $exists: true
+ }
+ }, {
+ sort: {
+ _id: -1
+ },
+ limit: 1000,
+ fields: {
+ _id: false,
+ category: true
+ }
+ });
+
+ const categories = {};
+
+ recentPosts.forEach(post => {
+ if (categories[post.category]) {
+ categories[post.category]++;
+ } else {
+ categories[post.category] = 1;
+ }
+ });
+}
+
+export async function predictAll() {
+ const allUsers = await User.find({}, {
+ fields: {
+ _id: true
+ }
+ });
+
+ allUsers.forEach(user => {
+ predictOne(user._id);
+ });
+}
diff --git a/src/tsconfig.json b/src/tsconfig.json
index ecff047a74..36600eed2b 100644
--- a/src/tsconfig.json
+++ b/src/tsconfig.json
@@ -1,5 +1,6 @@
{
"compilerOptions": {
+ "allowJs": true,
"noEmitOnError": false,
"noImplicitAny": false,
"noImplicitReturns": true,
diff --git a/src/utils/type.ts b/src/utils/type.ts
new file mode 100644
index 0000000000..ba6ea0be77
--- /dev/null
+++ b/src/utils/type.ts
@@ -0,0 +1,3 @@
+// https://github.com/Microsoft/TypeScript/issues/12215
+export type Diff<T extends string, U extends string> = ({ [P in T]: P } & { [P in U]: never } & { [x: string]: never })[T];
+export type Omit<T, K extends keyof T> = { [P in Diff<keyof T, K>]: T[P] };
diff --git a/src/web/app/base.styl b/src/web/app/app.styl
index 81c039f0a3..94faba73d4 100644
--- a/src/web/app/base.styl
+++ b/src/web/app/app.styl
@@ -5,8 +5,6 @@ json('../../const.json')
$theme-color = themeColor
$theme-color-foreground = themeColorForeground
-@import './reset'
-
/*
::selection
background $theme-color
@@ -14,6 +12,9 @@ $theme-color-foreground = themeColorForeground
*/
*
+ position relative
+ box-sizing border-box
+ background-clip padding-box !important
tap-highlight-color rgba($theme-color, 0.7)
-webkit-tap-highlight-color rgba($theme-color, 0.7)
@@ -29,6 +30,9 @@ html
&, *
cursor progress !important
+body
+ overflow-wrap break-word
+
#error
padding 32px
color #fff
diff --git a/src/web/app/auth/style.styl b/src/web/app/auth/style.styl
index 046a5ff6ee..bd25e1b572 100644
--- a/src/web/app/auth/style.styl
+++ b/src/web/app/auth/style.styl
@@ -1,4 +1,5 @@
-@import "../base"
+@import "../app"
+@import "../reset"
html
background #eee
diff --git a/src/web/app/ch/router.js b/src/web/app/ch/router.js
new file mode 100644
index 0000000000..424158f403
--- /dev/null
+++ b/src/web/app/ch/router.js
@@ -0,0 +1,32 @@
+import * as riot from 'riot';
+const route = require('page');
+let page = null;
+
+export default me => {
+ route('/', index);
+ route('/:channel', channel);
+ route('*', notFound);
+
+ function index() {
+ mount(document.createElement('mk-index'));
+ }
+
+ function channel(ctx) {
+ const el = document.createElement('mk-channel');
+ el.setAttribute('id', ctx.params.channel);
+ mount(el);
+ }
+
+ function notFound() {
+ mount(document.createElement('mk-not-found'));
+ }
+
+ // EXEC
+ route();
+};
+
+function mount(content) {
+ if (page) page.unmount();
+ const body = document.getElementById('app');
+ page = riot.mount(body.appendChild(content))[0];
+}
diff --git a/src/web/app/ch/script.js b/src/web/app/ch/script.js
new file mode 100644
index 0000000000..760d405c52
--- /dev/null
+++ b/src/web/app/ch/script.js
@@ -0,0 +1,18 @@
+/**
+ * Channels
+ */
+
+// Style
+import './style.styl';
+
+require('./tags');
+import init from '../init';
+import route from './router';
+
+/**
+ * init
+ */
+init(me => {
+ // Start routing
+ route(me);
+});
diff --git a/src/web/app/ch/style.styl b/src/web/app/ch/style.styl
new file mode 100644
index 0000000000..21ca648cbe
--- /dev/null
+++ b/src/web/app/ch/style.styl
@@ -0,0 +1,10 @@
+@import "../app"
+
+html
+ padding 8px
+ background #efefef
+
+#wait
+ top auto
+ bottom 15px
+ left 15px
diff --git a/src/web/app/ch/tags/channel.tag b/src/web/app/ch/tags/channel.tag
new file mode 100644
index 0000000000..4ae62e7b39
--- /dev/null
+++ b/src/web/app/ch/tags/channel.tag
@@ -0,0 +1,403 @@
+<mk-channel>
+ <mk-header/>
+ <hr>
+ <main if={ !fetching }>
+ <h1>{ channel.title }</h1>
+
+ <div if={ SIGNIN }>
+ <p if={ channel.is_watching }>このチャンネルをウォッチしています <a onclick={ unwatch }>ウォッチ解除</a></p>
+ <p if={ !channel.is_watching }><a onclick={ watch }>このチャンネルをウォッチする</a></p>
+ </div>
+
+ <div class="share">
+ <mk-twitter-button/>
+ <mk-line-button/>
+ </div>
+
+ <div class="body">
+ <p if={ postsFetching }>読み込み中<mk-ellipsis/></p>
+ <div if={ !postsFetching }>
+ <p if={ posts == null || posts.length == 0 }>まだ投稿がありません</p>
+ <virtual if={ posts != null }>
+ <mk-channel-post each={ post in posts.slice().reverse() } post={ post } form={ parent.refs.form }/>
+ </virtual>
+ </div>
+ </div>
+ <hr>
+ <mk-channel-form if={ SIGNIN } channel={ channel } ref="form"/>
+ <div if={ !SIGNIN }>
+ <p>参加するには<a href={ CONFIG.url }>ログインまたは新規登録</a>してください</p>
+ </div>
+ <hr>
+ <footer>
+ <small><a href={ CONFIG.url }>Misskey</a> ver { version } (葵 aoi)</small>
+ </footer>
+ </main>
+ <style>
+ :scope
+ display block
+
+ > main
+ > h1
+ font-size 1.5em
+ color #f00
+
+ > .share
+ > *
+ margin-right 4px
+
+ > .body
+ margin 8px 0 0 0
+
+ > mk-channel-form
+ max-width 500px
+
+ </style>
+ <script>
+ import Progress from '../../common/scripts/loading';
+ import ChannelStream from '../../common/scripts/channel-stream';
+
+ this.mixin('i');
+ this.mixin('api');
+
+ this.id = this.opts.id;
+ this.fetching = true;
+ this.postsFetching = true;
+ this.channel = null;
+ this.posts = null;
+ this.connection = new ChannelStream(this.id);
+ this.version = VERSION;
+ this.unreadCount = 0;
+
+ this.on('mount', () => {
+ document.documentElement.style.background = '#efefef';
+
+ Progress.start();
+
+ let fetched = false;
+
+ // チャンネル概要読み込み
+ this.api('channels/show', {
+ channel_id: this.id
+ }).then(channel => {
+ if (fetched) {
+ Progress.done();
+ } else {
+ Progress.set(0.5);
+ fetched = true;
+ }
+
+ this.update({
+ fetching: false,
+ channel: channel
+ });
+
+ document.title = channel.title + ' | Misskey'
+ });
+
+ // 投稿読み込み
+ this.api('channels/posts', {
+ channel_id: this.id
+ }).then(posts => {
+ if (fetched) {
+ Progress.done();
+ } else {
+ Progress.set(0.5);
+ fetched = true;
+ }
+
+ this.update({
+ postsFetching: false,
+ posts: posts
+ });
+ });
+
+ this.connection.on('post', this.onPost);
+ document.addEventListener('visibilitychange', this.onVisibilitychange, false);
+ });
+
+ this.on('unmount', () => {
+ this.connection.off('post', this.onPost);
+ this.connection.close();
+ document.removeEventListener('visibilitychange', this.onVisibilitychange);
+ });
+
+ this.onPost = post => {
+ this.posts.unshift(post);
+ this.update();
+
+ if (document.hidden && this.SIGNIN && post.user_id !== this.I.id) {
+ this.unreadCount++;
+ document.title = `(${this.unreadCount}) ${this.channel.title} | Misskey`;
+ }
+ };
+
+ this.onVisibilitychange = () => {
+ if (!document.hidden) {
+ this.unreadCount = 0;
+ document.title = this.channel.title + ' | Misskey'
+ }
+ };
+
+ this.watch = () => {
+ this.api('channels/watch', {
+ channel_id: this.id
+ }).then(() => {
+ this.channel.is_watching = true;
+ this.update();
+ }, e => {
+ alert('error');
+ });
+ };
+
+ this.unwatch = () => {
+ this.api('channels/unwatch', {
+ channel_id: this.id
+ }).then(() => {
+ this.channel.is_watching = false;
+ this.update();
+ }, e => {
+ alert('error');
+ });
+ };
+ </script>
+</mk-channel>
+
+<mk-channel-post>
+ <header>
+ <a class="index" onclick={ reply }>{ post.index }:</a>
+ <a class="name" href={ CONFIG.url + '/' + post.user.username }><b>{ post.user.name }</b></a>
+ <mk-time time={ post.created_at }/>
+ <mk-time time={ post.created_at } mode="detail"/>
+ <span>ID:<i>{ post.user.username }</i></span>
+ </header>
+ <div>
+ <a if={ post.reply }>&gt;&gt;{ post.reply.index }</a>
+ { post.text }
+ <div class="media" if={ post.media }>
+ <virtual each={ file in post.media }>
+ <a href={ file.url } target="_blank">
+ <img src={ file.url + '?thumbnail&size=512' } alt={ file.name } title={ file.name }/>
+ </a>
+ </virtual>
+ </div>
+ </div>
+ <style>
+ :scope
+ display block
+ margin 0
+ padding 0
+
+ > header
+ position -webkit-sticky
+ position sticky
+ z-index 1
+ top 0
+ background rgba(239, 239, 239, 0.9)
+
+ > .index
+ margin-right 0.25em
+ color #000
+
+ > .name
+ margin-right 0.5em
+ color #008000
+
+ > mk-time
+ margin-right 0.5em
+
+ &:first-of-type
+ display none
+
+ @media (max-width 600px)
+ > mk-time
+ &:first-of-type
+ display initial
+
+ &:last-of-type
+ display none
+
+ > div
+ padding 0 0 1em 2em
+
+ > .media
+ > a
+ display inline-block
+
+ > img
+ max-width 100%
+ vertical-align bottom
+
+ </style>
+ <script>
+ this.post = this.opts.post;
+ this.form = this.opts.form;
+
+ this.reply = () => {
+ this.form.update({
+ reply: this.post
+ });
+ };
+ </script>
+</mk-channel-post>
+
+<mk-channel-form>
+ <p if={ reply }><b>&gt;&gt;{ reply.index }</b> ({ reply.user.name }): <a onclick={ clearReply }>[x]</a></p>
+ <textarea ref="text" disabled={ wait } oninput={ update } onkeydown={ onkeydown } onpaste={ onpaste } placeholder="%i18n:ch.tags.mk-channel-form.textarea%"></textarea>
+ <div class="actions">
+ <button onclick={ selectFile }><i class="fa fa-upload"></i>%i18n:ch.tags.mk-channel-form.upload%</button>
+ <button onclick={ drive }><i class="fa fa-cloud"></i>%i18n:ch.tags.mk-channel-form.drive%</button>
+ <button class={ wait: wait } ref="submit" disabled={ wait || (refs.text.value.length == 0) } onclick={ post }>
+ <i class="fa fa-paper-plane" if={ !wait }></i>{ wait ? '%i18n:ch.tags.mk-channel-form.posting%' : '%i18n:ch.tags.mk-channel-form.post%' }<mk-ellipsis if={ wait }/>
+ </button>
+ </div>
+ <mk-uploader ref="uploader"/>
+ <ol if={ files }>
+ <li each={ files }>{ name }</li>
+ </ol>
+ <input ref="file" type="file" accept="image/*" multiple="multiple" onchange={ changeFile }/>
+ <style>
+ :scope
+ display block
+
+ > textarea
+ width 100%
+ max-width 100%
+ min-width 100%
+ min-height 5em
+
+ > .actions
+ display flex
+
+ > button
+ > i
+ margin-right 0.25em
+
+ &:last-child
+ margin-left auto
+
+ &.wait
+ cursor wait
+
+ > input[type='file']
+ display none
+
+ </style>
+ <script>
+ import CONFIG from '../../common/scripts/config';
+
+ this.mixin('api');
+
+ this.channel = this.opts.channel;
+ this.files = null;
+
+ this.on('mount', () => {
+ this.refs.uploader.on('uploaded', file => {
+ this.update({
+ files: [file]
+ });
+ });
+ });
+
+ this.upload = file => {
+ this.refs.uploader.upload(file);
+ };
+
+ this.clearReply = () => {
+ this.update({
+ reply: null
+ });
+ };
+
+ this.clear = () => {
+ this.clearReply();
+ this.update({
+ files: null
+ });
+ this.refs.text.value = '';
+ };
+
+ this.post = () => {
+ this.update({
+ wait: true
+ });
+
+ const files = this.files && this.files.length > 0
+ ? this.files.map(f => f.id)
+ : undefined;
+
+ this.api('posts/create', {
+ text: this.refs.text.value == '' ? undefined : this.refs.text.value,
+ media_ids: files,
+ reply_id: this.reply ? this.reply.id : undefined,
+ channel_id: this.channel.id
+ }).then(data => {
+ this.clear();
+ }).catch(err => {
+ alert('失敗した');
+ }).then(() => {
+ this.update({
+ wait: false
+ });
+ });
+ };
+
+ this.changeFile = () => {
+ this.refs.file.files.forEach(this.upload);
+ };
+
+ this.selectFile = () => {
+ this.refs.file.click();
+ };
+
+ this.drive = () => {
+ window['cb'] = files => {
+ this.update({
+ files: files
+ });
+ };
+
+ window.open(CONFIG.url + '/selectdrive?multiple=true',
+ 'drive_window',
+ 'height=500,width=800');
+ };
+
+ this.onkeydown = e => {
+ if ((e.which == 10 || e.which == 13) && (e.ctrlKey || e.metaKey)) this.post();
+ };
+
+ this.onpaste = e => {
+ e.clipboardData.items.forEach(item => {
+ if (item.kind == 'file') {
+ this.upload(item.getAsFile());
+ }
+ });
+ };
+ </script>
+</mk-channel-form>
+
+<mk-twitter-button>
+ <a href="https://twitter.com/share?ref_src=twsrc%5Etfw" class="twitter-share-button" data-show-count="false">Tweet</a>
+ <script>
+ this.on('mount', () => {
+ const head = document.getElementsByTagName('head')[0];
+ const script = document.createElement('script');
+ script.setAttribute('src', 'https://platform.twitter.com/widgets.js');
+ script.setAttribute('async', 'async');
+ head.appendChild(script);
+ });
+ </script>
+</mk-twitter-button>
+
+<mk-line-button>
+ <div class="line-it-button" data-lang="ja" data-type="share-a" data-url={ CONFIG.chUrl } style="display: none;"></div>
+ <script>
+ this.on('mount', () => {
+ const head = document.getElementsByTagName('head')[0];
+ const script = document.createElement('script');
+ script.setAttribute('src', 'https://d.line-scdn.net/r/web/social-plugin/js/thirdparty/loader.min.js');
+ script.setAttribute('async', 'async');
+ head.appendChild(script);
+ });
+ </script>
+</mk-line-button>
diff --git a/src/web/app/ch/tags/header.tag b/src/web/app/ch/tags/header.tag
new file mode 100644
index 0000000000..5cdcbd09cc
--- /dev/null
+++ b/src/web/app/ch/tags/header.tag
@@ -0,0 +1,20 @@
+<mk-header>
+ <div>
+ <a href={ CONFIG.chUrl }>Index</a> | <a href={ CONFIG.url }>Misskey</a>
+ </div>
+ <div>
+ <a if={ !SIGNIN } href={ CONFIG.url }>ログイン(新規登録)</a>
+ <a if={ SIGNIN } href={ CONFIG.url + '/' + I.username }>{ I.username }</a>
+ </div>
+ <style>
+ :scope
+ display flex
+
+ > div:last-child
+ margin-left auto
+
+ </style>
+ <script>
+ this.mixin('i');
+ </script>
+</mk-header>
diff --git a/src/web/app/ch/tags/index.js b/src/web/app/ch/tags/index.js
new file mode 100644
index 0000000000..12ffdaeb84
--- /dev/null
+++ b/src/web/app/ch/tags/index.js
@@ -0,0 +1,3 @@
+require('./index.tag');
+require('./channel.tag');
+require('./header.tag');
diff --git a/src/web/app/ch/tags/index.tag b/src/web/app/ch/tags/index.tag
new file mode 100644
index 0000000000..50ccc0d91c
--- /dev/null
+++ b/src/web/app/ch/tags/index.tag
@@ -0,0 +1,35 @@
+<mk-index>
+ <mk-header/>
+ <hr>
+ <button onclick={ n }>%i18n:ch.tags.mk-index.new%</button>
+ <hr>
+ <ul if={ channels }>
+ <li each={ channels }><a href={ '/' + this.id }>{ this.title }</a></li>
+ </ul>
+ <style>
+ :scope
+ display block
+
+ </style>
+ <script>
+ this.mixin('api');
+
+ this.on('mount', () => {
+ this.api('channels').then(channels => {
+ this.update({
+ channels: channels
+ });
+ });
+ });
+
+ this.n = () => {
+ const title = window.prompt('%i18n:ch.tags.mk-index.channel-title%');
+
+ this.api('channels/create', {
+ title: title
+ }).then(channel => {
+ location.href = '/' + channel.id;
+ });
+ };
+ </script>
+</mk-index>
diff --git a/src/web/app/common/scripts/channel-stream.js b/src/web/app/common/scripts/channel-stream.js
new file mode 100644
index 0000000000..17944dbe45
--- /dev/null
+++ b/src/web/app/common/scripts/channel-stream.js
@@ -0,0 +1,16 @@
+'use strict';
+
+import Stream from './stream';
+
+/**
+ * Channel stream connection
+ */
+class Connection extends Stream {
+ constructor(channelId) {
+ super('channel', {
+ channel: channelId
+ });
+ }
+}
+
+export default Connection;
diff --git a/src/web/app/common/scripts/config.js b/src/web/app/common/scripts/config.js
index 75a7abba29..c5015622f0 100644
--- a/src/web/app/common/scripts/config.js
+++ b/src/web/app/common/scripts/config.js
@@ -6,6 +6,7 @@ const host = isRoot ? Url.host : Url.host.substring(Url.host.indexOf('.') + 1, U
const scheme = Url.protocol;
const url = `${scheme}//${host}`;
const apiUrl = `${scheme}//api.${host}`;
+const chUrl = `${scheme}//ch.${host}`;
const devUrl = `${scheme}//dev.${host}`;
const aboutUrl = `${scheme}//about.${host}`;
const statsUrl = `${scheme}//stats.${host}`;
@@ -16,6 +17,7 @@ export default {
scheme,
url,
apiUrl,
+ chUrl,
devUrl,
aboutUrl,
statsUrl,
diff --git a/src/web/app/common/scripts/home-stream.js b/src/web/app/common/scripts/home-stream.js
index 24f13cd291..de9ceb3b51 100644
--- a/src/web/app/common/scripts/home-stream.js
+++ b/src/web/app/common/scripts/home-stream.js
@@ -1,6 +1,7 @@
'use strict';
import Stream from './stream';
+import signout from './signout';
/**
* Home stream connection
@@ -11,7 +12,17 @@ class Connection extends Stream {
i: me.token
});
+ // 最終利用日時を更新するため定期的にaliveメッセージを送信
+ setInterval(() => {
+ this.send({ type: 'alive' });
+ }, 1000 * 60);
+
this.on('i_updated', me.update);
+
+ this.on('my_token_regenerated', () => {
+ alert('%i18n:common.my-token-regenerated%');
+ signout();
+ });
}
}
diff --git a/src/web/app/common/tags/activity-table.tag b/src/web/app/common/tags/activity-table.tag
index 6331e7c9c3..1d26d1788a 100644
--- a/src/web/app/common/tags/activity-table.tag
+++ b/src/web/app/common/tags/activity-table.tag
@@ -17,7 +17,6 @@
display block
max-width 600px
margin 0 auto
- background #fff
> svg
display block
diff --git a/src/web/app/common/tags/api-info.tag b/src/web/app/common/tags/api-info.tag
deleted file mode 100644
index 612f20a7a8..0000000000
--- a/src/web/app/common/tags/api-info.tag
+++ /dev/null
@@ -1,27 +0,0 @@
-<mk-api-info>
- <p>Token:<code>{ I.token }</code></p>
- <p>APIを利用するには、上記のトークンを「i」というキーでパラメータに付加してリクエストします。</p>
- <p>アカウントを乗っ取られてしまう可能性があるため、このトークンは第三者に教えないでください(アプリなどにも入力しないでください)。</p>
- <p>万が一このトークンが漏れたりその可能性がある場合は
- <button class="regenerate" onclick={ regenerateToken }>トークンを再生成</button>できます。(副作用として、ログインしているすべてのデバイスでログアウトが発生します)
- </p>
- <style>
- :scope
- display block
- color #4a535a
-
- code
- padding 4px
- background #eee
-
- .regenerate
- display inline
- color $theme-color
-
- &:hover
- text-decoration underline
- </style>
- <script>
- this.mixin('i');
- </script>
-</mk-api-info>
diff --git a/src/web/app/common/tags/error.tag b/src/web/app/common/tags/error.tag
index e4e0272a49..62f4563e5c 100644
--- a/src/web/app/common/tags/error.tag
+++ b/src/web/app/common/tags/error.tag
@@ -1,7 +1,15 @@
<mk-error>
- <img src="/assets/error.jpg" alt=""/>
+ <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%</p>
+ <p class="text">{
+ '%i18n:common.tags.mk-error.description%'.substr(0, '%i18n:common.tags.mk-error.description%'.indexOf('{'))
+ }<a onclick={ 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 if={ !troubleshooting } onclick={ troubleshoot }>%i18n:common.tags.mk-error.troubleshoot%</button>
+ <mk-troubleshooter if={ troubleshooting }/>
<p class="thanks">%i18n:common.tags.mk-error.thanks%</p>
<style>
:scope
@@ -30,6 +38,25 @@
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%)
+
+ > mk-troubleshooter
+ margin 1em auto 0 auto
+
> .thanks
display block
margin 2em auto 0 auto
@@ -49,9 +76,142 @@
</style>
<script>
+ this.troubleshooting = false;
+
this.on('mount', () => {
document.title = 'Oops!';
document.documentElement.style.background = '#f8f8f8';
});
+
+ this.reload = () => {
+ location.reload();
+ };
+
+ this.troubleshoot = () => {
+ this.update({
+ troubleshooting: true
+ });
+ };
</script>
</mk-error>
+
+<mk-troubleshooter>
+ <h1><i class="fa fa-wrench"></i>%i18n:common.tags.mk-error.troubleshooter.title%</h1>
+ <div>
+ <p data-wip={ network == null }><i if={ network != null } class="fa fa-{ network ? 'check' : 'times' }"></i>{ network == null ? '%i18n:common.tags.mk-error.troubleshooter.checking-network%' : '%i18n:common.tags.mk-error.troubleshooter.network%' }<mk-ellipsis if={ network == null }/></p>
+ <p if={ network == true } data-wip={ internet == null }><i if={ internet != null } class="fa fa-{ internet ? 'check' : 'times' }"></i>{ internet == null ? '%i18n:common.tags.mk-error.troubleshooter.checking-internet%' : '%i18n:common.tags.mk-error.troubleshooter.internet%' }<mk-ellipsis if={ internet == null }/></p>
+ <p if={ internet == true } data-wip={ server == null }><i if={ server != null } class="fa fa-{ server ? 'check' : 'times' }"></i>{ server == null ? '%i18n:common.tags.mk-error.troubleshooter.checking-server%' : '%i18n:common.tags.mk-error.troubleshooter.server%' }<mk-ellipsis if={ server == null }/></p>
+ </div>
+ <p if={ !end }>%i18n:common.tags.mk-error.troubleshooter.finding%<mk-ellipsis/></p>
+ <p if={ network === false }><b><i class="fa fa-exclamation-triangle"></i>%i18n:common.tags.mk-error.troubleshooter.no-network%</b><br>%i18n:common.tags.mk-error.troubleshooter.no-network-desc%</p>
+ <p if={ internet === false }><b><i class="fa fa-exclamation-triangle"></i>%i18n:common.tags.mk-error.troubleshooter.no-internet%</b><br>%i18n:common.tags.mk-error.troubleshooter.no-internet-desc%</p>
+ <p if={ server === false }><b><i class="fa fa-exclamation-triangle"></i>%i18n:common.tags.mk-error.troubleshooter.no-server%</b><br>%i18n:common.tags.mk-error.troubleshooter.no-server-desc%</p>
+ <p if={ server === true } class="success"><b><i class="fa fa-info-circle"></i>%i18n:common.tags.mk-error.troubleshooter.success%</b><br>%i18n:common.tags.mk-error.troubleshooter.success-desc%</p>
+
+ <style>
+ :scope
+ display block
+ 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
+
+ > i
+ 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
+
+ > i
+ margin-right 0.25em
+
+ &.fa-times
+ color #e03524
+
+ &.fa-check
+ color #84c32f
+
+ > p
+ margin 0
+ padding 0.6em 1.2em
+ font-size 1em
+ color #444
+ border-top solid 1px #eee
+
+ > b
+ > i
+ margin-right 0.25em
+
+ &.success
+ > b
+ color #39adad
+
+ &:not(.success)
+ > b
+ color #ad4339
+
+ </style>
+ <script>
+ import CONFIG from '../../common/scripts/config';
+
+ this.on('mount', () => {
+ this.update({
+ network: navigator.onLine
+ });
+
+ if (!this.network) {
+ this.update({
+ end: true
+ });
+ return;
+ }
+
+ // Check internet connection
+ fetch('https://google.com?rand=' + Math.random(), {
+ mode: 'no-cors'
+ }).then(() => {
+ this.update({
+ internet: true
+ });
+
+ // Check misskey server is available
+ fetch(`${CONFIG.apiUrl}/meta`).then(() => {
+ this.update({
+ end: true,
+ server: true
+ });
+ })
+ .catch(() => {
+ this.update({
+ end: true,
+ server: false
+ });
+ });
+ })
+ .catch(() => {
+ this.update({
+ end: true,
+ internet: false
+ });
+ });
+ });
+ </script>
+</mk-troubleshooter>
diff --git a/src/web/app/common/tags/index.js b/src/web/app/common/tags/index.js
index 5dc4ef4546..35a9f4586e 100644
--- a/src/web/app/common/tags/index.js
+++ b/src/web/app/common/tags/index.js
@@ -14,7 +14,6 @@ require('./forkit.tag');
require('./introduction.tag');
require('./copyright.tag');
require('./signin-history.tag');
-require('./api-info.tag');
require('./twitter-setting.tag');
require('./authorized-apps.tag');
require('./poll.tag');
@@ -28,3 +27,4 @@ require('./activity-table.tag');
require('./reaction-picker.tag');
require('./reactions-viewer.tag');
require('./reaction-icon.tag');
+require('./post-menu.tag');
diff --git a/src/web/app/common/tags/post-menu.tag b/src/web/app/common/tags/post-menu.tag
new file mode 100644
index 0000000000..be4468a214
--- /dev/null
+++ b/src/web/app/common/tags/post-menu.tag
@@ -0,0 +1,157 @@
+<mk-post-menu>
+ <div class="backdrop" ref="backdrop" onclick={ close }></div>
+ <div class="popover { compact: opts.compact }" ref="popover">
+ <button if={ post.user_id === I.id } onclick={ pin }>%i18n:common.tags.mk-post-menu.pin%</button>
+ <div if={ I.is_pro && !post.is_category_verified }>
+ <select ref="categorySelect">
+ <option value="">%i18n:common.tags.mk-post-menu.select%</option>
+ <option value="music">%i18n:common.post_categories.music%</option>
+ <option value="game">%i18n:common.post_categories.game%</option>
+ <option value="anime">%i18n:common.post_categories.anime%</option>
+ <option value="it">%i18n:common.post_categories.it%</option>
+ <option value="gadgets">%i18n:common.post_categories.gadgets%</option>
+ <option value="photography">%i18n:common.post_categories.photography%</option>
+ </select>
+ <button onclick={ categorize }>%i18n:common.tags.mk-post-menu.categorize%</button>
+ </div>
+ </div>
+ <style>
+ $border-color = rgba(27, 31, 35, 0.15)
+
+ :scope
+ display block
+ 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
+
+ </style>
+ <script>
+ import anime from 'animejs';
+
+ this.mixin('i');
+ this.mixin('api');
+
+ this.post = this.opts.post;
+ this.source = this.opts.source;
+
+ this.on('mount', () => {
+ const rect = this.source.getBoundingClientRect();
+ const width = this.refs.popover.offsetWidth;
+ const height = this.refs.popover.offsetHeight;
+ if (this.opts.compact) {
+ const x = rect.left + window.pageXOffset + (this.source.offsetWidth / 2);
+ const y = rect.top + window.pageYOffset + (this.source.offsetHeight / 2);
+ this.refs.popover.style.left = (x - (width / 2)) + 'px';
+ this.refs.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;
+ this.refs.popover.style.left = (x - (width / 2)) + 'px';
+ this.refs.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
+ });
+ });
+
+ this.pin = () => {
+ this.api('i/pin', {
+ post_id: this.post.id
+ }).then(() => {
+ if (this.opts.cb) this.opts.cb('pinned', '%i18n:common.tags.mk-post-menu.pinned%');
+ this.unmount();
+ });
+ };
+
+ this.categorize = () => {
+ const category = this.refs.categorySelect.options[this.refs.categorySelect.selectedIndex].value;
+ this.api('posts/categorize', {
+ post_id: this.post.id,
+ category: category
+ }).then(() => {
+ if (this.opts.cb) this.opts.cb('categorized', '%i18n:common.tags.mk-post-menu.categorized%');
+ this.unmount();
+ });
+ };
+
+ this.close = () => {
+ this.refs.backdrop.style.pointerEvents = 'none';
+ anime({
+ targets: this.refs.backdrop,
+ opacity: 0,
+ duration: 200,
+ easing: 'linear'
+ });
+
+ this.refs.popover.style.pointerEvents = 'none';
+ anime({
+ targets: this.refs.popover,
+ opacity: 0,
+ scale: 0.5,
+ duration: 200,
+ easing: 'easeInBack',
+ complete: () => this.unmount()
+ });
+ };
+ </script>
+</mk-post-menu>
diff --git a/src/web/app/common/tags/signup.tag b/src/web/app/common/tags/signup.tag
index 0359f4fab9..17de0347f5 100644
--- a/src/web/app/common/tags/signup.tag
+++ b/src/web/app/common/tags/signup.tag
@@ -3,7 +3,7 @@
<label class="username">
<p class="caption"><i class="fa fa-at"></i>%i18n:common.tags.mk-signup.username%</p>
<input ref="username" type="text" pattern="^[a-zA-Z0-9-]{3,20}$" placeholder="a~z、A~Z、0~9、-" autocomplete="off" required="required" onkeyup={ onChangeUsername }/>
- <p class="profile-page-url-preview" if={ refs.username.value != '' && username-state != 'invalidFormat' && username-state != 'minRange' && username-state != 'maxRange' }>{ '/' + refs.username.value }</p>
+ <p class="profile-page-url-preview" if={ refs.username.value != '' && username-state != 'invalidFormat' && username-state != 'minRange' && username-state != 'maxRange' }>{ CONFIG.url + '/' + refs.username.value }</p>
<p class="info" if={ usernameState == 'wait' } style="color:#999"><i class="fa fa-fw fa-spinner fa-pulse"></i>%i18n:common.tags.mk-signup.checking%</p>
<p class="info" if={ usernameState == 'ok' } style="color:#3CB7B5"><i class="fa fa-fw fa-check"></i>%i18n:common.tags.mk-signup.available%</p>
<p class="info" if={ usernameState == 'unavailable' } style="color:#FF1161"><i class="fa fa-fw fa-exclamation-triangle"></i>%i18n:common.tags.mk-signup.unavailable%</p>
diff --git a/src/web/app/desktop/router.js b/src/web/app/desktop/router.js
index afa8a2dce3..977e3fa9a6 100644
--- a/src/web/app/desktop/router.js
+++ b/src/web/app/desktop/router.js
@@ -7,14 +7,15 @@ const route = require('page');
let page = null;
export default me => {
- route('/', index);
- route('/i>mentions', mentions);
- route('/post::post', post);
- route('/search::query', search);
- route('/:user', user.bind(null, 'home'));
- route('/:user/graphs', user.bind(null, 'graphs'));
- route('/:user/:post', post);
- route('*', notFound);
+ route('/', index);
+ route('/selectdrive', selectDrive);
+ route('/i>mentions', mentions);
+ route('/post::post', post);
+ route('/search::query', search);
+ route('/:user', user.bind(null, 'home'));
+ route('/:user/graphs', user.bind(null, 'graphs'));
+ route('/:user/:post', post);
+ route('*', notFound);
function index() {
me ? home() : entrance();
@@ -54,6 +55,10 @@ export default me => {
mount(el);
}
+ function selectDrive() {
+ mount(document.createElement('mk-selectdrive-page'));
+ }
+
function notFound() {
mount(document.createElement('mk-not-found'));
}
@@ -67,6 +72,7 @@ export default me => {
};
function mount(content) {
+ document.documentElement.style.background = '#313a42';
document.documentElement.removeAttribute('data-page');
if (page) page.unmount();
const body = document.getElementById('app');
diff --git a/src/web/app/desktop/script.js b/src/web/app/desktop/script.js
index 2e81147943..46a7fce700 100644
--- a/src/web/app/desktop/script.js
+++ b/src/web/app/desktop/script.js
@@ -11,7 +11,7 @@ import * as riot from 'riot';
import init from '../init';
import route from './router';
import fuckAdBlock from './scripts/fuck-ad-block';
-import getPostSummary from '../common/scripts/get-post-summary';
+import getPostSummary from '../../../common/get-post-summary.ts';
/**
* init
diff --git a/src/web/app/desktop/scripts/password-dialog.js b/src/web/app/desktop/scripts/password-dialog.js
new file mode 100644
index 0000000000..2bdc93e421
--- /dev/null
+++ b/src/web/app/desktop/scripts/password-dialog.js
@@ -0,0 +1,11 @@
+import * as riot from 'riot';
+
+export default (title, onOk, onCancel) => {
+ const dialog = document.body.appendChild(document.createElement('mk-input-dialog'));
+ return riot.mount(dialog, {
+ title: title,
+ type: 'password',
+ onOk: onOk,
+ onCancel: onCancel
+ });
+};
diff --git a/src/web/app/desktop/style.styl b/src/web/app/desktop/style.styl
index fa50f6ce31..4597dffdb3 100644
--- a/src/web/app/desktop/style.styl
+++ b/src/web/app/desktop/style.styl
@@ -1,4 +1,5 @@
-@import "../base"
+@import "../app"
+@import "../reset"
@import "../../../../node_modules/cropperjs/dist/cropper.css"
*::input-placeholder
@@ -39,7 +40,8 @@
background rgba(0, 0, 0, 0.2)
html
- background #fdfdfd
+ //background #2f3e42
+ background #313a42
// ↓ workaround of https://github.com/riot/riot/issues/2134
&[data-page='entrance']
diff --git a/src/web/app/desktop/tags/detailed-post-window.tag b/src/web/app/desktop/tags/detailed-post-window.tag
new file mode 100644
index 0000000000..04f9acf974
--- /dev/null
+++ b/src/web/app/desktop/tags/detailed-post-window.tag
@@ -0,0 +1,80 @@
+<mk-detailed-post-window>
+ <div class="bg" ref="bg" onclick={ bgClick }></div>
+ <div class="main" ref="main" if={ !fetching }>
+ <mk-post-detail ref="detail" post={ post }/>
+ </div>
+ <style>
+ :scope
+ display block
+ opacity 0
+
+ > .bg
+ display block
+ position fixed
+ z-index 1000
+ top 0
+ left 0
+ width 100%
+ height 100%
+ background rgba(0, 0, 0, 0.7)
+
+ > .main
+ display block
+ position fixed
+ z-index 1000
+ top 20%
+ left 0
+ right 0
+ margin 0 auto 0 auto
+ padding 0
+ width 638px
+ text-align center
+
+ > mk-post-detail
+ margin 0 auto
+
+ </style>
+ <script>
+ import anime from 'animejs';
+
+ this.mixin('api');
+
+ this.fetching = true;
+ this.post = null;
+
+ this.on('mount', () => {
+ anime({
+ targets: this.root,
+ opacity: 1,
+ duration: 100,
+ easing: 'linear'
+ });
+
+ this.api('posts/show', {
+ post_id: this.opts.post
+ }).then(post => {
+
+ this.update({
+ fetching: false,
+ post: post
+ });
+ });
+ });
+
+ this.close = () => {
+ this.refs.bg.style.pointerEvents = 'none';
+ this.refs.main.style.pointerEvents = 'none';
+ anime({
+ targets: this.root,
+ opacity: 0,
+ duration: 300,
+ easing: 'linear',
+ complete: () => this.unmount()
+ });
+ };
+
+ this.bgClick = () => {
+ this.close();
+ };
+ </script>
+</mk-detailed-post-window>
diff --git a/src/web/app/desktop/tags/dialog.tag b/src/web/app/desktop/tags/dialog.tag
index 9905123eeb..743fd63942 100644
--- a/src/web/app/desktop/tags/dialog.tag
+++ b/src/web/app/desktop/tags/dialog.tag
@@ -44,6 +44,9 @@
// color #43A4EC
font-weight bold
+ &:empty
+ display none
+
> i
margin-right 0.5em
diff --git a/src/web/app/desktop/tags/home-widgets/nav.tag b/src/web/app/desktop/tags/home-widgets/nav.tag
index 499d66014b..54bfb87a11 100644
--- a/src/web/app/desktop/tags/home-widgets/nav.tag
+++ b/src/web/app/desktop/tags/home-widgets/nav.tag
@@ -1,4 +1,4 @@
-<mk-nav-home-widget><a href={ CONFIG.aboutUrl }>Misskeyについて</a><i>・</i><a href={ CONFIG.statsUrl }>統計</a><i>・</i><a href={ CONFIG.statusUrl }>ステータス</a><i>・</i><a href="http://zawazawa.jp/misskey/">Wiki</a><i>・</i><a href="https://github.com/syuilo/misskey">リポジトリ</a><i>・</i><a href={ CONFIG.devUrl }>開発者</a><i>・</i><a href="https://twitter.com/misskey_xyz" target="_blank">Follow us on <i class="fa fa-twitter"></i></a>
+<mk-nav-home-widget><a href={ CONFIG.aboutUrl }>%i18n:desktop.tags.mk-nav-home-widget.about%</a><i>・</i><a href={ CONFIG.statsUrl }>%i18n:desktop.tags.mk-nav-home-widget.stats%</a><i>・</i><a href={ CONFIG.statusUrl }>%i18n:desktop.tags.mk-nav-home-widget.status%</a><i>・</i><a href="http://zawazawa.jp/misskey/">%i18n:desktop.tags.mk-nav-home-widget.wiki%</a><i>・</i><a href="https://github.com/syuilo/misskey/blob/master/DONORS.md">%i18n:desktop.tags.mk-nav-home-widget.donors%</a><i>・</i><a href="https://github.com/syuilo/misskey">%i18n:desktop.tags.mk-nav-home-widget.repository%</a><i>・</i><a href={ CONFIG.devUrl }>%i18n:desktop.tags.mk-nav-home-widget.develop%</a><i>・</i><a href="https://twitter.com/misskey_xyz" target="_blank">Follow us on <i class="fa fa-twitter"></i></a>
<style>
:scope
display block
diff --git a/src/web/app/desktop/tags/home-widgets/rss-reader.tag b/src/web/app/desktop/tags/home-widgets/rss-reader.tag
index 550d7e76de..e9b740762e 100644
--- a/src/web/app/desktop/tags/home-widgets/rss-reader.tag
+++ b/src/web/app/desktop/tags/home-widgets/rss-reader.tag
@@ -4,7 +4,7 @@
<div class="feed" if={ !initializing }>
<virtual each={ item in items }><a href={ item.link } target="_blank">{ item.title }</a></virtual>
</div>
- <p class="initializing" if={ initializing }><i class="fa fa-spinner fa-pulse fa-fw"></i>読み込んでいます<mk-ellipsis/></p>
+ <p class="initializing" if={ initializing }><i class="fa fa-spinner fa-pulse fa-fw"></i>%i18n:common.loading%<mk-ellipsis/></p>
<style>
:scope
display block
diff --git a/src/web/app/desktop/tags/home-widgets/version.tag b/src/web/app/desktop/tags/home-widgets/version.tag
index 079e4e86b8..ea5307061c 100644
--- a/src/web/app/desktop/tags/home-widgets/version.tag
+++ b/src/web/app/desktop/tags/home-widgets/version.tag
@@ -1,5 +1,5 @@
<mk-version-home-widget>
- <p>ver{ version }</p>
+ <p>ver { version } (葵 aoi)</p>
<style>
:scope
display block
diff --git a/src/web/app/desktop/tags/index.js b/src/web/app/desktop/tags/index.js
index 177ba41293..37fdfe37e4 100644
--- a/src/web/app/desktop/tags/index.js
+++ b/src/web/app/desktop/tags/index.js
@@ -16,17 +16,9 @@ require('./crop-window.tag');
require('./settings.tag');
require('./settings-window.tag');
require('./analog-clock.tag');
-require('./ui-header.tag');
-require('./ui-header-account.tag');
-require('./ui-header-notifications.tag');
-require('./ui-header-clock.tag');
-require('./ui-header-nav.tag');
-require('./ui-header-post-button.tag');
-require('./ui-header-search.tag');
require('./notifications.tag');
require('./post-form-window.tag');
require('./post-form.tag');
-require('./timeline-post.tag');
require('./post-preview.tag');
require('./repost-form-window.tag');
require('./home-widgets/user-recommendation.tag');
@@ -69,6 +61,7 @@ require('./pages/user.tag');
require('./pages/post.tag');
require('./pages/search.tag');
require('./pages/not-found.tag');
+require('./pages/selectdrive.tag');
require('./autocomplete-suggestion.tag');
require('./progress-dialog.tag');
require('./user-preview.tag');
@@ -79,7 +72,6 @@ require('./search-posts.tag');
require('./set-avatar-suggestion.tag');
require('./set-banner-suggestion.tag');
require('./repost-form.tag');
-require('./timeline-post-sub.tag');
require('./sub-post-content.tag');
require('./images-viewer.tag');
require('./image-dialog.tag');
@@ -90,4 +82,4 @@ require('./user-followers.tag');
require('./user-following-window.tag');
require('./user-followers-window.tag');
require('./list-user.tag');
-require('./ui-notification.tag');
+require('./detailed-post-window.tag');
diff --git a/src/web/app/desktop/tags/input-dialog.tag b/src/web/app/desktop/tags/input-dialog.tag
index f343c4625a..78fd62ee8b 100644
--- a/src/web/app/desktop/tags/input-dialog.tag
+++ b/src/web/app/desktop/tags/input-dialog.tag
@@ -5,7 +5,7 @@
</yield>
<yield to="content">
<div class="body">
- <input ref="text" oninput={ parent.update } onkeydown={ parent.onKeydown } placeholder={ parent.placeholder }/>
+ <input ref="text" type={ parent.type } oninput={ parent.onInput } onkeydown={ parent.onKeydown } placeholder={ parent.placeholder }/>
</div>
<div class="action">
<button class="cancel" onclick={ parent.cancel }>キャンセル</button>
@@ -126,6 +126,7 @@
this.placeholder = this.opts.placeholder;
this.default = this.opts.default;
this.allowEmpty = this.opts.allowEmpty != null ? this.opts.allowEmpty : true;
+ this.type = this.opts.type ? this.opts.type : 'text';
this.on('mount', () => {
this.text = this.refs.window.refs.text;
@@ -156,6 +157,10 @@
this.refs.window.close();
};
+ this.onInput = () => {
+ this.update();
+ };
+
this.onKeydown = e => {
if (e.which == 13) { // Enter
e.preventDefault();
diff --git a/src/web/app/desktop/tags/notifications.tag b/src/web/app/desktop/tags/notifications.tag
index 21e4fe7fa5..a4f66105a8 100644
--- a/src/web/app/desktop/tags/notifications.tag
+++ b/src/web/app/desktop/tags/notifications.tag
@@ -207,7 +207,7 @@
</style>
<script>
- import getPostSummary from '../../common/scripts/get-post-summary';
+ import getPostSummary from '../../../../common/get-post-summary.ts';
this.getPostSummary = getPostSummary;
this.mixin('i');
@@ -252,6 +252,12 @@
});
this.onNotification = notification => {
+ // TODO: ユーザーが画面を見てないと思われるとき(ブラウザやタブがアクティブじゃないなど)は送信しない
+ this.stream.send({
+ type: 'read_notification',
+ id: notification.id
+ });
+
this.notifications.unshift(notification);
this.update();
};
diff --git a/src/web/app/desktop/tags/pages/home.tag b/src/web/app/desktop/tags/pages/home.tag
index 124a2eefa3..e8ba4023de 100644
--- a/src/web/app/desktop/tags/pages/home.tag
+++ b/src/web/app/desktop/tags/pages/home.tag
@@ -8,7 +8,7 @@
</style>
<script>
import Progress from '../../../common/scripts/loading';
- import getPostSummary from '../../../common/scripts/get-post-summary';
+ import getPostSummary from '../../../../../common/get-post-summary.ts';
this.mixin('i');
this.mixin('api');
diff --git a/src/web/app/desktop/tags/pages/post.tag b/src/web/app/desktop/tags/pages/post.tag
index c91e98bbd4..f270b43ac2 100644
--- a/src/web/app/desktop/tags/pages/post.tag
+++ b/src/web/app/desktop/tags/pages/post.tag
@@ -1,7 +1,9 @@
<mk-post-page>
<mk-ui ref="ui">
- <main>
+ <main if={ !parent.fetching }>
+ <a if={ parent.post.next } href={ parent.post.next }><i class="fa fa-angle-up"></i>%i18n:desktop.tags.mk-post-page.next%</a>
<mk-post-detail ref="detail" post={ parent.post }/>
+ <a if={ parent.post.prev } href={ parent.post.prev }><i class="fa fa-angle-down"></i>%i18n:desktop.tags.mk-post-page.prev%</a>
</main>
</mk-ui>
<style>
@@ -10,6 +12,19 @@
main
padding 16px
+ text-align center
+
+ > a
+ display inline-block
+
+ &:first-child
+ margin-bottom 4px
+
+ &:last-child
+ margin-top 4px
+
+ > i
+ margin-right 4px
> mk-post-detail
margin 0 auto
@@ -18,16 +33,23 @@
<script>
import Progress from '../../../common/scripts/loading';
- this.post = this.opts.post;
+ this.mixin('api');
+
+ this.fetching = true;
+ this.post = null;
this.on('mount', () => {
Progress.start();
- this.refs.ui.refs.detail.on('post-fetched', () => {
- Progress.set(0.5);
- });
+ this.api('posts/show', {
+ post_id: this.opts.post
+ }).then(post => {
+
+ this.update({
+ fetching: false,
+ post: post
+ });
- this.refs.ui.refs.detail.on('loaded', () => {
Progress.done();
});
});
diff --git a/src/web/app/desktop/tags/pages/selectdrive.tag b/src/web/app/desktop/tags/pages/selectdrive.tag
new file mode 100644
index 0000000000..63fc588fac
--- /dev/null
+++ b/src/web/app/desktop/tags/pages/selectdrive.tag
@@ -0,0 +1,160 @@
+<mk-selectdrive-page>
+ <mk-drive-browser ref="browser" multiple={ multiple }/>
+ <div>
+ <button class="upload" title="PCからドライブにファイルをアップロード" onclick={ upload }><i class="fa fa-upload"></i></button>
+ <button class="cancel" onclick={ close }>キャンセル</button>
+ <button class="ok" onclick={ ok }>決定</button>
+ </div>
+
+ <style>
+ :scope
+ display block
+ position fixed
+ height 100%
+ background #fff
+
+ > mk-drive-browser
+ height calc(100% - 72px)
+
+ > div
+ position fixed
+ bottom 0
+ left 0
+ width 100%
+ height 72px
+ background lighten($theme-color, 95%)
+
+ .upload
+ display inline-block
+ position absolute
+ top 8px
+ left 16px
+ cursor pointer
+ padding 0
+ margin 8px 4px 0 0
+ width 40px
+ height 40px
+ font-size 1em
+ color rgba($theme-color, 0.5)
+ background transparent
+ outline none
+ border solid 1px transparent
+ border-radius 4px
+
+ &:hover
+ background transparent
+ border-color rgba($theme-color, 0.3)
+
+ &:active
+ color rgba($theme-color, 0.6)
+ background transparent
+ border-color rgba($theme-color, 0.5)
+ box-shadow 0 2px 4px rgba(darken($theme-color, 50%), 0.15) inset
+
+ &:focus
+ &:after
+ content ""
+ pointer-events none
+ position absolute
+ top -5px
+ right -5px
+ bottom -5px
+ left -5px
+ border 2px solid rgba($theme-color, 0.3)
+ border-radius 8px
+
+ .ok
+ .cancel
+ display block
+ position absolute
+ bottom 16px
+ cursor pointer
+ padding 0
+ margin 0
+ width 120px
+ height 40px
+ font-size 1em
+ outline none
+ border-radius 4px
+
+ &:focus
+ &:after
+ content ""
+ pointer-events none
+ position absolute
+ top -5px
+ right -5px
+ bottom -5px
+ left -5px
+ border 2px solid rgba($theme-color, 0.3)
+ border-radius 8px
+
+ &:disabled
+ opacity 0.7
+ cursor default
+
+ .ok
+ right 16px
+ color $theme-color-foreground
+ background linear-gradient(to bottom, lighten($theme-color, 25%) 0%, lighten($theme-color, 10%) 100%)
+ border solid 1px lighten($theme-color, 15%)
+
+ &:not(:disabled)
+ font-weight bold
+
+ &:hover:not(:disabled)
+ background linear-gradient(to bottom, lighten($theme-color, 8%) 0%, darken($theme-color, 8%) 100%)
+ border-color $theme-color
+
+ &:active:not(:disabled)
+ background $theme-color
+ border-color $theme-color
+
+ .cancel
+ right 148px
+ color #888
+ background linear-gradient(to bottom, #ffffff 0%, #f5f5f5 100%)
+ border solid 1px #e2e2e2
+
+ &:hover
+ background linear-gradient(to bottom, #f9f9f9 0%, #ececec 100%)
+ border-color #dcdcdc
+
+ &:active
+ background #ececec
+ border-color #dcdcdc
+
+ </style>
+ <script>
+ const q = (new URL(location)).searchParams;
+ this.multiple = q.get('multiple') == 'true' ? true : false;
+
+ this.on('mount', () => {
+ document.documentElement.style.background = '#fff';
+
+ this.refs.browser.on('selected', file => {
+ this.files = [file];
+ this.ok();
+ });
+
+ this.refs.browser.on('change-selection', files => {
+ this.update({
+ files: files
+ });
+ });
+ });
+
+ this.upload = () => {
+ this.refs.browser.selectLocalFile();
+ };
+
+ this.close = () => {
+ window.close();
+ };
+
+ this.ok = () => {
+ window.opener.cb(this.multiple ? this.files : this.files[0]);
+ window.close();
+ };
+ </script>
+</mk-selectdrive-page>
diff --git a/src/web/app/desktop/tags/pages/user.tag b/src/web/app/desktop/tags/pages/user.tag
index 864fe22735..811ca5c0fd 100644
--- a/src/web/app/desktop/tags/pages/user.tag
+++ b/src/web/app/desktop/tags/pages/user.tag
@@ -16,7 +16,7 @@
this.refs.ui.refs.user.on('user-fetched', user => {
Progress.set(0.5);
- document.title = user.name + ' | Misskey'
+ document.title = user.name + ' | Misskey';
});
this.refs.ui.refs.user.on('loaded', () => {
diff --git a/src/web/app/desktop/tags/post-detail.tag b/src/web/app/desktop/tags/post-detail.tag
index b162a4084a..ce7f81e32c 100644
--- a/src/web/app/desktop/tags/post-detail.tag
+++ b/src/web/app/desktop/tags/post-detail.tag
@@ -1,9 +1,6 @@
<mk-post-detail title={ title }>
- <div class="fetching" if={ fetching }>
- <mk-ellipsis-icon/>
- </div>
- <div class="main" if={ !fetching }>
- <button class="read-more" if={ p.reply_to && p.reply_to.reply_to_id && context == null } title="会話をもっと読み込む" onclick={ loadContext } disabled={ contextFetching }>
+ <div class="main">
+ <button class="read-more" if={ p.reply && p.reply.reply_id && context == null } title="会話をもっと読み込む" onclick={ loadContext } disabled={ contextFetching }>
<i class="fa fa-ellipsis-v" if={ !contextFetching }></i>
<i class="fa fa-spinner fa-pulse" if={ contextFetching }></i>
</button>
@@ -12,8 +9,8 @@
<mk-post-detail-sub post={ post }/>
</virtual>
</div>
- <div class="reply-to" if={ p.reply_to }>
- <mk-post-detail-sub post={ p.reply_to }/>
+ <div class="reply-to" if={ p.reply }>
+ <mk-post-detail-sub post={ p.reply }/>
</div>
<div class="repost" if={ isRepost }>
<p>
@@ -33,7 +30,7 @@
<header>
<a class="name" href={ '/' + p.user.username } data-user-preview={ p.user.id }>{ p.user.name }</a>
<span class="username">@{ p.user.username }</span>
- <a class="time" href={ url }>
+ <a class="time" href={ '/' + p.user.username + '/' + p.id }>
<mk-time time={ p.created_at }/>
</a>
</header>
@@ -46,16 +43,18 @@
</div>
<footer>
<mk-reactions-viewer post={ p }/>
- <button onclick={ reply } title="返信"><i class="fa fa-reply"></i>
- <p class="count" if={ p.replies_count > 0 }>{ p.replies_count }</p>
+ <button onclick={ reply } title="返信">
+ <i class="fa fa-reply"></i><p class="count" if={ p.replies_count > 0 }>{ p.replies_count }</p>
+ </button>
+ <button onclick={ repost } title="Repost">
+ <i class="fa fa-retweet"></i><p class="count" if={ p.repost_count > 0 }>{ p.repost_count }</p>
</button>
- <button onclick={ repost } title="Repost"><i class="fa fa-retweet"></i>
- <p class="count" if={ p.repost_count > 0 }>{ p.repost_count }</p>
+ <button class={ reacted: p.my_reaction != null } onclick={ react } ref="reactButton" title="リアクション">
+ <i class="fa fa-plus"></i><p class="count" if={ p.reactions_count > 0 }>{ p.reactions_count }</p>
</button>
- <button class={ reacted: p.my_reaction != null } onclick={ react } ref="reactButton" title="リアクション"><i class="fa fa-plus"></i>
- <p class="count" if={ p.reactions_count > 0 }>{ p.reactions_count }</p>
+ <button onclick={ menu } ref="menuButton">
+ <i class="fa fa-ellipsis-h"></i>
</button>
- <button><i class="fa fa-ellipsis-h"></i></button>
</footer>
</article>
<div class="replies">
@@ -71,13 +70,11 @@
padding 0
width 640px
overflow hidden
+ text-align left
background #fff
border solid 1px rgba(0, 0, 0, 0.1)
border-radius 8px
- > .fetching
- padding 64px 0
-
> .main
> .read-more
@@ -262,56 +259,41 @@
this.mixin('api');
this.mixin('user-preview');
- this.fetching = true;
this.contextFetching = false;
this.context = null;
- this.post = null;
+ this.post = this.opts.post;
+ this.isRepost = this.post.repost != null;
+ this.p = this.isRepost ? this.post.repost : this.post;
+ this.p.reactions_count = this.p.reaction_counts ? Object.keys(this.p.reaction_counts).map(key => this.p.reaction_counts[key]).reduce((a, b) => a + b) : 0;
+ this.title = dateStringify(this.p.created_at);
this.on('mount', () => {
- this.api('posts/show', {
- post_id: this.opts.post
- }).then(post => {
- const isRepost = post.repost != null;
- const p = isRepost ? post.repost : post;
- p.reactions_count = p.reaction_counts ? Object.keys(p.reaction_counts).map(key => p.reaction_counts[key]).reduce((a, b) => a + b) : 0;
+ if (this.p.text) {
+ const tokens = this.p.ast;
- this.update({
- fetching: false,
- post: post,
- isRepost: isRepost,
- p: p,
- title: dateStringify(p.created_at)
- });
+ this.refs.text.innerHTML = compile(tokens);
- this.trigger('loaded');
-
- if (this.p.text) {
- const tokens = this.p.ast;
-
- this.refs.text.innerHTML = compile(tokens);
-
- this.refs.text.children.forEach(e => {
- if (e.tagName == 'MK-URL') riot.mount(e);
- });
+ this.refs.text.children.forEach(e => {
+ if (e.tagName == 'MK-URL') riot.mount(e);
+ });
- // URLをプレビュー
- tokens
- .filter(t => (t.type == 'url' || t.type == 'link') && !t.silent)
- .map(t => {
- riot.mount(this.refs.text.appendChild(document.createElement('mk-url-preview')), {
- url: t.url
- });
+ // URLをプレビュー
+ tokens
+ .filter(t => (t.type == 'url' || t.type == 'link') && !t.silent)
+ .map(t => {
+ riot.mount(this.refs.text.appendChild(document.createElement('mk-url-preview')), {
+ url: t.url
});
- }
+ });
+ }
- // Get replies
- this.api('posts/replies', {
- post_id: this.p.id,
- limit: 8
- }).then(replies => {
- this.update({
- replies: replies
- });
+ // Get replies
+ this.api('posts/replies', {
+ post_id: this.p.id,
+ limit: 8
+ }).then(replies => {
+ this.update({
+ replies: replies
});
});
});
@@ -335,12 +317,19 @@
});
};
+ this.menu = () => {
+ riot.mount(document.body.appendChild(document.createElement('mk-post-menu')), {
+ source: this.refs.menuButton,
+ post: this.p
+ });
+ };
+
this.loadContext = () => {
this.contextFetching = true;
// Fetch context
this.api('posts/context', {
- post_id: this.p.reply_to_id
+ post_id: this.p.reply_id
}).then(context => {
this.update({
contextFetching: false,
diff --git a/src/web/app/desktop/tags/post-form.tag b/src/web/app/desktop/tags/post-form.tag
index 6a363d67cd..5041078bee 100644
--- a/src/web/app/desktop/tags/post-form.tag
+++ b/src/web/app/desktop/tags/post-form.tag
@@ -475,7 +475,7 @@
this.api('posts/create', {
text: this.refs.text.value == '' ? undefined : this.refs.text.value,
media_ids: files,
- reply_to_id: this.inReplyToPost ? this.inReplyToPost.id : undefined,
+ reply_id: this.inReplyToPost ? this.inReplyToPost.id : undefined,
repost_id: this.repost ? this.repost.id : undefined,
poll: this.poll ? this.refs.poll.get() : undefined
}).then(data => {
diff --git a/src/web/app/desktop/tags/settings.tag b/src/web/app/desktop/tags/settings.tag
index a89cfda0e4..eabddfb432 100644
--- a/src/web/app/desktop/tags/settings.tag
+++ b/src/web/app/desktop/tags/settings.tag
@@ -7,7 +7,7 @@
<p class={ active: page == 'apps' } onmousedown={ setPage.bind(null, 'apps') }><i class="fa fa-fw fa-puzzle-piece"></i>アプリ</p>
<p class={ active: page == 'twitter' } onmousedown={ setPage.bind(null, 'twitter') }><i class="fa fa-fw fa-twitter"></i>Twitter</p>
<p class={ active: page == 'signin' } onmousedown={ setPage.bind(null, 'signin') }><i class="fa fa-fw fa-sign-in"></i>ログイン履歴</p>
- <p class={ active: page == 'password' } onmousedown={ setPage.bind(null, 'password') }><i class="fa fa-fw fa-unlock-alt"></i>パスワード</p>
+ <p class={ active: page == 'password' } onmousedown={ setPage.bind(null, 'password') }><i class="fa fa-fw fa-unlock-alt"></i>%i18n:desktop.tags.mk-settings.password%</p>
<p class={ active: page == 'api' } onmousedown={ setPage.bind(null, 'api') }><i class="fa fa-fw fa-key"></i>API</p>
</div>
<div class="pages">
@@ -58,6 +58,11 @@
<mk-signin-history/>
</section>
+ <section class="password" show={ page == 'password' }>
+ <h1>%i18n:desktop.tags.mk-settings.password%</h1>
+ <mk-password-setting/>
+ </section>
+
<section class="api" show={ page == 'api' }>
<h1>API</h1>
<mk-api-info/>
@@ -211,3 +216,71 @@
};
</script>
</mk-settings>
+
+<mk-api-info>
+ <p>Token:<code>{ I.token }</code></p>
+ <p>APIを利用するには、上記のトークンを「i」というキーでパラメータに付加してリクエストします。</p>
+ <p>アカウントを乗っ取られてしまう可能性があるため、このトークンは第三者に教えないでください(アプリなどにも入力しないでください)。</p>
+ <p>万が一このトークンが漏れたりその可能性がある場合は<a class="regenerate" onclick={ regenerateToken }>トークンを再生成</a>できます。(副作用として、ログインしているすべてのデバイスでログアウトが発生します)</p>
+ <style>
+ :scope
+ display block
+ color #4a535a
+
+ code
+ padding 4px
+ background #eee
+ </style>
+ <script>
+ import passwordDialog from '../scripts/password-dialog';
+
+ this.mixin('i');
+ this.mixin('api');
+
+ this.regenerateToken = () => {
+ passwordDialog('%i18n:desktop.tags.mk-api-info.regenerate-token%', password => {
+ this.api('i/regenerate_token', {
+ password: password
+ });
+ });
+ };
+ </script>
+</mk-api-info>
+
+<mk-password-setting>
+ <button onclick={ reset }>%i18n:desktop.tags.mk-password-setting.reset%</button>
+ <style>
+ :scope
+ display block
+ color #4a535a
+ </style>
+ <script>
+ import passwordDialog from '../scripts/password-dialog';
+ import dialog from '../scripts/dialog';
+ import notify from '../scripts/notify';
+
+ this.mixin('i');
+ this.mixin('api');
+
+ this.reset = () => {
+ passwordDialog('%i18n:desktop.tags.mk-password-setting.enter-current-password%', currentPassword => {
+ passwordDialog('%i18n:desktop.tags.mk-password-setting.enter-new-password%', newPassword => {
+ passwordDialog('%i18n:desktop.tags.mk-password-setting.enter-new-password-again%', newPassword2 => {
+ if (newPassword !== newPassword2) {
+ dialog(null, '%i18n:desktop.tags.mk-password-setting.not-match%', [{
+ text: 'OK'
+ }]);
+ return;
+ }
+ this.api('i/change_password', {
+ current_password: currentPassword,
+ new_password: newPassword
+ }).then(() => {
+ notify('%i18n:desktop.tags.mk-password-setting.changed%');
+ });
+ });
+ });
+ });
+ };
+ </script>
+</mk-password-setting>
diff --git a/src/web/app/desktop/tags/sub-post-content.tag b/src/web/app/desktop/tags/sub-post-content.tag
index 02cb5251b2..c75ae2911c 100644
--- a/src/web/app/desktop/tags/sub-post-content.tag
+++ b/src/web/app/desktop/tags/sub-post-content.tag
@@ -1,6 +1,6 @@
<mk-sub-post-content>
<div class="body">
- <a class="reply" if={ post.reply_to_id }>
+ <a class="reply" if={ post.reply_id }>
<i class="fa fa-reply"></i>
</a>
<span ref="text"></span>
diff --git a/src/web/app/desktop/tags/timeline-post-sub.tag b/src/web/app/desktop/tags/timeline-post-sub.tag
deleted file mode 100644
index ab1e26721b..0000000000
--- a/src/web/app/desktop/tags/timeline-post-sub.tag
+++ /dev/null
@@ -1,107 +0,0 @@
-<mk-timeline-post-sub title={ title }>
- <article>
- <a class="avatar-anchor" href={ '/' + post.user.username }>
- <img class="avatar" src={ post.user.avatar_url + '?thumbnail&size=64' } alt="avatar" data-user-preview={ post.user_id }/>
- </a>
- <div class="main">
- <header>
- <a class="name" href={ '/' + post.user.username } data-user-preview={ post.user_id }>{ post.user.name }</a>
- <span class="username">@{ post.user.username }</span>
- <a class="created-at" href={ '/' + post.user.username + '/' + post.id }>
- <mk-time time={ post.created_at }/>
- </a>
- </header>
- <div class="body">
- <mk-sub-post-content class="text" post={ post }/>
- </div>
- </div>
- </article>
- <style>
- :scope
- display block
- margin 0
- padding 0
- font-size 0.9em
-
- > article
- padding 16px
-
- &: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 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
-
- > .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>
- <script>
- import dateStringify from '../../common/scripts/date-stringify';
-
- this.mixin('user-preview');
-
- this.post = this.opts.post;
- this.title = dateStringify(this.post.created_at);
- </script>
-</mk-timeline-post-sub>
diff --git a/src/web/app/desktop/tags/timeline-post.tag b/src/web/app/desktop/tags/timeline-post.tag
deleted file mode 100644
index 150b928dfd..0000000000
--- a/src/web/app/desktop/tags/timeline-post.tag
+++ /dev/null
@@ -1,487 +0,0 @@
-<mk-timeline-post tabindex="-1" title={ title } onkeydown={ onKeyDown }>
- <div class="reply-to" if={ p.reply_to }>
- <mk-timeline-post-sub post={ p.reply_to }/>
- </div>
- <div class="repost" if={ isRepost }>
- <p>
- <a class="avatar-anchor" href={ '/' + post.user.username } data-user-preview={ post.user_id }>
- <img class="avatar" src={ post.user.avatar_url + '?thumbnail&size=32' } alt="avatar"/>
- </a>
- <i class="fa fa-retweet"></i>{'%i18n:desktop.tags.mk-timeline-post.reposted-by%'.substr(0, '%i18n:desktop.tags.mk-timeline-post.reposted-by%'.indexOf('{'))}<a class="name" href={ '/' + post.user.username } data-user-preview={ post.user_id }>{ post.user.name }</a>{'%i18n:desktop.tags.mk-timeline-post.reposted-by%'.substr('%i18n:desktop.tags.mk-timeline-post.reposted-by%'.indexOf('}') + 1)}
- </p>
- <mk-time time={ post.created_at }/>
- </div>
- <article>
- <a class="avatar-anchor" href={ '/' + p.user.username }>
- <img class="avatar" src={ p.user.avatar_url + '?thumbnail&size=64' } alt="avatar" data-user-preview={ p.user.id }/>
- </a>
- <div class="main">
- <header>
- <a class="name" href={ '/' + p.user.username } data-user-preview={ p.user.id }>{ p.user.name }</a>
- <span class="is-bot" if={ p.user.is_bot }>bot</span>
- <span class="username">@{ p.user.username }</span>
- <div class="info">
- <span class="app" if={ p.app }>via <b>{ p.app.name }</b></span>
- <a class="created-at" href={ url }>
- <mk-time time={ p.created_at }/>
- </a>
- </div>
- </header>
- <div class="body">
- <div class="text" ref="text">
- <a class="reply" if={ p.reply_to }>
- <i class="fa fa-reply"></i>
- </a>
- <p class="dummy"></p>
- <a class="quote" if={ p.repost != null }>RP:</a>
- </div>
- <div class="media" if={ p.media }>
- <mk-images-viewer images={ p.media }/>
- </div>
- <mk-poll if={ p.poll } post={ p } ref="pollViewer"/>
- <div class="repost" if={ p.repost }><i class="fa fa-quote-right fa-flip-horizontal"></i>
- <mk-post-preview class="repost" post={ p.repost }/>
- </div>
- </div>
- <footer>
- <mk-reactions-viewer post={ p } ref="reactionsViewer"/>
- <button onclick={ reply } title="%i18n:desktop.tags.mk-timeline-post.reply%"><i class="fa fa-reply"></i>
- <p class="count" if={ p.replies_count > 0 }>{ p.replies_count }</p>
- </button>
- <button onclick={ repost } title="%i18n:desktop.tags.mk-timeline-post.repost%"><i class="fa fa-retweet"></i>
- <p class="count" if={ p.repost_count > 0 }>{ p.repost_count }</p>
- </button>
- <button class={ reacted: p.my_reaction != null } onclick={ react } ref="reactButton" title="%i18n:desktop.tags.mk-timeline-post.add-reaction%"><i class="fa fa-plus"></i>
- <p class="count" if={ p.reactions_count > 0 }>{ p.reactions_count }</p>
- </button>
- <button>
- <i class="fa fa-ellipsis-h"></i>
- </button>
- <button onclick={ toggleDetail } title="%i18n:desktop.tags.mk-timeline-post.detail">
- <i class="fa fa-caret-down" if={ !isDetailOpened }></i>
- <i class="fa fa-caret-up" if={ isDetailOpened }></i>
- </button>
- </footer>
- </div>
- </article>
- <div class="detail" if={ isDetailOpened }>
- <mk-post-status-graph width="462" height="130" post={ p }/>
- </div>
- <style>
- :scope
- display block
- margin 0
- padding 0
- background #fff
-
- &: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
-
- i
- 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
- margin-bottom 4px
- white-space nowrap
- line-height 1.4
-
- > .name
- display block
- margin 0 .5em 0 0
- padding 0
- overflow hidden
- color #777
- font-size 1em
- font-weight 700
- text-align left
- text-decoration none
- text-overflow ellipsis
-
- &:hover
- text-decoration underline
-
- > .is-bot
- text-align left
- margin 0 .5em 0 0
- padding 1px 6px
- font-size 12px
- color #aaa
- border solid 1px #ddd
- border-radius 3px
-
- > .username
- text-align left
- margin 0 .5em 0 0
- color #ccc
-
- > .info
- margin-left auto
- text-align right
- font-size 0.9em
-
- > .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
-
- > .dummy
- display none
-
- mk-url-preview
- margin-top 8px
-
- .link
- &:after
- content "\f14c"
- display inline-block
- padding-left 2px
- font-family FontAwesome
- font-size .9em
- font-weight 400
- font-style normal
-
- > .reply
- margin-right 8px
- color #717171
-
- > .quote
- margin-left 4px
- font-style oblique
- color #a0bf46
-
- 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
-
- > .media
- > img
- display block
- max-width 100%
-
- > mk-poll
- font-size 80%
-
- > .repost
- margin 8px 0
-
- > i:first-child
- position absolute
- top -8px
- left -8px
- z-index 1
- color #c0dac6
- font-size 28px
- background #fff
-
- > 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>
- <script>
- import compile from '../../common/scripts/text-compiler';
- import dateStringify from '../../common/scripts/date-stringify';
-
- this.mixin('api');
- this.mixin('stream');
- this.mixin('user-preview');
-
- this.isDetailOpened = false;
-
- this.set = post => {
- this.post = post;
- this.isRepost = this.post.repost && this.post.text == null && this.post.media_ids == null && this.post.poll == null;
- this.p = this.isRepost ? this.post.repost : this.post;
- this.p.reactions_count = this.p.reaction_counts ? Object.keys(this.p.reaction_counts).map(key => this.p.reaction_counts[key]).reduce((a, b) => a + b) : 0;
- this.title = dateStringify(this.p.created_at);
- this.url = `/${this.p.user.username}/${this.p.id}`;
- };
-
- this.set(this.opts.post);
-
- this.refresh = post => {
- this.set(post);
- this.update();
- if (this.refs.reactionsViewer) this.refs.reactionsViewer.update({
- post
- });
- if (this.refs.pollViewer) this.refs.pollViewer.init(post);
- };
-
- this.onStreamPostUpdated = data => {
- const post = data.post;
- if (post.id == this.post.id) {
- this.refresh(post);
- }
- };
-
- this.onStreamConnected = () => {
- this.capture();
- };
-
- this.capture = withHandler => {
- this.stream.send({
- type: 'capture',
- id: this.post.id
- });
- if (withHandler) this.stream.on('post-updated', this.onStreamPostUpdated);
- };
-
- this.decapture = withHandler => {
- this.stream.send({
- type: 'decapture',
- id: this.post.id
- });
- if (withHandler) this.stream.off('post-updated', this.onStreamPostUpdated);
- };
-
- this.on('mount', () => {
- this.capture(true);
- this.stream.on('_connected_', this.onStreamConnected);
-
- if (this.p.text) {
- const tokens = this.p.ast;
-
- this.refs.text.innerHTML = this.refs.text.innerHTML.replace('<p class="dummy"></p>', compile(tokens));
-
- this.refs.text.children.forEach(e => {
- if (e.tagName == 'MK-URL') riot.mount(e);
- });
-
- // URLをプレビュー
- tokens
- .filter(t => (t.type == 'url' || t.type == 'link') && !t.silent)
- .map(t => {
- riot.mount(this.refs.text.appendChild(document.createElement('mk-url-preview')), {
- url: t.url
- });
- });
- }
- });
-
- this.on('unmount', () => {
- this.decapture(true);
- this.stream.off('_connected_', this.onStreamConnected);
- });
-
- this.reply = () => {
- riot.mount(document.body.appendChild(document.createElement('mk-post-form-window')), {
- reply: this.p
- });
- };
-
- this.repost = () => {
- riot.mount(document.body.appendChild(document.createElement('mk-repost-form-window')), {
- post: this.p
- });
- };
-
- this.react = () => {
- riot.mount(document.body.appendChild(document.createElement('mk-reaction-picker')), {
- source: this.refs.reactButton,
- post: this.p
- });
- };
-
- this.toggleDetail = () => {
- this.update({
- isDetailOpened: !this.isDetailOpened
- });
- };
-
- this.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.root, e => e.previousElementSibling);
- break;
-
- case e.which == 40: // [↓]
- case e.which == 75: // [k]
- case e.which == 9: // [Tab]
- focus(this.root, 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();
- };
-
- function focus(el, fn) {
- const target = fn(el);
- if (target) {
- if (target.hasAttribute('tabindex')) {
- target.focus();
- } else {
- focus(target, fn);
- }
- }
- }
- </script>
-</mk-timeline-post>
diff --git a/src/web/app/desktop/tags/timeline.tag b/src/web/app/desktop/tags/timeline.tag
index d4cd50455c..44f3d5d8ec 100644
--- a/src/web/app/desktop/tags/timeline.tag
+++ b/src/web/app/desktop/tags/timeline.tag
@@ -10,16 +10,6 @@
:scope
display block
- > mk-timeline-post
- border-bottom solid 1px #eaeaea
-
- &:first-child
- border-top-left-radius 6px
- border-top-right-radius 6px
-
- &:last-of-type
- border-bottom none
-
> .date
display block
margin 0
@@ -90,3 +80,636 @@
</script>
</mk-timeline>
+
+<mk-timeline-post tabindex="-1" title={ title } onkeydown={ onKeyDown } dblclick={ onDblClick }>
+ <div class="reply-to" if={ p.reply }>
+ <mk-timeline-post-sub post={ p.reply }/>
+ </div>
+ <div class="repost" if={ isRepost }>
+ <p>
+ <a class="avatar-anchor" href={ '/' + post.user.username } data-user-preview={ post.user_id }>
+ <img class="avatar" src={ post.user.avatar_url + '?thumbnail&size=32' } alt="avatar"/>
+ </a>
+ <i class="fa fa-retweet"></i>{'%i18n:desktop.tags.mk-timeline-post.reposted-by%'.substr(0, '%i18n:desktop.tags.mk-timeline-post.reposted-by%'.indexOf('{'))}<a class="name" href={ '/' + post.user.username } data-user-preview={ post.user_id }>{ post.user.name }</a>{'%i18n:desktop.tags.mk-timeline-post.reposted-by%'.substr('%i18n:desktop.tags.mk-timeline-post.reposted-by%'.indexOf('}') + 1)}
+ </p>
+ <mk-time time={ post.created_at }/>
+ </div>
+ <article>
+ <a class="avatar-anchor" href={ '/' + p.user.username }>
+ <img class="avatar" src={ p.user.avatar_url + '?thumbnail&size=64' } alt="avatar" data-user-preview={ p.user.id }/>
+ </a>
+ <div class="main">
+ <header>
+ <a class="name" href={ '/' + p.user.username } data-user-preview={ p.user.id }>{ p.user.name }</a>
+ <span class="is-bot" if={ p.user.is_bot }>bot</span>
+ <span class="username">@{ p.user.username }</span>
+ <div class="info">
+ <span class="app" if={ p.app }>via <b>{ p.app.name }</b></span>
+ <a class="created-at" href={ url }>
+ <mk-time time={ p.created_at }/>
+ </a>
+ </div>
+ </header>
+ <div class="body">
+ <div class="text" ref="text">
+ <p class="channel" if={ p.channel != null }><a href={ CONFIG.chUrl + '/' + p.channel.id } target="_blank">{ p.channel.title }</a>:</p>
+ <a class="reply" if={ p.reply }>
+ <i class="fa fa-reply"></i>
+ </a>
+ <p class="dummy"></p>
+ <a class="quote" if={ p.repost != null }>RP:</a>
+ </div>
+ <div class="media" if={ p.media }>
+ <mk-images-viewer images={ p.media }/>
+ </div>
+ <mk-poll if={ p.poll } post={ p } ref="pollViewer"/>
+ <div class="repost" if={ p.repost }><i class="fa fa-quote-right fa-flip-horizontal"></i>
+ <mk-post-preview class="repost" post={ p.repost }/>
+ </div>
+ </div>
+ <footer>
+ <mk-reactions-viewer post={ p } ref="reactionsViewer"/>
+ <button onclick={ reply } title="%i18n:desktop.tags.mk-timeline-post.reply%">
+ <i class="fa fa-reply"></i><p class="count" if={ p.replies_count > 0 }>{ p.replies_count }</p>
+ </button>
+ <button onclick={ repost } title="%i18n:desktop.tags.mk-timeline-post.repost%">
+ <i class="fa fa-retweet"></i><p class="count" if={ p.repost_count > 0 }>{ p.repost_count }</p>
+ </button>
+ <button class={ reacted: p.my_reaction != null } onclick={ react } ref="reactButton" title="%i18n:desktop.tags.mk-timeline-post.add-reaction%">
+ <i class="fa fa-plus"></i><p class="count" if={ p.reactions_count > 0 }>{ p.reactions_count }</p>
+ </button>
+ <button onclick={ menu } ref="menuButton">
+ <i class="fa fa-ellipsis-h"></i>
+ </button>
+ <button onclick={ toggleDetail } title="%i18n:desktop.tags.mk-timeline-post.detail">
+ <i class="fa fa-caret-down" if={ !isDetailOpened }></i>
+ <i class="fa fa-caret-up" if={ isDetailOpened }></i>
+ </button>
+ </footer>
+ </div>
+ </article>
+ <div class="detail" if={ isDetailOpened }>
+ <mk-post-status-graph width="462" height="130" post={ p }/>
+ </div>
+ <style>
+ :scope
+ display block
+ 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
+
+ i
+ 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
+ margin-bottom 4px
+ white-space nowrap
+ line-height 1.4
+
+ > .name
+ display block
+ margin 0 .5em 0 0
+ padding 0
+ overflow hidden
+ color #777
+ font-size 1em
+ font-weight 700
+ text-align left
+ text-decoration none
+ text-overflow ellipsis
+
+ &:hover
+ text-decoration underline
+
+ > .is-bot
+ text-align left
+ margin 0 .5em 0 0
+ padding 1px 6px
+ font-size 12px
+ color #aaa
+ border solid 1px #ddd
+ border-radius 3px
+
+ > .username
+ text-align left
+ margin 0 .5em 0 0
+ color #ccc
+
+ > .info
+ margin-left auto
+ text-align right
+ font-size 0.9em
+
+ > .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
+
+ > .dummy
+ display none
+
+ mk-url-preview
+ margin-top 8px
+
+ .link
+ &:after
+ content "\f14c"
+ display inline-block
+ padding-left 2px
+ font-family FontAwesome
+ font-size .9em
+ font-weight 400
+ font-style normal
+
+ > .channel
+ margin 0
+
+ > .reply
+ margin-right 8px
+ color #717171
+
+ > .quote
+ margin-left 4px
+ font-style oblique
+ color #a0bf46
+
+ 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
+
+ > .media
+ > img
+ display block
+ max-width 100%
+
+ > mk-poll
+ font-size 80%
+
+ > .repost
+ margin 8px 0
+
+ > i:first-child
+ position absolute
+ top -8px
+ left -8px
+ z-index 1
+ color #c0dac6
+ font-size 28px
+ background #fff
+
+ > 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>
+ <script>
+ import compile from '../../common/scripts/text-compiler';
+ import dateStringify from '../../common/scripts/date-stringify';
+
+ this.mixin('i');
+ this.mixin('api');
+ this.mixin('stream');
+ this.mixin('user-preview');
+
+ this.isDetailOpened = false;
+
+ this.set = post => {
+ this.post = post;
+ this.isRepost = this.post.repost && this.post.text == null && this.post.media_ids == null && this.post.poll == null;
+ this.p = this.isRepost ? this.post.repost : this.post;
+ this.p.reactions_count = this.p.reaction_counts ? Object.keys(this.p.reaction_counts).map(key => this.p.reaction_counts[key]).reduce((a, b) => a + b) : 0;
+ this.title = dateStringify(this.p.created_at);
+ this.url = `/${this.p.user.username}/${this.p.id}`;
+ };
+
+ this.set(this.opts.post);
+
+ this.refresh = post => {
+ this.set(post);
+ this.update();
+ if (this.refs.reactionsViewer) this.refs.reactionsViewer.update({
+ post
+ });
+ if (this.refs.pollViewer) this.refs.pollViewer.init(post);
+ };
+
+ this.onStreamPostUpdated = data => {
+ const post = data.post;
+ if (post.id == this.post.id) {
+ this.refresh(post);
+ }
+ };
+
+ this.onStreamConnected = () => {
+ this.capture();
+ };
+
+ this.capture = withHandler => {
+ if (this.SIGNIN) {
+ this.stream.send({
+ type: 'capture',
+ id: this.post.id
+ });
+ if (withHandler) this.stream.on('post-updated', this.onStreamPostUpdated);
+ }
+ };
+
+ this.decapture = withHandler => {
+ if (this.SIGNIN) {
+ this.stream.send({
+ type: 'decapture',
+ id: this.post.id
+ });
+ if (withHandler) this.stream.off('post-updated', this.onStreamPostUpdated);
+ }
+ };
+
+ this.on('mount', () => {
+ this.capture(true);
+
+ if (this.SIGNIN) {
+ this.stream.on('_connected_', this.onStreamConnected);
+ }
+
+ if (this.p.text) {
+ const tokens = this.p.ast;
+
+ this.refs.text.innerHTML = this.refs.text.innerHTML.replace('<p class="dummy"></p>', compile(tokens));
+
+ this.refs.text.children.forEach(e => {
+ if (e.tagName == 'MK-URL') riot.mount(e);
+ });
+
+ // URLをプレビュー
+ tokens
+ .filter(t => (t.type == 'url' || t.type == 'link') && !t.silent)
+ .map(t => {
+ riot.mount(this.refs.text.appendChild(document.createElement('mk-url-preview')), {
+ url: t.url
+ });
+ });
+ }
+ });
+
+ this.on('unmount', () => {
+ this.decapture(true);
+ this.stream.off('_connected_', this.onStreamConnected);
+ });
+
+ this.reply = () => {
+ riot.mount(document.body.appendChild(document.createElement('mk-post-form-window')), {
+ reply: this.p
+ });
+ };
+
+ this.repost = () => {
+ riot.mount(document.body.appendChild(document.createElement('mk-repost-form-window')), {
+ post: this.p
+ });
+ };
+
+ this.react = () => {
+ riot.mount(document.body.appendChild(document.createElement('mk-reaction-picker')), {
+ source: this.refs.reactButton,
+ post: this.p
+ });
+ };
+
+ this.menu = () => {
+ riot.mount(document.body.appendChild(document.createElement('mk-post-menu')), {
+ source: this.refs.menuButton,
+ post: this.p
+ });
+ };
+
+ this.toggleDetail = () => {
+ this.update({
+ isDetailOpened: !this.isDetailOpened
+ });
+ };
+
+ this.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.root, e => e.previousElementSibling);
+ break;
+
+ case e.which == 40: // [↓]
+ case e.which == 75: // [k]
+ case e.which == 9: // [Tab]
+ focus(this.root, 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();
+ };
+
+ this.onDblClick = () => {
+ riot.mount(document.body.appendChild(document.createElement('mk-detailed-post-window')), {
+ post: this.p.id
+ });
+ };
+
+ function focus(el, fn) {
+ const target = fn(el);
+ if (target) {
+ if (target.hasAttribute('tabindex')) {
+ target.focus();
+ } else {
+ focus(target, fn);
+ }
+ }
+ }
+ </script>
+</mk-timeline-post>
+
+<mk-timeline-post-sub title={ title }>
+ <article>
+ <a class="avatar-anchor" href={ '/' + post.user.username }>
+ <img class="avatar" src={ post.user.avatar_url + '?thumbnail&size=64' } alt="avatar" data-user-preview={ post.user_id }/>
+ </a>
+ <div class="main">
+ <header>
+ <a class="name" href={ '/' + post.user.username } data-user-preview={ post.user_id }>{ post.user.name }</a>
+ <span class="username">@{ post.user.username }</span>
+ <a class="created-at" href={ '/' + post.user.username + '/' + post.id }>
+ <mk-time time={ post.created_at }/>
+ </a>
+ </header>
+ <div class="body">
+ <mk-sub-post-content class="text" post={ post }/>
+ </div>
+ </div>
+ </article>
+ <style>
+ :scope
+ display block
+ margin 0
+ padding 0
+ font-size 0.9em
+
+ > article
+ padding 16px
+
+ &: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 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
+
+ > .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>
+ <script>
+ import dateStringify from '../../common/scripts/date-stringify';
+
+ this.mixin('user-preview');
+
+ this.post = this.opts.post;
+ this.title = dateStringify(this.post.created_at);
+ </script>
+</mk-timeline-post-sub>
diff --git a/src/web/app/desktop/tags/ui-header-account.tag b/src/web/app/desktop/tags/ui-header-account.tag
deleted file mode 100644
index 23c4fdbbf9..0000000000
--- a/src/web/app/desktop/tags/ui-header-account.tag
+++ /dev/null
@@ -1,214 +0,0 @@
-<mk-ui-header-account>
- <button class="header" data-active={ isOpen.toString() } onclick={ toggle }>
- <span class="username">{ I.username }<i class="fa fa-angle-down" if={ !isOpen }></i><i class="fa fa-angle-up" if={ isOpen }></i></span>
- <img class="avatar" src={ I.avatar_url + '?thumbnail&size=64' } alt="avatar"/>
- </button>
- <div class="menu" if={ isOpen }>
- <ul>
- <li>
- <a href={ '/' + I.username }><i class="fa fa-user"></i>%i18n:desktop.tags.mk-ui-header-account.profile%<i class="fa fa-angle-right"></i></a>
- </li>
- <li onclick={ drive }>
- <p><i class="fa fa-cloud"></i>%i18n:desktop.tags.mk-ui-header-account.drive%<i class="fa fa-angle-right"></i></p>
- </li>
- <li>
- <a href="/i>mentions"><i class="fa fa-at"></i>%i18n:desktop.tags.mk-ui-header-account.mentions%<i class="fa fa-angle-right"></i></a>
- </li>
- </ul>
- <ul>
- <li onclick={ settings }>
- <p><i class="fa fa-cog"></i>%i18n:desktop.tags.mk-ui-header-account.settings%<i class="fa fa-angle-right"></i></p>
- </li>
- </ul>
- <ul>
- <li onclick={ signout }>
- <p><i class="fa fa-power-off"></i>%i18n:desktop.tags.mk-ui-header-account.signout%<i class="fa fa-angle-right"></i></p>
- </li>
- </ul>
- </div>
- <style>
- :scope
- display block
- float left
-
- > .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
-
- i
- 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
-
- > i:first-of-type
- margin-right 6px
-
- > i: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
-
- </style>
- <script>
- import contains from '../../common/scripts/contains';
- import signout from '../../common/scripts/signout';
- this.signout = signout;
-
- this.mixin('i');
-
- this.isOpen = false;
-
- this.on('before-unmount', () => {
- this.close();
- });
-
- this.toggle = () => {
- this.isOpen ? this.close() : this.open();
- };
-
- this.open = () => {
- this.update({
- isOpen: true
- });
- document.querySelectorAll('body *').forEach(el => {
- el.addEventListener('mousedown', this.mousedown);
- });
- };
-
- this.close = () => {
- this.update({
- isOpen: false
- });
- document.querySelectorAll('body *').forEach(el => {
- el.removeEventListener('mousedown', this.mousedown);
- });
- };
-
- this.mousedown = e => {
- e.preventDefault();
- if (!contains(this.root, e.target) && this.root != e.target) this.close();
- return false;
- };
-
- this.drive = () => {
- this.close();
- riot.mount(document.body.appendChild(document.createElement('mk-drive-browser-window')));
- };
-
- this.settings = () => {
- this.close();
- riot.mount(document.body.appendChild(document.createElement('mk-settings-window')));
- };
-
- </script>
-</mk-ui-header-account>
diff --git a/src/web/app/desktop/tags/ui-header-clock.tag b/src/web/app/desktop/tags/ui-header-clock.tag
deleted file mode 100644
index b8cb078497..0000000000
--- a/src/web/app/desktop/tags/ui-header-clock.tag
+++ /dev/null
@@ -1,86 +0,0 @@
-<mk-ui-header-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>
- <style>
- :scope
- 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>
- <script>
- this.now = new Date();
-
- this.draw = () => {
- const now = this.now = new Date();
- this.yyyy = now.getFullYear();
- this.mm = ('0' + (now.getMonth() + 1)).slice(-2);
- this.dd = ('0' + now.getDate()).slice(-2);
- this.hh = ('0' + now.getHours()).slice(-2);
- this.nn = ('0' + now.getMinutes()).slice(-2);
- this.update();
- };
-
- this.on('mount', () => {
- this.draw();
- this.clock = setInterval(this.draw, 1000);
- });
-
- this.on('unmount', () => {
- clearInterval(this.clock);
- });
- </script>
-</mk-ui-header-clock>
diff --git a/src/web/app/desktop/tags/ui-header-nav.tag b/src/web/app/desktop/tags/ui-header-nav.tag
deleted file mode 100644
index c36ce65798..0000000000
--- a/src/web/app/desktop/tags/ui-header-nav.tag
+++ /dev/null
@@ -1,133 +0,0 @@
-<mk-ui-header-nav>
- <ul if={ SIGNIN }>
- <li class="home { active: page == 'home' }">
- <a href={ CONFIG.url }>
- <i class="fa fa-home"></i>
- <p>%i18n:desktop.tags.mk-ui-header-nav.home%</p>
- </a>
- </li>
- <li class="messaging">
- <a onclick={ messaging }>
- <i class="fa fa-comments"></i>
- <p>%i18n:desktop.tags.mk-ui-header-nav.messaging%</p>
- <i class="fa fa-circle" if={ hasUnreadMessagingMessages }></i>
- </a>
- </li>
- <li class="info">
- <a href="https://twitter.com/misskey_xyz" target="_blank">
- <i class="fa fa-info"></i>
- <p>%i18n:desktop.tags.mk-ui-header-nav.info%</p>
- </a>
- </li>
- </ul>
- <style>
- :scope
- 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
-
- > i:first-child
- margin-right 8px
-
- > i:last-child
- margin-left 5px
- vertical-align super
- 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>
- <script>
- this.mixin('i');
- this.mixin('api');
- this.mixin('stream');
-
- this.page = this.opts.page;
-
- this.on('mount', () => {
- this.stream.on('read_all_messaging_messages', this.onReadAllMessagingMessages);
- this.stream.on('unread_messaging_message', this.onUnreadMessagingMessage);
-
- // Fetch count of unread messaging messages
- this.api('messaging/unread').then(res => {
- if (res.count > 0) {
- this.update({
- hasUnreadMessagingMessages: true
- });
- }
- });
- });
-
- this.on('unmount', () => {
- this.stream.off('read_all_messaging_messages', this.onReadAllMessagingMessages);
- this.stream.off('unread_messaging_message', this.onUnreadMessagingMessage);
- });
-
- this.onReadAllMessagingMessages = () => {
- this.update({
- hasUnreadMessagingMessages: false
- });
- };
-
- this.onUnreadMessagingMessage = () => {
- this.update({
- hasUnreadMessagingMessages: true
- });
- };
-
- this.messaging = () => {
- riot.mount(document.body.appendChild(document.createElement('mk-messaging-window')));
- };
- </script>
-</mk-ui-header-nav>
diff --git a/src/web/app/desktop/tags/ui-header-notifications.tag b/src/web/app/desktop/tags/ui-header-notifications.tag
deleted file mode 100644
index 3cd8d1e3df..0000000000
--- a/src/web/app/desktop/tags/ui-header-notifications.tag
+++ /dev/null
@@ -1,108 +0,0 @@
-<mk-ui-header-notifications>
- <button class="header" data-active={ isOpen } onclick={ toggle }><i class="fa fa-bell-o"></i></button>
- <div class="notifications" if={ isOpen }>
- <mk-notifications/>
- </div>
- <style>
- :scope
- display block
- float left
-
- > .header
- 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%)
-
- > i
- font-size 1.2em
- line-height 48px
-
- > .notifications
- 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>
- <script>
- import contains from '../../common/scripts/contains';
-
- this.isOpen = false;
-
- this.toggle = () => {
- this.isOpen ? this.close() : this.open();
- };
-
- this.open = () => {
- this.update({
- isOpen: true
- });
- document.querySelectorAll('body *').forEach(el => {
- el.addEventListener('mousedown', this.mousedown);
- });
- };
-
- this.close = () => {
- this.update({
- isOpen: false
- });
- document.querySelectorAll('body *').forEach(el => {
- el.removeEventListener('mousedown', this.mousedown);
- });
- };
-
- this.mousedown = e => {
- e.preventDefault();
- if (!contains(this.root, e.target) && this.root != e.target) this.close();
- return false;
- };
- </script>
-</mk-ui-header-notifications>
diff --git a/src/web/app/desktop/tags/ui-header-post-button.tag b/src/web/app/desktop/tags/ui-header-post-button.tag
deleted file mode 100644
index ca380b06ea..0000000000
--- a/src/web/app/desktop/tags/ui-header-post-button.tag
+++ /dev/null
@@ -1,42 +0,0 @@
-<mk-ui-header-post-button>
- <button onclick={ post } title="新規投稿"><i class="fa fa-pencil-square-o"></i></button>
- <style>
- :scope
- 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 2px
- 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>
- <script>
- this.post = e => {
- this.parent.parent.openPostForm();
- };
- </script>
-</mk-ui-header-post-button>
diff --git a/src/web/app/desktop/tags/ui-header-search.tag b/src/web/app/desktop/tags/ui-header-search.tag
deleted file mode 100644
index 616476f42c..0000000000
--- a/src/web/app/desktop/tags/ui-header-search.tag
+++ /dev/null
@@ -1,42 +0,0 @@
-<mk-ui-header-search>
- <form class="search" onsubmit={ onsubmit }>
- <input ref="q" type="search" placeholder="&#xf002; %i18n:desktop.tags.mk-ui-header-search.placeholder%"/>
- <div class="result"></div>
- </form>
- <style>
- :scope
-
- > form
- display block
- float left
-
- > input
- user-select text
- cursor auto
- margin 0
- padding 6px 18px
- width 14em
- height 48px
- font-size 1em
- line-height calc(48px - 12px)
- background transparent
- outline none
- //border solid 1px #ddd
- border none
- border-radius 0
- transition color 0.5s ease, border 0.5s ease
- font-family FontAwesome, sans-serif
-
- &::-webkit-input-placeholder
- color #9eaba8
-
- </style>
- <script>
- this.mixin('page');
-
- this.onsubmit = e => {
- e.preventDefault();
- this.page('/search:' + this.refs.q.value);
- };
- </script>
-</mk-ui-header-search>
diff --git a/src/web/app/desktop/tags/ui-header.tag b/src/web/app/desktop/tags/ui-header.tag
deleted file mode 100644
index fa7f2cb2ac..0000000000
--- a/src/web/app/desktop/tags/ui-header.tag
+++ /dev/null
@@ -1,86 +0,0 @@
-<mk-ui-header>
- <mk-donation if={ SIGNIN && I.data.no_donation != 'true' }/>
- <mk-special-message/>
- <div class="main">
- <div class="backdrop"></div>
- <div class="main">
- <div class="container">
- <div class="left">
- <mk-ui-header-nav page={ opts.page }/>
- </div>
- <div class="right">
- <mk-ui-header-search/>
- <mk-ui-header-account if={ SIGNIN }/>
- <mk-ui-header-notifications if={ SIGNIN }/>
- <mk-ui-header-post-button if={ SIGNIN }/>
- <mk-ui-header-clock/>
- </div>
- </div>
- </div>
- </div>
- <style>
- :scope
- display block
- position -webkit-sticky
- position sticky
- top 0
- z-index 1024
- width 100%
- box-shadow 0 1px 1px rgba(0, 0, 0, 0.075)
-
- > .main
-
- > .backdrop
- position absolute
- top 0
- z-index 1023
- width 100%
- height 48px
- backdrop-filter blur(12px)
- //background-color rgba(255, 255, 255, 0.75)
- background #fff
-
- &:after
- content ""
- 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
-
- > .main
- z-index 1024
- margin 0
- padding 0
- background-clip content-box
- font-size 0.9rem
- user-select none
-
- > .container
- width 100%
- max-width 1300px
- margin 0 auto
-
- &:after
- content ""
- display block
- clear both
-
- > .left
- float left
- height 3rem
-
- > .right
- float right
- height 48px
-
- @media (max-width 1100px)
- > mk-ui-header-search
- display none
-
- </style>
- <script>this.mixin('i');</script>
-</mk-ui-header>
diff --git a/src/web/app/desktop/tags/ui-notification.tag b/src/web/app/desktop/tags/ui-notification.tag
deleted file mode 100644
index f39d766d8c..0000000000
--- a/src/web/app/desktop/tags/ui-notification.tag
+++ /dev/null
@@ -1,51 +0,0 @@
-<mk-ui-notification>
- <p>{ opts.message }</p>
- <style>
- :scope
- 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>
- <script>
- import anime from 'animejs';
-
- this.on('mount', () => {
- anime({
- targets: this.root,
- opacity: 1,
- translateY: [-64, 0],
- easing: 'easeOutElastic',
- duration: 500
- });
-
- setTimeout(() => {
- anime({
- targets: this.root,
- opacity: 0,
- translateY: -64,
- duration: 500,
- easing: 'easeInElastic',
- complete: () => this.unmount()
- });
- }, 6000);
- });
- </script>
-</mk-ui-notification>
diff --git a/src/web/app/desktop/tags/ui.tag b/src/web/app/desktop/tags/ui.tag
index 788fb56131..3123c34f4f 100644
--- a/src/web/app/desktop/tags/ui.tag
+++ b/src/web/app/desktop/tags/ui.tag
@@ -5,7 +5,7 @@
<div class="content">
<yield />
</div>
- <mk-stream-indicator/>
+ <mk-stream-indicator if={ SIGNIN }/>
<style>
:scope
display block
@@ -35,3 +35,785 @@
};
</script>
</mk-ui>
+
+<mk-ui-header>
+ <mk-donation if={ SIGNIN && I.data.no_donation != 'true' }/>
+ <mk-special-message/>
+ <div class="main">
+ <div class="backdrop"></div>
+ <div class="main">
+ <div class="container">
+ <div class="left">
+ <mk-ui-header-nav page={ opts.page }/>
+ </div>
+ <div class="right">
+ <mk-ui-header-search/>
+ <mk-ui-header-account if={ SIGNIN }/>
+ <mk-ui-header-notifications if={ SIGNIN }/>
+ <mk-ui-header-post-button if={ SIGNIN }/>
+ <mk-ui-header-clock/>
+ </div>
+ </div>
+ </div>
+ </div>
+ <style>
+ :scope
+ display block
+ position -webkit-sticky
+ position sticky
+ top 0
+ z-index 1024
+ width 100%
+ box-shadow 0 1px 1px rgba(0, 0, 0, 0.075)
+
+ > .main
+
+ > .backdrop
+ position absolute
+ top 0
+ z-index 1023
+ width 100%
+ height 48px
+ backdrop-filter blur(12px)
+ //background-color rgba(255, 255, 255, 0.75)
+ background #1d2429
+
+ &:after
+ content ""
+ 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
+
+ > .main
+ z-index 1024
+ margin 0
+ padding 0
+ background-clip content-box
+ font-size 0.9rem
+ user-select none
+
+ > .container
+ width 100%
+ max-width 1300px
+ margin 0 auto
+
+ &:after
+ content ""
+ display block
+ clear both
+
+ > .left
+ float left
+ height 3rem
+
+ > .right
+ float right
+ height 48px
+
+ @media (max-width 1100px)
+ > mk-ui-header-search
+ display none
+
+ </style>
+ <script>this.mixin('i');</script>
+</mk-ui-header>
+
+<mk-ui-header-search>
+ <form class="search" onsubmit={ onsubmit }>
+ <input ref="q" type="search" placeholder="&#xf002; %i18n:desktop.tags.mk-ui-header-search.placeholder%"/>
+ <div class="result"></div>
+ </form>
+ <style>
+ :scope
+
+ > form
+ display block
+ float left
+
+ > input
+ user-select text
+ cursor auto
+ margin 0
+ padding 6px 18px
+ width 14em
+ height 48px
+ font-size 1em
+ line-height calc(48px - 12px)
+ background transparent
+ outline none
+ //border solid 1px #ddd
+ border none
+ border-radius 0
+ transition color 0.5s ease, border 0.5s ease
+ font-family FontAwesome, sans-serif
+
+ &::-webkit-input-placeholder
+ color #9eaba8
+
+ </style>
+ <script>
+ this.mixin('page');
+
+ this.onsubmit = e => {
+ e.preventDefault();
+ this.page('/search:' + this.refs.q.value);
+ };
+ </script>
+</mk-ui-header-search>
+
+<mk-ui-header-post-button>
+ <button onclick={ post } title="新規投稿"><i class="fa fa-pencil-square-o"></i></button>
+ <style>
+ :scope
+ 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 2px
+ 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>
+ <script>
+ this.post = e => {
+ this.parent.parent.openPostForm();
+ };
+ </script>
+</mk-ui-header-post-button>
+
+<mk-ui-header-notifications>
+ <button class="header" data-active={ isOpen } onclick={ toggle }><i class="fa fa-bell-o"></i></button>
+ <div class="notifications" if={ isOpen }>
+ <mk-notifications/>
+ </div>
+ <style>
+ :scope
+ display block
+ float left
+
+ > .header
+ 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%)
+
+ > i
+ font-size 1.2em
+ line-height 48px
+
+ > .notifications
+ 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>
+ <script>
+ import contains from '../../common/scripts/contains';
+
+ this.isOpen = false;
+
+ this.toggle = () => {
+ this.isOpen ? this.close() : this.open();
+ };
+
+ this.open = () => {
+ this.update({
+ isOpen: true
+ });
+ document.querySelectorAll('body *').forEach(el => {
+ el.addEventListener('mousedown', this.mousedown);
+ });
+ };
+
+ this.close = () => {
+ this.update({
+ isOpen: false
+ });
+ document.querySelectorAll('body *').forEach(el => {
+ el.removeEventListener('mousedown', this.mousedown);
+ });
+ };
+
+ this.mousedown = e => {
+ e.preventDefault();
+ if (!contains(this.root, e.target) && this.root != e.target) this.close();
+ return false;
+ };
+ </script>
+</mk-ui-header-notifications>
+
+<mk-ui-header-nav>
+ <ul>
+ <virtual if={ SIGNIN }>
+ <li class="home { active: page == 'home' }">
+ <a href={ CONFIG.url }>
+ <i class="fa fa-home"></i>
+ <p>%i18n:desktop.tags.mk-ui-header-nav.home%</p>
+ </a>
+ </li>
+ <li class="messaging">
+ <a onclick={ messaging }>
+ <i class="fa fa-comments"></i>
+ <p>%i18n:desktop.tags.mk-ui-header-nav.messaging%</p>
+ <i class="fa fa-circle" if={ hasUnreadMessagingMessages }></i>
+ </a>
+ </li>
+ </virtual>
+ <li class="ch">
+ <a href={ CONFIG.chUrl } target="_blank">
+ <i class="fa fa-television"></i>
+ <p>%i18n:desktop.tags.mk-ui-header-nav.ch%</p>
+ </a>
+ </li>
+ <li class="info">
+ <a href="https://twitter.com/misskey_xyz" target="_blank">
+ <i class="fa fa-info"></i>
+ <p>%i18n:desktop.tags.mk-ui-header-nav.info%</p>
+ </a>
+ </li>
+ </ul>
+ <style>
+ :scope
+ 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
+
+ > i:first-child
+ margin-right 8px
+
+ > i:last-child
+ margin-left 5px
+ vertical-align super
+ 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>
+ <script>
+ this.mixin('i');
+ this.mixin('api');
+ this.mixin('stream');
+
+ this.page = this.opts.page;
+
+ this.on('mount', () => {
+ if (this.SIGNIN) {
+ this.stream.on('read_all_messaging_messages', this.onReadAllMessagingMessages);
+ this.stream.on('unread_messaging_message', this.onUnreadMessagingMessage);
+
+ // Fetch count of unread messaging messages
+ this.api('messaging/unread').then(res => {
+ if (res.count > 0) {
+ this.update({
+ hasUnreadMessagingMessages: true
+ });
+ }
+ });
+ }
+ });
+
+ this.on('unmount', () => {
+ if (this.SIGNIN) {
+ this.stream.off('read_all_messaging_messages', this.onReadAllMessagingMessages);
+ this.stream.off('unread_messaging_message', this.onUnreadMessagingMessage);
+ }
+ });
+
+ this.onReadAllMessagingMessages = () => {
+ this.update({
+ hasUnreadMessagingMessages: false
+ });
+ };
+
+ this.onUnreadMessagingMessage = () => {
+ this.update({
+ hasUnreadMessagingMessages: true
+ });
+ };
+
+ this.messaging = () => {
+ riot.mount(document.body.appendChild(document.createElement('mk-messaging-window')));
+ };
+ </script>
+</mk-ui-header-nav>
+
+<mk-ui-header-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>
+ <style>
+ :scope
+ 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>
+ <script>
+ this.now = new Date();
+
+ this.draw = () => {
+ const now = this.now = new Date();
+ this.yyyy = now.getFullYear();
+ this.mm = ('0' + (now.getMonth() + 1)).slice(-2);
+ this.dd = ('0' + now.getDate()).slice(-2);
+ this.hh = ('0' + now.getHours()).slice(-2);
+ this.nn = ('0' + now.getMinutes()).slice(-2);
+ this.update();
+ };
+
+ this.on('mount', () => {
+ this.draw();
+ this.clock = setInterval(this.draw, 1000);
+ });
+
+ this.on('unmount', () => {
+ clearInterval(this.clock);
+ });
+ </script>
+</mk-ui-header-clock>
+
+<mk-ui-header-account>
+ <button class="header" data-active={ isOpen.toString() } onclick={ toggle }>
+ <span class="username">{ I.username }<i class="fa fa-angle-down" if={ !isOpen }></i><i class="fa fa-angle-up" if={ isOpen }></i></span>
+ <img class="avatar" src={ I.avatar_url + '?thumbnail&size=64' } alt="avatar"/>
+ </button>
+ <div class="menu" if={ isOpen }>
+ <ul>
+ <li>
+ <a href={ '/' + I.username }><i class="fa fa-user"></i>%i18n:desktop.tags.mk-ui-header-account.profile%<i class="fa fa-angle-right"></i></a>
+ </li>
+ <li onclick={ drive }>
+ <p><i class="fa fa-cloud"></i>%i18n:desktop.tags.mk-ui-header-account.drive%<i class="fa fa-angle-right"></i></p>
+ </li>
+ <li>
+ <a href="/i>mentions"><i class="fa fa-at"></i>%i18n:desktop.tags.mk-ui-header-account.mentions%<i class="fa fa-angle-right"></i></a>
+ </li>
+ </ul>
+ <ul>
+ <li onclick={ settings }>
+ <p><i class="fa fa-cog"></i>%i18n:desktop.tags.mk-ui-header-account.settings%<i class="fa fa-angle-right"></i></p>
+ </li>
+ </ul>
+ <ul>
+ <li onclick={ signout }>
+ <p><i class="fa fa-power-off"></i>%i18n:desktop.tags.mk-ui-header-account.signout%<i class="fa fa-angle-right"></i></p>
+ </li>
+ </ul>
+ </div>
+ <style>
+ :scope
+ display block
+ float left
+
+ > .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
+
+ i
+ 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
+
+ > i:first-of-type
+ margin-right 6px
+
+ > i: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
+
+ </style>
+ <script>
+ import contains from '../../common/scripts/contains';
+ import signout from '../../common/scripts/signout';
+ this.signout = signout;
+
+ this.mixin('i');
+
+ this.isOpen = false;
+
+ this.on('before-unmount', () => {
+ this.close();
+ });
+
+ this.toggle = () => {
+ this.isOpen ? this.close() : this.open();
+ };
+
+ this.open = () => {
+ this.update({
+ isOpen: true
+ });
+ document.querySelectorAll('body *').forEach(el => {
+ el.addEventListener('mousedown', this.mousedown);
+ });
+ };
+
+ this.close = () => {
+ this.update({
+ isOpen: false
+ });
+ document.querySelectorAll('body *').forEach(el => {
+ el.removeEventListener('mousedown', this.mousedown);
+ });
+ };
+
+ this.mousedown = e => {
+ e.preventDefault();
+ if (!contains(this.root, e.target) && this.root != e.target) this.close();
+ return false;
+ };
+
+ this.drive = () => {
+ this.close();
+ riot.mount(document.body.appendChild(document.createElement('mk-drive-browser-window')));
+ };
+
+ this.settings = () => {
+ this.close();
+ riot.mount(document.body.appendChild(document.createElement('mk-settings-window')));
+ };
+
+ </script>
+</mk-ui-header-account>
+
+<mk-ui-notification>
+ <p>{ opts.message }</p>
+ <style>
+ :scope
+ 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>
+ <script>
+ import anime from 'animejs';
+
+ this.on('mount', () => {
+ anime({
+ targets: this.root,
+ opacity: 1,
+ translateY: [-64, 0],
+ easing: 'easeOutElastic',
+ duration: 500
+ });
+
+ setTimeout(() => {
+ anime({
+ targets: this.root,
+ opacity: 0,
+ translateY: -64,
+ duration: 500,
+ easing: 'easeInElastic',
+ complete: () => this.unmount()
+ });
+ }, 6000);
+ });
+ </script>
+</mk-ui-notification>
diff --git a/src/web/app/dev/style.styl b/src/web/app/dev/style.styl
index 4fd537709d..cdbcb0e261 100644
--- a/src/web/app/dev/style.styl
+++ b/src/web/app/dev/style.styl
@@ -1,4 +1,5 @@
-@import "../base"
+@import "../app"
+@import "../reset"
html
background-color #fff
diff --git a/src/web/app/init.js b/src/web/app/init.js
index 44391b8fcb..5a6899ed4f 100644
--- a/src/web/app/init.js
+++ b/src/web/app/init.js
@@ -2,7 +2,7 @@
* App initializer
*/
-"use strict";
+'use strict';
import * as riot from 'riot';
import api from './common/scripts/api';
@@ -19,7 +19,20 @@ require('./common/tags');
* APP ENTRY POINT!
*/
-console.info(`Misskey v${VERSION}`);
+console.info(`Misskey v${VERSION} (葵 aoi)`);
+
+{ // Set lang attr
+ const html = document.documentElement;
+ html.setAttribute('lang', LANG);
+}
+
+{ // Set description meta tag
+ const head = document.getElementsByTagName('head')[0];
+ const meta = document.createElement('meta');
+ meta.setAttribute('name', 'description');
+ meta.setAttribute('content', '%i18n:common.misskey%');
+ head.appendChild(meta);
+}
document.domain = CONFIG.host;
diff --git a/src/web/app/mobile/router.js b/src/web/app/mobile/router.js
index d0b45d9614..01eb3c8145 100644
--- a/src/web/app/mobile/router.js
+++ b/src/web/app/mobile/router.js
@@ -8,6 +8,7 @@ let page = null;
export default me => {
route('/', index);
+ route('/selectdrive', selectDrive);
route('/i/notifications', notifications);
route('/i/messaging', messaging);
route('/i/messaging/:username', messaging);
@@ -15,6 +16,7 @@ export default me => {
route('/i/drive/folder/:folder', drive);
route('/i/drive/file/:file', drive);
route('/i/settings', settings);
+ route('/i/settings/profile', settingsProfile);
route('/i/settings/signin-history', settingsSignin);
route('/i/settings/api', settingsApi);
route('/i/settings/twitter', settingsTwitter);
@@ -22,7 +24,7 @@ export default me => {
route('/post/new', newPost);
route('/post::post', post);
route('/search::query', search);
- route('/:user', user.bind(null, 'posts'));
+ route('/:user', user.bind(null, 'overview'));
route('/:user/graphs', user.bind(null, 'graphs'));
route('/:user/followers', userFollowers);
route('/:user/following', userFollowing);
@@ -63,6 +65,10 @@ export default me => {
mount(document.createElement('mk-settings-page'));
}
+ function settingsProfile() {
+ mount(document.createElement('mk-profile-setting-page'));
+ }
+
function settingsSignin() {
mount(document.createElement('mk-signin-history-page'));
}
@@ -117,6 +123,10 @@ export default me => {
mount(el);
}
+ function selectDrive() {
+ mount(document.createElement('mk-selectdrive-page'));
+ }
+
function notFound() {
mount(document.createElement('mk-not-found'));
}
@@ -130,6 +140,7 @@ export default me => {
};
function mount(content) {
+ document.documentElement.style.background = '#fff';
if (page) page.unmount();
const body = document.getElementById('app');
page = riot.mount(body.appendChild(content))[0];
diff --git a/src/web/app/mobile/style.styl b/src/web/app/mobile/style.styl
index bd6965e402..63e4f2349f 100644
--- a/src/web/app/mobile/style.styl
+++ b/src/web/app/mobile/style.styl
@@ -1,4 +1,5 @@
-@import "../base"
+@import "../app"
+@import "../reset"
#wait
top auto
diff --git a/src/web/app/mobile/tags/drive-selector.tag b/src/web/app/mobile/tags/drive-selector.tag
index 32845432f2..2edae67c1b 100644
--- a/src/web/app/mobile/tags/drive-selector.tag
+++ b/src/web/app/mobile/tags/drive-selector.tag
@@ -3,7 +3,7 @@
<header>
<h1>%i18n:mobile.tags.mk-drive-selector.select-file%<span class="count" if={ files.length > 0 }>({ files.length })</span></h1>
<button class="close" onclick={ cancel }><i class="fa fa-times"></i></button>
- <button class="ok" onclick={ ok }><i class="fa fa-check"></i></button>
+ <button if={ opts.multiple } class="ok" onclick={ ok }><i class="fa fa-check"></i></button>
</header>
<mk-drive ref="browser" select-file={ true } multiple={ opts.multiple }/>
</div>
@@ -68,6 +68,11 @@
files: files
});
});
+
+ this.refs.browser.on('selected', file => {
+ this.trigger('selected', file);
+ this.unmount();
+ });
});
this.cancel = () => {
diff --git a/src/web/app/mobile/tags/drive.tag b/src/web/app/mobile/tags/drive.tag
index e19325091d..6929c50ab1 100644
--- a/src/web/app/mobile/tags/drive.tag
+++ b/src/web/app/mobile/tags/drive.tag
@@ -1,5 +1,5 @@
<mk-drive>
- <nav>
+ <nav ref="nav">
<p onclick={ goRoot }><i class="fa fa-cloud"></i>%i18n:mobile.tags.mk-drive.drive%</p>
<virtual each={ folder in hierarchyFolders }>
<span><i class="fa fa-angle-right"></i></span>
@@ -56,10 +56,6 @@
display block
background #fff
- &[data-is-naked]
- > nav
- top 48px
-
> nav
display block
position sticky
@@ -190,7 +186,7 @@
this.file = null;
this.isFileSelectMode = this.opts.selectFile;
- this.multiple =this.opts.multiple;
+ this.multiple = this.opts.multiple;
this.on('mount', () => {
this.stream.on('drive_file_created', this.onStreamDriveFileCreated);
@@ -205,6 +201,10 @@
} else {
this.fetch();
}
+
+ if (this.opts.isNaked) {
+ this.refs.nav.style.top = `${this.opts.top}px`;
+ }
});
this.on('unmount', () => {
@@ -435,13 +435,17 @@
this.chooseFile = file => {
if (this.isFileSelectMode) {
- if (this.selectedFiles.some(f => f.id == file.id)) {
- this.selectedFiles = this.selectedFiles.filter(f => f.id != file.id);
+ 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.update();
+ this.trigger('change-selection', this.selectedFiles);
} else {
- this.selectedFiles.push(file);
+ this.trigger('selected', file);
}
- this.update();
- this.trigger('change-selection', this.selectedFiles);
} else {
this.cf(file);
}
@@ -479,7 +483,7 @@
if (fn == null || fn == '') return;
switch (fn) {
case '1':
- this.refs.file.click();
+ this.selectLocalFile();
break;
case '2':
this.urlUpload();
@@ -499,6 +503,10 @@
}
};
+ this.selectLocalFile = () => {
+ this.refs.file.click();
+ };
+
this.createFolder = () => {
const name = window.prompt('フォルダー名');
if (name == null || name == '') return;
diff --git a/src/web/app/mobile/tags/home-timeline.tag b/src/web/app/mobile/tags/home-timeline.tag
index 5d5399f322..051158597d 100644
--- a/src/web/app/mobile/tags/home-timeline.tag
+++ b/src/web/app/mobile/tags/home-timeline.tag
@@ -6,7 +6,7 @@
display block
> mk-init-following
- border-bottom solid 1px #eee
+ margin-bottom 8px
</style>
<script>
@@ -23,6 +23,12 @@
});
});
+ this.fetch = () => {
+ this.api('posts/timeline').then(posts => {
+ this.refs.timeline.setPosts(posts);
+ });
+ };
+
this.on('mount', () => {
this.stream.on('post', this.onStreamPost);
this.stream.on('follow', this.onStreamFollow);
diff --git a/src/web/app/mobile/tags/home.tag b/src/web/app/mobile/tags/home.tag
index 48b5a67c38..d92e3ae4e5 100644
--- a/src/web/app/mobile/tags/home.tag
+++ b/src/web/app/mobile/tags/home.tag
@@ -7,6 +7,7 @@
> mk-home-timeline
max-width 600px
margin 0 auto
+ padding 8px
@media (min-width 500px)
padding 16px
diff --git a/src/web/app/mobile/tags/index.js b/src/web/app/mobile/tags/index.js
index 02d1541fcd..19952c20cd 100644
--- a/src/web/app/mobile/tags/index.js
+++ b/src/web/app/mobile/tags/index.js
@@ -1,6 +1,4 @@
require('./ui.tag');
-require('./ui-header.tag');
-require('./ui-nav.tag');
require('./page/entrance.tag');
require('./page/entrance/signin.tag');
require('./page/entrance/signup.tag');
@@ -14,17 +12,17 @@ require('./page/post.tag');
require('./page/new-post.tag');
require('./page/search.tag');
require('./page/settings.tag');
+require('./page/settings/profile.tag');
require('./page/settings/signin.tag');
require('./page/settings/api.tag');
require('./page/settings/authorized-apps.tag');
require('./page/settings/twitter.tag');
require('./page/messaging.tag');
require('./page/messaging-room.tag');
+require('./page/selectdrive.tag');
require('./home.tag');
require('./home-timeline.tag');
require('./timeline.tag');
-require('./timeline-post.tag');
-require('./timeline-post-sub.tag');
require('./post-preview.tag');
require('./sub-post-content.tag');
require('./images-viewer.tag');
@@ -50,3 +48,4 @@ require('./users-list.tag');
require('./user-following.tag');
require('./user-followers.tag');
require('./init-following.tag');
+require('./user-card.tag');
diff --git a/src/web/app/mobile/tags/init-following.tag b/src/web/app/mobile/tags/init-following.tag
index 0c54d3a6a1..6357f86a29 100644
--- a/src/web/app/mobile/tags/init-following.tag
+++ b/src/web/app/mobile/tags/init-following.tag
@@ -1,12 +1,9 @@
<mk-init-following>
<p class="title">気になるユーザーをフォロー:</p>
<div class="users" if={ !fetching && users.length > 0 }>
- <div class="user" each={ users }><a class="avatar-anchor" href={ '/' + username }><img class="avatar" src={ avatar_url + '?thumbnail&size=42' } alt=""/></a>
- <div class="body"><a class="name" href={ '/' + username } target="_blank">{ name }</a>
- <p class="username">@{ username }</p>
- </div>
- <mk-follow-button user={ this }/>
- </div>
+ <virtual each={ users }>
+ <mk-user-card user={ this } />
+ </virtual>
</div>
<p class="empty" if={ !fetching && users.length == 0 }>おすすめのユーザーは見つかりませんでした。</p>
<p class="fetching" if={ fetching }><i class="fa fa-spinner fa-pulse fa-fw"></i>読み込んでいます<mk-ellipsis/></p>
@@ -15,63 +12,27 @@
<style>
:scope
display block
- padding 16px
+ background #fff
+ border-radius 8px
+ box-shadow 0 0 0 1px rgba(0, 0, 0, 0.2)
> .title
- margin 0 0 12px 0
+ margin 0
+ padding 8px 16px
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
+ overflow-x scroll
+ -webkit-overflow-scrolling touch
+ white-space nowrap
+ padding 16px
+ background #eee
- > mk-follow-button
- position absolute
- top 16px
- right 16px
+ > mk-user-card
+ &:not(:last-child)
+ margin-right 16px
> .empty
margin 0
@@ -90,7 +51,8 @@
> .refresh
display block
- margin 0 8px 0 0
+ margin 0
+ padding 8px 16px
text-align right
font-size 0.9em
color #999
@@ -117,7 +79,7 @@
color #222
> i
- padding 14px
+ padding 10px
</style>
<script>
diff --git a/src/web/app/mobile/tags/notification-preview.tag b/src/web/app/mobile/tags/notification-preview.tag
index 077ae78463..1fdcc57641 100644
--- a/src/web/app/mobile/tags/notification-preview.tag
+++ b/src/web/app/mobile/tags/notification-preview.tag
@@ -110,7 +110,7 @@
</style>
<script>
- import getPostSummary from '../../common/scripts/get-post-summary';
+ import getPostSummary from '../../../../common/get-post-summary.ts';
this.getPostSummary = getPostSummary;
this.notification = this.opts.notification;
</script>
diff --git a/src/web/app/mobile/tags/notification.tag b/src/web/app/mobile/tags/notification.tag
index 3663709525..53222b9dbe 100644
--- a/src/web/app/mobile/tags/notification.tag
+++ b/src/web/app/mobile/tags/notification.tag
@@ -163,7 +163,7 @@
</style>
<script>
- import getPostSummary from '../../common/scripts/get-post-summary';
+ import getPostSummary from '../../../../common/get-post-summary.ts';
this.getPostSummary = getPostSummary;
this.notification = this.opts.notification;
</script>
diff --git a/src/web/app/mobile/tags/notifications.tag b/src/web/app/mobile/tags/notifications.tag
index 21a941e630..2e95990314 100644
--- a/src/web/app/mobile/tags/notifications.tag
+++ b/src/web/app/mobile/tags/notifications.tag
@@ -1,9 +1,7 @@
<mk-notifications>
<div class="notifications" if={ notifications.length != 0 }>
<virtual each={ notification, i in notifications }>
- <div>
- <mk-notification notification={ notification }/>
- </div>
+ <mk-notification notification={ notification }/>
<p class="date" if={ i != notifications.length - 1 && notification._date != notifications[i + 1]._date }><span><i class="fa fa-angle-up"></i>{ notification._datetext }</span><span><i class="fa fa-angle-down"></i>{ notifications[i + 1]._datetext }</span></p>
</virtual>
</div>
@@ -15,20 +13,28 @@
<style>
:scope
display block
+ 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
- > div
+ > mk-notification
+ margin 0 auto
+ max-width 500px
border-bottom solid 1px rgba(0, 0, 0, 0.05)
&:last-child
border-bottom none
- > mk-notification
- margin 0 auto
- max-width 500px
-
> .date
display block
margin 0
@@ -72,7 +78,7 @@
</style>
<script>
- import getPostSummary from '../../common/scripts/get-post-summary';
+ import getPostSummary from '../../../../common/get-post-summary.ts';
this.getPostSummary = getPostSummary;
this.mixin('api');
@@ -117,6 +123,12 @@
});
this.onNotification = notification => {
+ // TODO: ユーザーが画面を見てないと思われるとき(ブラウザやタブがアクティブじゃないなど)は送信しない
+ this.stream.send({
+ type: 'read_notification',
+ id: notification.id
+ });
+
this.notifications.unshift(notification);
this.update();
};
diff --git a/src/web/app/mobile/tags/page/drive.tag b/src/web/app/mobile/tags/page/drive.tag
index 1169e3b9eb..218960c702 100644
--- a/src/web/app/mobile/tags/page/drive.tag
+++ b/src/web/app/mobile/tags/page/drive.tag
@@ -1,6 +1,6 @@
<mk-drive-page>
<mk-ui ref="ui">
- <mk-drive ref="browser" folder={ parent.opts.folder } file={ parent.opts.file } data-is-naked="true"/>
+ <mk-drive ref="browser" folder={ parent.opts.folder } file={ parent.opts.file } is-naked={ true } top={ 48 }/>
</mk-ui>
<style>
:scope
diff --git a/src/web/app/mobile/tags/page/home.tag b/src/web/app/mobile/tags/page/home.tag
index 32c80fd20e..3b0255b293 100644
--- a/src/web/app/mobile/tags/page/home.tag
+++ b/src/web/app/mobile/tags/page/home.tag
@@ -9,7 +9,7 @@
<script>
import ui from '../../scripts/ui-event';
import Progress from '../../../common/scripts/loading';
- import getPostSummary from '../../../common/scripts/get-post-summary';
+ import getPostSummary from '../../../../../common/get-post-summary.ts';
import openPostForm from '../../scripts/open-post-form';
this.mixin('i');
@@ -20,6 +20,7 @@
this.on('mount', () => {
document.title = 'Misskey'
ui.trigger('title', '<i class="fa fa-home"></i>%i18n:mobile.tags.mk-home.home%');
+ document.documentElement.style.background = '#313a42';
ui.trigger('func', () => {
openPostForm();
diff --git a/src/web/app/mobile/tags/page/notifications.tag b/src/web/app/mobile/tags/page/notifications.tag
index f90cd1628d..743de04393 100644
--- a/src/web/app/mobile/tags/page/notifications.tag
+++ b/src/web/app/mobile/tags/page/notifications.tag
@@ -10,9 +10,16 @@
import ui from '../../scripts/ui-event';
import Progress from '../../../common/scripts/loading';
+ this.mixin('api');
+
this.on('mount', () => {
document.title = 'Misskey | %i18n:mobile.tags.mk-notifications-page.notifications%';
ui.trigger('title', '<i class="fa fa-bell-o"></i>%i18n:mobile.tags.mk-notifications-page.notifications%');
+ document.documentElement.style.background = '#313a42';
+
+ ui.trigger('func', () => {
+ this.readAll();
+ }, 'check');
Progress.start();
@@ -20,5 +27,13 @@
Progress.done();
});
});
+
+ this.readAll = () => {
+ const ok = window.confirm('%i18n:mobile.tags.mk-notifications-page.read-all%');
+
+ if (!ok) return;
+
+ this.api('notifications/mark_as_read_all');
+ };
</script>
</mk-notifications-page>
diff --git a/src/web/app/mobile/tags/page/post.tag b/src/web/app/mobile/tags/page/post.tag
index 7ab4ea2714..6888229f89 100644
--- a/src/web/app/mobile/tags/page/post.tag
+++ b/src/web/app/mobile/tags/page/post.tag
@@ -1,7 +1,11 @@
<mk-post-page>
<mk-ui ref="ui">
- <main>
- <mk-post-detail ref="post" post={ parent.post }/>
+ <main if={ !parent.fetching }>
+ <a if={ parent.post.next } href={ parent.post.next }><i class="fa fa-angle-up"></i>%i18n:mobile.tags.mk-post-page.next%</a>
+ <div>
+ <mk-post-detail ref="post" post={ parent.post }/>
+ </div>
+ <a if={ parent.post.prev } href={ parent.post.prev }><i class="fa fa-angle-down"></i>%i18n:mobile.tags.mk-post-page.prev%</a>
</main>
</mk-ui>
<style>
@@ -9,31 +13,61 @@
display block
main
- background #fff
+ text-align center
- > mk-post-detail
- width 100%
+ > div
+ margin 8px auto
+ padding 0
max-width 500px
- margin 0 auto
+ 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
+
+ > i
+ margin-right 4px
</style>
<script>
import ui from '../../scripts/ui-event';
import Progress from '../../../common/scripts/loading';
- this.post = this.opts.post;
+ this.mixin('api');
+
+ this.fetching = true;
+ this.post = null;
this.on('mount', () => {
document.title = 'Misskey';
- ui.trigger('title', '<i class="fa fa-sticky-note-o"></i>%i18n:mobile.tags.mk-post-page.submit%');
+ ui.trigger('title', '<i class="fa fa-sticky-note-o"></i>%i18n:mobile.tags.mk-post-page.title%');
+ document.documentElement.style.background = '#313a42';
Progress.start();
- this.refs.ui.refs.post.on('post-fetched', () => {
- Progress.set(0.5);
- });
+ this.api('posts/show', {
+ post_id: this.opts.post
+ }).then(post => {
+
+ this.update({
+ fetching: false,
+ post: post
+ });
- this.refs.ui.refs.post.on('loaded', () => {
Progress.done();
});
});
diff --git a/src/web/app/mobile/tags/page/search.tag b/src/web/app/mobile/tags/page/search.tag
index 869d5c8533..a66f07971a 100644
--- a/src/web/app/mobile/tags/page/search.tag
+++ b/src/web/app/mobile/tags/page/search.tag
@@ -14,6 +14,7 @@
document.title = `%i18n:mobile.tags.mk-search-page.search%: ${this.opts.query} | Misskey`
// TODO: クエリをHTMLエスケープ
ui.trigger('title', '<i class="fa fa-search"></i>' + this.opts.query);
+ document.documentElement.style.background = '#313a42';
Progress.start();
diff --git a/src/web/app/mobile/tags/page/selectdrive.tag b/src/web/app/mobile/tags/page/selectdrive.tag
new file mode 100644
index 0000000000..79ea3548f8
--- /dev/null
+++ b/src/web/app/mobile/tags/page/selectdrive.tag
@@ -0,0 +1,87 @@
+<mk-selectdrive-page>
+ <header>
+ <h1>%i18n:mobile.tags.mk-selectdrive-page.select-file%<span class="count" if={ files.length > 0 }>({ files.length })</span></h1>
+ <button class="upload" onclick={ upload }><i class="fa fa-upload"></i></button>
+ <button if={ multiple } class="ok" onclick={ ok }><i class="fa fa-check"></i></button>
+ </header>
+ <mk-drive ref="browser" select-file={ true } multiple={ multiple } is-naked={ true } top={ 42 }/>
+
+ <style>
+ :scope
+ display block
+ width 100%
+ height 100%
+ background #fff
+
+ > header
+ position fixed
+ top 0
+ left 0
+ width 100%
+ z-index 1000
+ background #fff
+ box-shadow 0 1px rgba(0, 0, 0, 0.1)
+
+ > h1
+ margin 0
+ padding 0
+ text-align center
+ line-height 42px
+ font-size 1em
+ font-weight normal
+
+ > .count
+ margin-left 4px
+ opacity 0.5
+
+ > .upload
+ position absolute
+ top 0
+ left 0
+ line-height 42px
+ width 42px
+
+ > .ok
+ position absolute
+ top 0
+ right 0
+ line-height 42px
+ width 42px
+
+ > mk-drive
+ top 42px
+
+ </style>
+ <script>
+ const q = (new URL(location)).searchParams;
+ this.multiple = q.get('multiple') == 'true' ? true : false;
+
+ this.on('mount', () => {
+ document.documentElement.style.background = '#fff';
+
+ this.refs.browser.on('selected', file => {
+ this.files = [file];
+ this.ok();
+ });
+
+ this.refs.browser.on('change-selection', files => {
+ this.update({
+ files: files
+ });
+ });
+ });
+
+ this.upload = () => {
+ this.refs.browser.selectLocalFile();
+ };
+
+ this.close = () => {
+ window.close();
+ };
+
+ this.ok = () => {
+ window.opener.cb(this.multiple ? this.files : this.files[0]);
+ window.close();
+ };
+ </script>
+</mk-selectdrive-page>
diff --git a/src/web/app/mobile/tags/page/settings.tag b/src/web/app/mobile/tags/page/settings.tag
index 58094a876a..b6501142ee 100644
--- a/src/web/app/mobile/tags/page/settings.tag
+++ b/src/web/app/mobile/tags/page/settings.tag
@@ -1,12 +1,6 @@
<mk-settings-page>
<mk-ui ref="ui">
- <ul>
- <li><a><i class="fa fa-user"></i>%i18n:mobile.tags.mk-settings-page.profile%</a></li>
- <li><a href="./settings/authorized-apps"><i class="fa fa-puzzle-piece"></i>%i18n:mobile.tags.mk-settings-page.applications%</a></li>
- <li><a href="./settings/twitter"><i class="fa fa-twitter"></i>%i18n:mobile.tags.mk-settings-page.twitter-integration%</a></li>
- <li><a href="./settings/signin-history"><i class="fa fa-sign-in"></i>%i18n:mobile.tags.mk-settings-page.signin-history%</a></li>
- <li><a href="./settings/api"><i class="fa fa-key"></i>API</a></li>
- </ul>
+ <mk-settings />
</mk-ui>
<style>
:scope
@@ -18,6 +12,92 @@
this.on('mount', () => {
document.title = 'Misskey | %i18n:mobile.tags.mk-settings-page.settings%';
ui.trigger('title', '<i class="fa fa-cog"></i>%i18n:mobile.tags.mk-settings-page.settings%');
+ document.documentElement.style.background = '#313a42';
});
</script>
</mk-settings-page>
+
+<mk-settings>
+ <p><mk-raw content={ '%i18n:mobile.tags.mk-settings.signed-in-as%'.replace('{}', '<b>' + I.name + '</b>') }/></p>
+ <ul>
+ <li><a href="./settings/profile"><i class="fa fa-user"></i>%i18n:mobile.tags.mk-settings-page.profile%<i class="fa fa-angle-right"></i></a></li>
+ <li><a href="./settings/authorized-apps"><i class="fa fa-puzzle-piece"></i>%i18n:mobile.tags.mk-settings-page.applications%<i class="fa fa-angle-right"></i></a></li>
+ <li><a href="./settings/twitter"><i class="fa fa-twitter"></i>%i18n:mobile.tags.mk-settings-page.twitter-integration%<i class="fa fa-angle-right"></i></a></li>
+ <li><a href="./settings/signin-history"><i class="fa fa-sign-in"></i>%i18n:mobile.tags.mk-settings-page.signin-history%<i class="fa fa-angle-right"></i></a></li>
+ <li><a href="./settings/api"><i class="fa fa-key"></i>%i18n:mobile.tags.mk-settings-page.api%<i class="fa fa-angle-right"></i></a></li>
+ </ul>
+ <ul>
+ <li><a onclick={ signout }><i class="fa fa-power-off"></i>%i18n:mobile.tags.mk-settings-page.signout%</a></li>
+ </ul>
+ <p><small>ver { version } (葵 aoi)</small></p>
+ <style>
+ :scope
+ display block
+
+ > 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
+
+ > i:nth-of-type(1)
+ margin-right 4px
+
+ > i: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>
+ <script>
+ import signout from '../../../common/scripts/signout';
+ this.signout = signout;
+
+ this.mixin('i');
+
+ this.version = VERSION;
+ </script>
+</mk-settings>
diff --git a/src/web/app/mobile/tags/page/settings/api.tag b/src/web/app/mobile/tags/page/settings/api.tag
index cfffeacb5a..25413e2d80 100644
--- a/src/web/app/mobile/tags/page/settings/api.tag
+++ b/src/web/app/mobile/tags/page/settings/api.tag
@@ -7,7 +7,7 @@
display block
</style>
<script>
- const ui = require('../../../scripts/ui-event');
+ import ui from '../../../scripts/ui-event';
this.on('mount', () => {
document.title = 'Misskey | API';
@@ -15,3 +15,22 @@
});
</script>
</mk-api-info-page>
+
+<mk-api-info>
+ <p>Token:<code>{ I.token }</code></p>
+ <p>APIを利用するには、上記のトークンを「i」というキーでパラメータに付加してリクエストします。</p>
+ <p>アカウントを乗っ取られてしまう可能性があるため、このトークンは第三者に教えないでください(アプリなどにも入力しないでください)。</p>
+ <p>万が一このトークンが漏れたりその可能性がある場合はデスクトップ版Misskeyから再生成できます。</p>
+ <style>
+ :scope
+ display block
+ color #4a535a
+
+ code
+ padding 4px
+ background #eee
+ </style>
+ <script>
+ this.mixin('i');
+ </script>
+</mk-api-info>
diff --git a/src/web/app/mobile/tags/page/settings/authorized-apps.tag b/src/web/app/mobile/tags/page/settings/authorized-apps.tag
index e962871ec7..78efd13e47 100644
--- a/src/web/app/mobile/tags/page/settings/authorized-apps.tag
+++ b/src/web/app/mobile/tags/page/settings/authorized-apps.tag
@@ -7,7 +7,7 @@
display block
</style>
<script>
- const ui = require('../../../scripts/ui-event');
+ import ui from '../../../scripts/ui-event';
this.on('mount', () => {
document.title = 'Misskey | %i18n:mobile.tags.mk-authorized-apps-page.application%';
diff --git a/src/web/app/mobile/tags/page/settings/profile.tag b/src/web/app/mobile/tags/page/settings/profile.tag
new file mode 100644
index 0000000000..305f16fec5
--- /dev/null
+++ b/src/web/app/mobile/tags/page/settings/profile.tag
@@ -0,0 +1,247 @@
+<mk-profile-setting-page>
+ <mk-ui ref="ui">
+ <mk-profile-setting/>
+ </mk-ui>
+ <style>
+ :scope
+ display block
+ </style>
+ <script>
+ import ui from '../../../scripts/ui-event';
+
+ this.on('mount', () => {
+ document.title = 'Misskey | %i18n:mobile.tags.mk-profile-setting-page.title%';
+ ui.trigger('title', '<i class="fa fa-user"></i>%i18n:mobile.tags.mk-profile-setting-page.title%');
+ document.documentElement.style.background = '#313a42';
+ });
+ </script>
+</mk-profile-setting-page>
+
+<mk-profile-setting>
+ <div>
+ <p><i class="fa fa-info-circle"></i>%i18n:mobile.tags.mk-profile-setting.will-be-published%</p>
+ <div class="form">
+ <div style={ I.banner_url ? 'background-image: url(' + I.banner_url + '?thumbnail&size=1024)' : '' } onclick={ clickBanner }>
+ <img src={ I.avatar_url + '?thumbnail&size=200' } alt="avatar" onclick={ clickAvatar }/>
+ </div>
+ <label>
+ <p>%i18n:mobile.tags.mk-profile-setting.name%</p>
+ <input ref="name" type="text" value={ I.name }/>
+ </label>
+ <label>
+ <p>%i18n:mobile.tags.mk-profile-setting.location%</p>
+ <input ref="location" type="text" value={ I.profile.location }/>
+ </label>
+ <label>
+ <p>%i18n:mobile.tags.mk-profile-setting.description%</p>
+ <textarea ref="description">{ I.description }</textarea>
+ </label>
+ <label>
+ <p>%i18n:mobile.tags.mk-profile-setting.birthday%</p>
+ <input ref="birthday" type="date" value={ I.profile.birthday }/>
+ </label>
+ <label>
+ <p>%i18n:mobile.tags.mk-profile-setting.avatar%</p>
+ <button onclick={ setAvatar } disabled={ avatarSaving }>%i18n:mobile.tags.mk-profile-setting.set-avatar%</button>
+ </label>
+ <label>
+ <p>%i18n:mobile.tags.mk-profile-setting.banner%</p>
+ <button onclick={ setBanner } disabled={ bannerSaving }>%i18n:mobile.tags.mk-profile-setting.set-banner%</button>
+ </label>
+ </div>
+ <button class="save" onclick={ save } disabled={ saving }><i class="fa fa-check"></i>%i18n:mobile.tags.mk-profile-setting.save%</button>
+ </div>
+ <style>
+ :scope
+ display block
+
+ > div
+ 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
+
+ > i
+ 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
+
+ > i
+ margin-right 4px
+
+ </style>
+ <script>
+ this.mixin('i');
+ this.mixin('api');
+
+ this.setAvatar = () => {
+ const i = riot.mount(document.body.appendChild(document.createElement('mk-drive-selector')), {
+ multiple: false
+ })[0];
+ i.one('selected', file => {
+ this.update({
+ avatarSaving: true
+ });
+
+ this.api('i/update', {
+ avatar_id: file.id
+ }).then(() => {
+ this.update({
+ avatarSaving: false
+ });
+
+ alert('%i18n:mobile.tags.mk-profile-setting.avatar-saved%');
+ });
+ });
+ };
+
+ this.setBanner = () => {
+ const i = riot.mount(document.body.appendChild(document.createElement('mk-drive-selector')), {
+ multiple: false
+ })[0];
+ i.one('selected', file => {
+ this.update({
+ bannerSaving: true
+ });
+
+ this.api('i/update', {
+ banner_id: file.id
+ }).then(() => {
+ this.update({
+ bannerSaving: false
+ });
+
+ alert('%i18n:mobile.tags.mk-profile-setting.banner-saved%');
+ });
+ });
+ };
+
+ this.clickAvatar = e => {
+ this.setAvatar();
+ return false;
+ };
+
+ this.clickBanner = e => {
+ this.setBanner();
+ return false;
+ };
+
+ this.save = () => {
+ this.update({
+ saving: true
+ });
+
+ this.api('i/update', {
+ name: this.refs.name.value,
+ location: this.refs.location.value || null,
+ description: this.refs.description.value || null,
+ birthday: this.refs.birthday.value || null
+ }).then(() => {
+ this.update({
+ saving: false
+ });
+
+ alert('%i18n:mobile.tags.mk-profile-setting.saved%');
+ });
+ };
+ </script>
+</mk-profile-setting>
diff --git a/src/web/app/mobile/tags/page/settings/signin.tag b/src/web/app/mobile/tags/page/settings/signin.tag
index 2305ea9fb4..a91ebfb140 100644
--- a/src/web/app/mobile/tags/page/settings/signin.tag
+++ b/src/web/app/mobile/tags/page/settings/signin.tag
@@ -7,7 +7,7 @@
display block
</style>
<script>
- const ui = require('../../../scripts/ui-event');
+ import ui from '../../../scripts/ui-event';
this.on('mount', () => {
document.title = 'Misskey | %i18n:mobile.tags.mk-signin-history-page.signin-history%';
diff --git a/src/web/app/mobile/tags/page/settings/twitter.tag b/src/web/app/mobile/tags/page/settings/twitter.tag
index f4e9f7628b..870eeeb5bc 100644
--- a/src/web/app/mobile/tags/page/settings/twitter.tag
+++ b/src/web/app/mobile/tags/page/settings/twitter.tag
@@ -7,7 +7,7 @@
display block
</style>
<script>
- const ui = require('../../../scripts/ui-event');
+ import ui from '../../../scripts/ui-event';
this.on('mount', () => {
document.title = 'Misskey | %i18n:mobile.tags.mk-twitter-setting-page.twitter-integration%';
diff --git a/src/web/app/mobile/tags/page/user-followers.tag b/src/web/app/mobile/tags/page/user-followers.tag
index f6fcffebe2..cffb2b58c4 100644
--- a/src/web/app/mobile/tags/page/user-followers.tag
+++ b/src/web/app/mobile/tags/page/user-followers.tag
@@ -29,6 +29,7 @@
document.title = '%i18n:mobile.tags.mk-user-followers-page.followers-of%'.replace('{}', user.name) + ' | Misskey';
// TODO: ユーザー名をエスケープ
ui.trigger('title', '<img src="' + user.avatar_url + '?thumbnail&size=64">' + '%i18n:mobile.tags.mk-user-followers-page.followers-of%'.replace('{}', user.name));
+ document.documentElement.style.background = '#313a42';
this.refs.ui.refs.list.on('loaded', () => {
Progress.done();
diff --git a/src/web/app/mobile/tags/page/user-following.tag b/src/web/app/mobile/tags/page/user-following.tag
index 4b289b6aa3..369cb46422 100644
--- a/src/web/app/mobile/tags/page/user-following.tag
+++ b/src/web/app/mobile/tags/page/user-following.tag
@@ -29,6 +29,7 @@
document.title = '%i18n:mobile.tags.mk-user-following-page.following-of%'.replace('{}', user.name) + ' | Misskey';
// TODO: ユーザー名をエスケープ
ui.trigger('title', '<img src="' + user.avatar_url + '?thumbnail&size=64">' + '%i18n:mobile.tags.mk-user-following-page.following-of%'.replace('{}', user.name));
+ document.documentElement.style.background = '#313a42';
this.refs.ui.refs.list.on('loaded', () => {
Progress.done();
diff --git a/src/web/app/mobile/tags/page/user.tag b/src/web/app/mobile/tags/page/user.tag
index 05ccef3113..1abeab492a 100644
--- a/src/web/app/mobile/tags/page/user.tag
+++ b/src/web/app/mobile/tags/page/user.tag
@@ -13,6 +13,7 @@
this.user = this.opts.user;
this.on('mount', () => {
+ document.documentElement.style.background = '#313a42';
Progress.start();
this.refs.ui.refs.user.on('loaded', user => {
diff --git a/src/web/app/mobile/tags/post-detail.tag b/src/web/app/mobile/tags/post-detail.tag
index 9d62a2b591..8a32101036 100644
--- a/src/web/app/mobile/tags/post-detail.tag
+++ b/src/web/app/mobile/tags/post-detail.tag
@@ -1,313 +1,306 @@
<mk-post-detail>
- <div class="fetching" if={ fetching }>
- <mk-ellipsis-icon/>
+ <button class="read-more" if={ p.reply && p.reply.reply_id && context == null } onclick={ loadContext } disabled={ loadingContext }>
+ <i class="fa fa-ellipsis-v" if={ !contextFetching }></i>
+ <i class="fa fa-spinner fa-pulse" if={ contextFetching }></i>
+ </button>
+ <div class="context">
+ <virtual each={ post in context }>
+ <mk-post-detail-sub post={ post }/>
+ </virtual>
</div>
- <div class="main" if={ !fetching }>
- <button class="read-more" if={ p.reply_to && p.reply_to.reply_to_id && context == null } onclick={ loadContext } disabled={ loadingContext }>
- <i class="fa fa-ellipsis-v" if={ !contextFetching }></i>
- <i class="fa fa-spinner fa-pulse" if={ contextFetching }></i>
- </button>
- <div class="context">
- <virtual each={ post in context }>
- <mk-post-preview post={ post }/>
- </virtual>
- </div>
- <div class="reply-to" if={ p.reply_to }>
- <mk-post-preview post={ p.reply_to }/>
- </div>
- <div class="repost" if={ isRepost }>
- <p>
- <a class="avatar-anchor" href={ '/' + post.user.username }>
- <img class="avatar" src={ post.user.avatar_url + '?thumbnail&size=32' } alt="avatar"/></a>
- <i class="fa fa-retweet"></i><a class="name" href={ '/' + post.user.username }>
- { post.user.name }
- </a>
- がRepost
- </p>
- </div>
- <article>
- <header>
- <a class="avatar-anchor" href={ '/' + p.user.username }>
- <img class="avatar" src={ p.user.avatar_url + '?thumbnail&size=64' } alt="avatar"/>
- </a>
- <div>
- <a class="name" href={ '/' + p.user.username }>{ p.user.name }</a>
- <span class="username">@{ p.user.username }</span>
- </div>
- </header>
- <div class="body">
- <div class="text" ref="text"></div>
- <div class="media" if={ p.media }>
- <virtual each={ file in p.media }><img src={ file.url + '?thumbnail&size=512' } alt={ file.name } title={ file.name }/></virtual>
- </div>
- <mk-poll if={ p.poll } post={ p }/>
- </div>
- <a class="time" href={ url }>
- <mk-time time={ p.created_at } mode="detail"/>
+ <div class="reply-to" if={ p.reply }>
+ <mk-post-detail-sub post={ p.reply }/>
+ </div>
+ <div class="repost" if={ isRepost }>
+ <p>
+ <a class="avatar-anchor" href={ '/' + post.user.username }>
+ <img class="avatar" src={ post.user.avatar_url + '?thumbnail&size=32' } alt="avatar"/></a>
+ <i class="fa fa-retweet"></i><a class="name" href={ '/' + post.user.username }>
+ { post.user.name }
</a>
- <footer>
- <mk-reactions-viewer post={ p }/>
- <button onclick={ reply } title="%i18n:mobile.tags.mk-post-detail.reply%"><i class="fa fa-reply"></i>
- <p class="count" if={ p.replies_count > 0 }>{ p.replies_count }</p>
- </button>
- <button onclick={ repost } title="Repost"><i class="fa fa-retweet"></i>
- <p class="count" if={ p.repost_count > 0 }>{ p.repost_count }</p>
- </button>
- <button class={ reacted: p.my_reaction != null } onclick={ react } ref="reactButton" title="%i18n:mobile.tags.mk-post-detail.reaction%"><i class="fa fa-plus"></i>
- <p class="count" if={ p.reactions_count > 0 }>{ p.reactions_count }</p>
- </button>
- <button><i class="fa fa-ellipsis-h"></i></button>
- </footer>
- </article>
- <div class="replies">
- <virtual each={ post in replies }>
- <mk-post-preview post={ post }/>
- </virtual>
+ がRepost
+ </p>
+ </div>
+ <article>
+ <header>
+ <a class="avatar-anchor" href={ '/' + p.user.username }>
+ <img class="avatar" src={ p.user.avatar_url + '?thumbnail&size=64' } alt="avatar"/>
+ </a>
+ <div>
+ <a class="name" href={ '/' + p.user.username }>{ p.user.name }</a>
+ <span class="username">@{ p.user.username }</span>
+ </div>
+ </header>
+ <div class="body">
+ <div class="text" ref="text"></div>
+ <div class="media" if={ p.media }>
+ <virtual each={ file in p.media }><img src={ file.url + '?thumbnail&size=512' } alt={ file.name } title={ file.name }/></virtual>
+ </div>
+ <mk-poll if={ p.poll } post={ p }/>
</div>
+ <a class="time" href={ '/' + p.user.username + '/' + p.id }>
+ <mk-time time={ p.created_at } mode="detail"/>
+ </a>
+ <footer>
+ <mk-reactions-viewer post={ p }/>
+ <button onclick={ reply } title="%i18n:mobile.tags.mk-post-detail.reply%">
+ <i class="fa fa-reply"></i><p class="count" if={ p.replies_count > 0 }>{ p.replies_count }</p>
+ </button>
+ <button onclick={ repost } title="Repost">
+ <i class="fa fa-retweet"></i><p class="count" if={ p.repost_count > 0 }>{ p.repost_count }</p>
+ </button>
+ <button class={ reacted: p.my_reaction != null } onclick={ react } ref="reactButton" title="%i18n:mobile.tags.mk-post-detail.reaction%">
+ <i class="fa fa-plus"></i><p class="count" if={ p.reactions_count > 0 }>{ p.reactions_count }</p>
+ </button>
+ <button onclick={ menu } ref="menuButton">
+ <i class="fa fa-ellipsis-h"></i>
+ </button>
+ </footer>
+ </article>
+ <div class="replies" if={ !compact }>
+ <virtual each={ post in replies }>
+ <mk-post-detail-sub post={ post }/>
+ </virtual>
</div>
<style>
:scope
display block
- margin 0
+ 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
- > .main
+ > .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
+ box-shadow none
- > .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
- box-shadow none
+ &:hover
+ background #f6f6f6
- &:hover
- background #f6f6f6
+ &:active
+ background #f0f0f0
- &:active
- background #f0f0f0
+ &:disabled
+ color #ccc
- &:disabled
- color #ccc
+ > .context
+ > *
+ border-bottom 1px solid #eef0f2
- > .context
- > *
- border-bottom 1px solid #eef0f2
+ > .repost
+ color #9dbb00
+ background linear-gradient(to bottom, #edfde2 0%, #fff 100%)
- > .repost
- color #9dbb00
- background linear-gradient(to bottom, #edfde2 0%, #fff 100%)
+ > p
+ margin 0
+ padding 16px 32px
- > p
- margin 0
- padding 16px 32px
+ .avatar-anchor
+ display inline-block
- .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
- .avatar
- vertical-align bottom
- min-width 28px
- min-height 28px
- max-width 28px
- max-height 28px
- margin 0 8px 0 0
- border-radius 6px
+ i
+ margin-right 4px
- i
- margin-right 4px
+ .name
+ font-weight bold
- .name
- font-weight bold
+ & + article
+ padding-top 8px
- & + article
- padding-top 8px
+ > .reply-to
+ border-bottom 1px solid #eef0f2
- > .reply-to
- border-bottom 1px solid #eef0f2
+ > article
+ padding 14px 16px 9px 16px
- > article
- padding 14px 16px 9px 16px
+ @media (min-width 500px)
+ padding 28px 32px 18px 32px
- @media (min-width 500px)
- padding 28px 32px 18px 32px
+ &:after
+ content ""
+ display block
+ clear both
- &:after
- content ""
- display block
- clear both
+ &:hover
+ > .main > footer > button
+ color #888
- &:hover
- > .main > footer > button
- color #888
+ > header
+ display flex
+ line-height 1.1
- > header
- display flex
- line-height 1.1
+ > .avatar-anchor
+ display block
+ padding 0 .5em 0 0
- > .avatar-anchor
+ > .avatar
display block
- padding 0 .5em 0 0
+ width 54px
+ height 54px
+ margin 0
+ border-radius 8px
+ vertical-align bottom
- > .avatar
- display block
- width 54px
- height 54px
- margin 0
- border-radius 8px
- vertical-align bottom
+ @media (min-width 500px)
+ width 60px
+ height 60px
- @media (min-width 500px)
- width 60px
- height 60px
+ > div
- > div
+ > .name
+ display inline-block
+ margin .4em 0
+ color #777
+ font-size 16px
+ font-weight bold
+ text-align left
+ text-decoration none
- > .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
- &:hover
- text-decoration underline
+ > .username
+ display block
+ text-align left
+ margin 0
+ color #ccc
- > .username
- display block
- text-align left
- margin 0
- color #ccc
+ > .body
+ padding 8px 0
- > .body
- padding 8px 0
+ > .text
+ cursor default
+ display block
+ margin 0
+ padding 0
+ overflow-wrap break-word
+ font-size 16px
+ color #717171
- > .text
- cursor default
- display block
- margin 0
- padding 0
- overflow-wrap break-word
- font-size 16px
- color #717171
+ @media (min-width 500px)
+ font-size 24px
- @media (min-width 500px)
- font-size 24px
+ .link
+ &:after
+ content "\f14c"
+ display inline-block
+ padding-left 2px
+ font-family FontAwesome
+ font-size .9em
+ font-weight 400
+ font-style normal
- .link
- &:after
- content "\f14c"
- display inline-block
- padding-left 2px
- font-family FontAwesome
- font-size .9em
- font-weight 400
- font-style normal
+ > mk-url-preview
+ margin-top 8px
- > mk-url-preview
- margin-top 8px
+ > .media
+ > img
+ display block
+ max-width 100%
- > .media
- > img
- display block
- max-width 100%
+ > .time
+ font-size 16px
+ color #c0c0c0
- > .time
- font-size 16px
- color #c0c0c0
+ > footer
+ font-size 1.2em
- > footer
- font-size 1.2em
+ > button
+ margin 0
+ padding 8px
+ background transparent
+ border none
+ box-shadow none
+ font-size 1em
+ color #ddd
+ cursor pointer
- > button
- margin 0 28px 0 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
+ &:hover
+ color #666
- > .count
- display inline
- margin 0 0 0 8px
- color #999
+ > .count
+ display inline
+ margin 0 0 0 8px
+ color #999
- &.reacted
- color $theme-color
+ &.reacted
+ color $theme-color
- > .replies
- > *
- border-top 1px solid #eef0f2
+ > .replies
+ > *
+ border-top 1px solid #eef0f2
</style>
<script>
import compile from '../../common/scripts/text-compiler';
- import getPostSummary from '../../common/scripts/get-post-summary';
+ import getPostSummary from '../../../../common/get-post-summary.ts';
import openPostForm from '../scripts/open-post-form';
this.mixin('api');
- this.fetching = true;
+ this.compact = this.opts.compact;
+ this.post = this.opts.post;
+ this.isRepost = this.post.repost != null;
+ this.p = this.isRepost ? this.post.repost : this.post;
+ this.p.reactions_count = this.p.reaction_counts ? Object.keys(this.p.reaction_counts).map(key => this.p.reaction_counts[key]).reduce((a, b) => a + b) : 0;
+ this.summary = getPostSummary(this.p);
+
this.loadingContext = false;
this.context = null;
- this.post = null;
this.on('mount', () => {
- this.api('posts/show', {
- post_id: this.opts.post
- }).then(post => {
- const isRepost = post.repost != null;
- const p = isRepost ? post.repost : post;
- p.reactions_count = p.reaction_counts ? Object.keys(p.reaction_counts).map(key => p.reaction_counts[key]).reduce((a, b) => a + b) : 0;
+ if (this.p.text) {
+ const tokens = this.p.ast;
- this.update({
- fetching: false,
- post: post,
- isRepost: isRepost,
- p: p,
- summary: getPostSummary(p)
- });
-
- this.trigger('loaded');
-
- if (this.p.text) {
- const tokens = this.p.ast;
+ this.refs.text.innerHTML = compile(tokens);
- this.refs.text.innerHTML = compile(tokens);
-
- this.refs.text.children.forEach(e => {
- if (e.tagName == 'MK-URL') riot.mount(e);
- });
+ this.refs.text.children.forEach(e => {
+ if (e.tagName == 'MK-URL') riot.mount(e);
+ });
- // URLをプレビュー
- tokens
- .filter(t => (t.type == 'url' || t.type == 'link') && !t.silent)
- .map(t => {
- riot.mount(this.refs.text.appendChild(document.createElement('mk-url-preview')), {
- url: t.url
- });
+ // URLをプレビュー
+ tokens
+ .filter(t => (t.type == 'url' || t.type == 'link') && !t.silent)
+ .map(t => {
+ riot.mount(this.refs.text.appendChild(document.createElement('mk-url-preview')), {
+ url: t.url
});
- }
+ });
+ }
- // Get replies
+ // Get replies
+ if (!this.compact) {
this.api('posts/replies', {
post_id: this.p.id,
limit: 8
@@ -316,7 +309,7 @@
replies: replies
});
});
- });
+ }
});
this.reply = () => {
@@ -342,12 +335,20 @@
});
};
+ this.menu = () => {
+ riot.mount(document.body.appendChild(document.createElement('mk-post-menu')), {
+ source: this.refs.menuButton,
+ post: this.p,
+ compact: true
+ });
+ };
+
this.loadContext = () => {
this.contextFetching = true;
// Fetch context
this.api('posts/context', {
- post_id: this.p.reply_to_id
+ post_id: this.p.reply_id
}).then(context => {
this.update({
contextFetching: false,
@@ -357,3 +358,101 @@
};
</script>
</mk-post-detail>
+
+<mk-post-detail-sub>
+ <article>
+ <a class="avatar-anchor" href={ '/' + post.user.username }>
+ <img class="avatar" src={ post.user.avatar_url + '?thumbnail&size=64' } alt="avatar"/>
+ </a>
+ <div class="main">
+ <header>
+ <a class="name" href={ '/' + post.user.username }>{ post.user.name }</a>
+ <span class="username">@{ post.user.username }</span>
+ <a class="time" href={ '/' + post.user.username + '/' + post.id }>
+ <mk-time time={ post.created_at }/>
+ </a>
+ </header>
+ <div class="body">
+ <mk-sub-post-content class="text" post={ post }/>
+ </div>
+ </div>
+ </article>
+ <style>
+ :scope
+ display block
+ margin 0
+ padding 8px
+ font-size 0.9em
+ background #fdfdfd
+
+ @media (min-width 500px)
+ padding 12px
+
+ > article
+ &: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>
+ <script>this.post = this.opts.post</script>
+</mk-post-detail-sub>
diff --git a/src/web/app/mobile/tags/post-form.tag b/src/web/app/mobile/tags/post-form.tag
index 28c7796840..d7d382c9e2 100644
--- a/src/web/app/mobile/tags/post-form.tag
+++ b/src/web/app/mobile/tags/post-form.tag
@@ -1,11 +1,9 @@
<mk-post-form>
<header>
+ <button class="cancel" onclick={ cancel }><i class="fa fa-times"></i></button>
<div>
- <button class="cancel" onclick={ cancel }><i class="fa fa-times"></i></button>
- <div>
- <span if={ refs.text } class="text-count { over: refs.text.value.length > 1000 }">{ 1000 - refs.text.value.length }</span>
- <button class="submit" onclick={ post }>%i18n:mobile.tags.mk-post-form.submit%</button>
- </div>
+ <span if={ refs.text } class="text-count { over: refs.text.value.length > 1000 }">{ 1000 - refs.text.value.length }</span>
+ <button class="submit" onclick={ post }>%i18n:mobile.tags.mk-post-form.submit%</button>
</div>
</header>
<div class="form">
@@ -30,46 +28,47 @@
<style>
:scope
display block
- padding-top 50px
+ 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
- position fixed
- z-index 1000
- top 0
- left 0
- width 100%
+ z-index 1
height 50px
- background #fff
+ box-shadow 0 1px 0 0 rgba(0, 0, 0, 0.1)
+
+ > .cancel
+ width 50px
+ line-height 50px
+ font-size 24px
+ color #555
> div
- max-width 500px
- margin 0 auto
+ position absolute
+ top 0
+ right 0
- > .cancel
- width 50px
+ > .text-count
line-height 50px
- font-size 24px
- color #555
-
- > div
- position absolute
- top 0
- right 0
-
- > .text-count
- line-height 50px
- color #657786
+ color #657786
- > .submit
- margin 8px
- padding 0 16px
- line-height 34px
- color $theme-color-foreground
- background $theme-color
- border-radius 4px
+ > .submit
+ margin 8px
+ padding 0 16px
+ line-height 34px
+ color $theme-color-foreground
+ background $theme-color
+ border-radius 4px
- &:disabled
- opacity 0.7
+ &:disabled
+ opacity 0.7
> .form
max-width 500px
@@ -268,7 +267,7 @@
this.api('posts/create', {
text: this.refs.text.value == '' ? undefined : this.refs.text.value,
media_ids: files,
- reply_to_id: opts.reply ? opts.reply.id : undefined,
+ reply_id: opts.reply ? opts.reply.id : undefined,
poll: this.poll ? this.refs.poll.get() : undefined
}).then(data => {
this.trigger('post');
diff --git a/src/web/app/mobile/tags/search-posts.tag b/src/web/app/mobile/tags/search-posts.tag
index 3e6caa1df2..967764bc2c 100644
--- a/src/web/app/mobile/tags/search-posts.tag
+++ b/src/web/app/mobile/tags/search-posts.tag
@@ -3,8 +3,16 @@
<style>
:scope
display block
+ 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>
<script>
this.mixin('api');
diff --git a/src/web/app/mobile/tags/sub-post-content.tag b/src/web/app/mobile/tags/sub-post-content.tag
index 97e0ecec03..e32e245185 100644
--- a/src/web/app/mobile/tags/sub-post-content.tag
+++ b/src/web/app/mobile/tags/sub-post-content.tag
@@ -1,5 +1,5 @@
<mk-sub-post-content>
- <div class="body"><a class="reply" if={ post.reply_to_id }><i class="fa fa-reply"></i></a><span ref="text"></span><a class="quote" if={ post.repost_id } href={ '/post:' + post.repost_id }>RP: ...</a></div>
+ <div class="body"><a class="reply" if={ post.reply_id }><i class="fa fa-reply"></i></a><span ref="text"></span><a class="quote" if={ post.repost_id } href={ '/post:' + post.repost_id }>RP: ...</a></div>
<details if={ post.media }>
<summary>({ post.media.length }個のメディア)</summary>
<mk-images-viewer images={ post.media }/>
diff --git a/src/web/app/mobile/tags/timeline-post-sub.tag b/src/web/app/mobile/tags/timeline-post-sub.tag
deleted file mode 100644
index 3fff552e8f..0000000000
--- a/src/web/app/mobile/tags/timeline-post-sub.tag
+++ /dev/null
@@ -1,101 +0,0 @@
-<mk-timeline-post-sub>
- <article><a class="avatar-anchor" href={ '/' + post.user.username }><img class="avatar" src={ post.user.avatar_url + '?thumbnail&size=96' } alt="avatar"/></a>
- <div class="main">
- <header><a class="name" href={ '/' + post.user.username }>{ post.user.name }</a><span class="username">@{ post.user.username }</span><a class="created-at" href={ '/' + post.user.username + '/' + post.id }>
- <mk-time time={ post.created_at }/></a></header>
- <div class="body">
- <mk-sub-post-content class="text" post={ post }/>
- </div>
- </div>
- </article>
- <style>
- :scope
- display block
- margin 0
- padding 0
- font-size 0.9em
-
- > article
- padding 16px
-
- &:after
- content ""
- display block
- clear both
-
- &:hover
- > .main > footer > button
- color #888
-
- > .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>
- <script>this.post = this.opts.post</script>
-</mk-timeline-post-sub>
diff --git a/src/web/app/mobile/tags/timeline-post.tag b/src/web/app/mobile/tags/timeline-post.tag
deleted file mode 100644
index 2395e9fb79..0000000000
--- a/src/web/app/mobile/tags/timeline-post.tag
+++ /dev/null
@@ -1,414 +0,0 @@
-<mk-timeline-post class={ repost: isRepost }>
- <div class="reply-to" if={ p.reply_to }>
- <mk-timeline-post-sub post={ p.reply_to }/>
- </div>
- <div class="repost" if={ isRepost }>
- <p>
- <a class="avatar-anchor" href={ '/' + post.user.username }>
- <img class="avatar" src={ post.user.avatar_url + '?thumbnail&size=64' } alt="avatar"/>
- </a>
- <i class="fa fa-retweet"></i>{'%i18n:mobile.tags.mk-timeline-post.reposted-by%'.substr(0, '%i18n:mobile.tags.mk-timeline-post.reposted-by%'.indexOf('{'))}<a class="name" href={ '/' + post.user.username }>{ post.user.name }</a>{'%i18n:mobile.tags.mk-timeline-post.reposted-by%'.substr('%i18n:mobile.tags.mk-timeline-post.reposted-by%'.indexOf('}') + 1)}
- </p>
- <mk-time time={ post.created_at }/>
- </div>
- <article>
- <a class="avatar-anchor" href={ '/' + p.user.username }>
- <img class="avatar" src={ p.user.avatar_url + '?thumbnail&size=96' } alt="avatar"/>
- </a>
- <div class="main">
- <header>
- <a class="name" href={ '/' + p.user.username }>{ p.user.name }</a>
- <span class="is-bot" if={ p.user.is_bot }>bot</span>
- <span class="username">@{ p.user.username }</span>
- <a class="created-at" href={ url }>
- <mk-time time={ p.created_at }/>
- </a>
- </header>
- <div class="body">
- <div class="text" ref="text">
- <a class="reply" if={ p.reply_to }>
- <i class="fa fa-reply"></i>
- </a>
- <p class="dummy"></p>
- <a class="quote" if={ p.repost != null }>RP:</a>
- </div>
- <div class="media" if={ p.media }>
- <mk-images-viewer images={ p.media }/>
- </div>
- <mk-poll if={ p.poll } post={ p } ref="pollViewer"/>
- <span class="app" if={ p.app }>via <b>{ p.app.name }</b></span>
- <div class="repost" if={ p.repost }><i class="fa fa-quote-right fa-flip-horizontal"></i>
- <mk-post-preview class="repost" post={ p.repost }/>
- </div>
- </div>
- <footer>
- <mk-reactions-viewer post={ p } ref="reactionsViewer"/>
- <button onclick={ reply }><i class="fa fa-reply"></i>
- <p class="count" if={ p.replies_count > 0 }>{ p.replies_count }</p>
- </button>
- <button onclick={ repost } title="Repost"><i class="fa fa-retweet"></i>
- <p class="count" if={ p.repost_count > 0 }>{ p.repost_count }</p>
- </button>
- <button class={ reacted: p.my_reaction != null } onclick={ react } ref="reactButton"><i class="fa fa-plus"></i>
- <p class="count" if={ p.reactions_count > 0 }>{ p.reactions_count }</p>
- </button>
- </footer>
- </div>
- </article>
- <style>
- :scope
- display block
- margin 0
- padding 0
- font-size 12px
-
- @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
-
- i
- 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
- white-space nowrap
-
- @media (min-width 500px)
- margin-bottom 2px
-
- > .name
- display block
- margin 0 0.5em 0 0
- padding 0
- overflow hidden
- color #777
- font-size 1em
- font-weight 700
- text-align left
- text-decoration none
- text-overflow ellipsis
-
- &:hover
- text-decoration underline
-
- > .is-bot
- text-align left
- margin 0 0.5em 0 0
- padding 1px 6px
- font-size 12px
- color #aaa
- border solid 1px #ddd
- border-radius 3px
-
- > .username
- text-align left
- margin 0 0.5em 0 0
- color #ccc
-
- > .created-at
- margin-left auto
- font-size 0.9em
- color #c0c0c0
-
- > .body
-
- > .text
- cursor default
- display block
- margin 0
- padding 0
- overflow-wrap break-word
- font-size 1.1em
- color #717171
-
- > .dummy
- display none
-
- .link
- &:after
- content "\f14c"
- display inline-block
- padding-left 2px
- font-family FontAwesome
- font-size .9em
- font-weight 400
- font-style normal
-
- mk-url-preview
- margin-top 8px
-
- > .reply
- margin-right 8px
- color #717171
-
- > .quote
- margin-left 4px
- font-style oblique
- color #a0bf46
-
- 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
-
- > .media
- > img
- display block
- max-width 100%
-
- > .app
- font-size 12px
- color #ccc
-
- > mk-poll
- font-size 80%
-
- > .repost
- margin 8px 0
-
- > i:first-child
- position absolute
- top -8px
- left -8px
- z-index 1
- color #c0dac6
- font-size 28px
- background #fff
-
- > mk-post-preview
- padding 16px
- border dashed 1px #c0dac6
- border-radius 8px
-
- > footer
- > button
- margin 0 28px 0 0
- padding 8px
- background transparent
- border none
- box-shadow 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
-
- </style>
- <script>
- import compile from '../../common/scripts/text-compiler';
- import getPostSummary from '../../common/scripts/get-post-summary';
- import openPostForm from '../scripts/open-post-form';
-
- this.mixin('api');
- this.mixin('stream');
-
- this.set = post => {
- this.post = post;
- this.isRepost = this.post.repost != null && this.post.text == null;
- this.p = this.isRepost ? this.post.repost : this.post;
- this.p.reactions_count = this.p.reaction_counts ? Object.keys(this.p.reaction_counts).map(key => this.p.reaction_counts[key]).reduce((a, b) => a + b) : 0;
- this.summary = getPostSummary(this.p);
- this.url = `/${this.p.user.username}/${this.p.id}`;
- };
-
- this.set(this.opts.post);
-
- this.refresh = post => {
- this.set(post);
- this.update();
- if (this.refs.reactionsViewer) this.refs.reactionsViewer.update({
- post
- });
- if (this.refs.pollViewer) this.refs.pollViewer.init(post);
- };
-
- this.onStreamPostUpdated = data => {
- const post = data.post;
- if (post.id == this.post.id) {
- this.refresh(post);
- }
- };
-
- this.onStreamConnected = () => {
- this.capture();
- };
-
- this.capture = withHandler => {
- this.stream.send({
- type: 'capture',
- id: this.post.id
- });
- if (withHandler) this.stream.on('post-updated', this.onStreamPostUpdated);
- };
-
- this.decapture = withHandler => {
- this.stream.send({
- type: 'decapture',
- id: this.post.id
- });
- if (withHandler) this.stream.off('post-updated', this.onStreamPostUpdated);
- };
-
- this.on('mount', () => {
- this.capture(true);
- this.stream.on('_connected_', this.onStreamConnected);
-
- if (this.p.text) {
- const tokens = this.p.ast;
-
- this.refs.text.innerHTML = this.refs.text.innerHTML.replace('<p class="dummy"></p>', compile(tokens));
-
- this.refs.text.children.forEach(e => {
- if (e.tagName == 'MK-URL') riot.mount(e);
- });
-
- // URLをプレビュー
- tokens
- .filter(t => (t.type == 'url' || t.type == 'link') && !t.silent)
- .map(t => {
- riot.mount(this.refs.text.appendChild(document.createElement('mk-url-preview')), {
- url: t.url
- });
- });
- }
- });
-
- this.on('unmount', () => {
- this.decapture(true);
- this.stream.off('_connected_', this.onStreamConnected);
- });
-
- this.reply = () => {
- openPostForm({
- reply: this.p
- });
- };
-
- this.repost = () => {
- const text = window.prompt(`「${this.summary}」をRepost`);
- if (text == null) return;
- this.api('posts/create', {
- repost_id: this.p.id,
- text: text == '' ? undefined : text
- });
- };
-
- this.react = () => {
- riot.mount(document.body.appendChild(document.createElement('mk-reaction-picker')), {
- source: this.refs.reactButton,
- post: this.p,
- compact: true
- });
- };
- </script>
-</mk-timeline-post>
diff --git a/src/web/app/mobile/tags/timeline.tag b/src/web/app/mobile/tags/timeline.tag
index 11f4e0740b..f9ec2cca60 100644
--- a/src/web/app/mobile/tags/timeline.tag
+++ b/src/web/app/mobile/tags/timeline.tag
@@ -22,6 +22,8 @@
:scope
display block
background #fff
+ border-radius 8px
+ box-shadow 0 0 0 1px rgba(0, 0, 0, 0.2)
> .init
padding 64px 0
@@ -44,12 +46,6 @@
font-size 3em
color #ccc
- > mk-timeline-post
- border-bottom solid 1px #eaeaea
-
- &:last-of-type
- border-bottom none
-
> .date
display block
margin 0
@@ -77,6 +73,7 @@
padding 16px
width 100%
color $theme-color
+ border-radius 0 0 8px 8px
&:disabled
opacity 0.7
@@ -138,3 +135,560 @@
};
</script>
</mk-timeline>
+
+<mk-timeline-post class={ repost: isRepost }>
+ <div class="reply-to" if={ p.reply }>
+ <mk-timeline-post-sub post={ p.reply }/>
+ </div>
+ <div class="repost" if={ isRepost }>
+ <p>
+ <a class="avatar-anchor" href={ '/' + post.user.username }>
+ <img class="avatar" src={ post.user.avatar_url + '?thumbnail&size=64' } alt="avatar"/>
+ </a>
+ <i class="fa fa-retweet"></i>{'%i18n:mobile.tags.mk-timeline-post.reposted-by%'.substr(0, '%i18n:mobile.tags.mk-timeline-post.reposted-by%'.indexOf('{'))}<a class="name" href={ '/' + post.user.username }>{ post.user.name }</a>{'%i18n:mobile.tags.mk-timeline-post.reposted-by%'.substr('%i18n:mobile.tags.mk-timeline-post.reposted-by%'.indexOf('}') + 1)}
+ </p>
+ <mk-time time={ post.created_at }/>
+ </div>
+ <article>
+ <a class="avatar-anchor" href={ '/' + p.user.username }>
+ <img class="avatar" src={ p.user.avatar_url + '?thumbnail&size=96' } alt="avatar"/>
+ </a>
+ <div class="main">
+ <header>
+ <a class="name" href={ '/' + p.user.username }>{ p.user.name }</a>
+ <span class="is-bot" if={ p.user.is_bot }>bot</span>
+ <span class="username">@{ p.user.username }</span>
+ <a class="created-at" href={ url }>
+ <mk-time time={ p.created_at }/>
+ </a>
+ </header>
+ <div class="body">
+ <div class="text" ref="text">
+ <p class="channel" if={ p.channel != null }><a href={ CONFIG.chUrl + '/' + p.channel.id } target="_blank">{ p.channel.title }</a>:</p>
+ <a class="reply" if={ p.reply }>
+ <i class="fa fa-reply"></i>
+ </a>
+ <p class="dummy"></p>
+ <a class="quote" if={ p.repost != null }>RP:</a>
+ </div>
+ <div class="media" if={ p.media }>
+ <mk-images-viewer images={ p.media }/>
+ </div>
+ <mk-poll if={ p.poll } post={ p } ref="pollViewer"/>
+ <span class="app" if={ p.app }>via <b>{ p.app.name }</b></span>
+ <div class="repost" if={ p.repost }><i class="fa fa-quote-right fa-flip-horizontal"></i>
+ <mk-post-preview class="repost" post={ p.repost }/>
+ </div>
+ </div>
+ <footer>
+ <mk-reactions-viewer post={ p } ref="reactionsViewer"/>
+ <button onclick={ reply }>
+ <i class="fa fa-reply"></i><p class="count" if={ p.replies_count > 0 }>{ p.replies_count }</p>
+ </button>
+ <button onclick={ repost } title="Repost">
+ <i class="fa fa-retweet"></i><p class="count" if={ p.repost_count > 0 }>{ p.repost_count }</p>
+ </button>
+ <button class={ reacted: p.my_reaction != null } onclick={ react } ref="reactButton">
+ <i class="fa fa-plus"></i><p class="count" if={ p.reactions_count > 0 }>{ p.reactions_count }</p>
+ </button>
+ <button class="menu" onclick={ menu } ref="menuButton">
+ <i class="fa fa-ellipsis-h"></i>
+ </button>
+ </footer>
+ </div>
+ </article>
+ <style>
+ :scope
+ display block
+ margin 0
+ padding 0
+ 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
+
+ i
+ 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
+ white-space nowrap
+
+ @media (min-width 500px)
+ margin-bottom 2px
+
+ > .name
+ display block
+ margin 0 0.5em 0 0
+ padding 0
+ overflow hidden
+ color #777
+ font-size 1em
+ font-weight 700
+ text-align left
+ text-decoration none
+ text-overflow ellipsis
+
+ &:hover
+ text-decoration underline
+
+ > .is-bot
+ text-align left
+ margin 0 0.5em 0 0
+ padding 1px 6px
+ font-size 12px
+ color #aaa
+ border solid 1px #ddd
+ border-radius 3px
+
+ > .username
+ text-align left
+ margin 0 0.5em 0 0
+ color #ccc
+
+ > .created-at
+ margin-left auto
+ font-size 0.9em
+ color #c0c0c0
+
+ > .body
+
+ > .text
+ cursor default
+ display block
+ margin 0
+ padding 0
+ overflow-wrap break-word
+ font-size 1.1em
+ color #717171
+
+ > .dummy
+ display none
+
+ .link
+ &:after
+ content "\f14c"
+ display inline-block
+ padding-left 2px
+ font-family FontAwesome
+ font-size .9em
+ font-weight 400
+ font-style normal
+
+ mk-url-preview
+ margin-top 8px
+
+ > .channel
+ margin 0
+
+ > .reply
+ margin-right 8px
+ color #717171
+
+ > .quote
+ margin-left 4px
+ font-style oblique
+ color #a0bf46
+
+ 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
+
+ > .media
+ > img
+ display block
+ max-width 100%
+
+ > .app
+ font-size 12px
+ color #ccc
+
+ > mk-poll
+ font-size 80%
+
+ > .repost
+ margin 8px 0
+
+ > i:first-child
+ position absolute
+ top -8px
+ left -8px
+ z-index 1
+ color #c0dac6
+ font-size 28px
+ background #fff
+
+ > 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>
+ <script>
+ import compile from '../../common/scripts/text-compiler';
+ import getPostSummary from '../../../../common/get-post-summary.ts';
+ import openPostForm from '../scripts/open-post-form';
+
+ this.mixin('i');
+ this.mixin('api');
+ this.mixin('stream');
+
+ this.set = post => {
+ this.post = post;
+ this.isRepost = this.post.repost != null && this.post.text == null;
+ this.p = this.isRepost ? this.post.repost : this.post;
+ this.p.reactions_count = this.p.reaction_counts ? Object.keys(this.p.reaction_counts).map(key => this.p.reaction_counts[key]).reduce((a, b) => a + b) : 0;
+ this.summary = getPostSummary(this.p);
+ this.url = `/${this.p.user.username}/${this.p.id}`;
+ };
+
+ this.set(this.opts.post);
+
+ this.refresh = post => {
+ this.set(post);
+ this.update();
+ if (this.refs.reactionsViewer) this.refs.reactionsViewer.update({
+ post
+ });
+ if (this.refs.pollViewer) this.refs.pollViewer.init(post);
+ };
+
+ this.onStreamPostUpdated = data => {
+ const post = data.post;
+ if (post.id == this.post.id) {
+ this.refresh(post);
+ }
+ };
+
+ this.onStreamConnected = () => {
+ this.capture();
+ };
+
+ this.capture = withHandler => {
+ if (this.SIGNIN) {
+ this.stream.send({
+ type: 'capture',
+ id: this.post.id
+ });
+ if (withHandler) this.stream.on('post-updated', this.onStreamPostUpdated);
+ }
+ };
+
+ this.decapture = withHandler => {
+ if (this.SIGNIN) {
+ this.stream.send({
+ type: 'decapture',
+ id: this.post.id
+ });
+ if (withHandler) this.stream.off('post-updated', this.onStreamPostUpdated);
+ }
+ };
+
+ this.on('mount', () => {
+ this.capture(true);
+
+ if (this.SIGNIN) {
+ this.stream.on('_connected_', this.onStreamConnected);
+ }
+
+ if (this.p.text) {
+ const tokens = this.p.ast;
+
+ this.refs.text.innerHTML = this.refs.text.innerHTML.replace('<p class="dummy"></p>', compile(tokens));
+
+ this.refs.text.children.forEach(e => {
+ if (e.tagName == 'MK-URL') riot.mount(e);
+ });
+
+ // URLをプレビュー
+ tokens
+ .filter(t => (t.type == 'url' || t.type == 'link') && !t.silent)
+ .map(t => {
+ riot.mount(this.refs.text.appendChild(document.createElement('mk-url-preview')), {
+ url: t.url
+ });
+ });
+ }
+ });
+
+ this.on('unmount', () => {
+ this.decapture(true);
+ this.stream.off('_connected_', this.onStreamConnected);
+ });
+
+ this.reply = () => {
+ openPostForm({
+ reply: this.p
+ });
+ };
+
+ this.repost = () => {
+ const text = window.prompt(`「${this.summary}」をRepost`);
+ if (text == null) return;
+ this.api('posts/create', {
+ repost_id: this.p.id,
+ text: text == '' ? undefined : text
+ });
+ };
+
+ this.react = () => {
+ riot.mount(document.body.appendChild(document.createElement('mk-reaction-picker')), {
+ source: this.refs.reactButton,
+ post: this.p,
+ compact: true
+ });
+ };
+
+ this.menu = () => {
+ riot.mount(document.body.appendChild(document.createElement('mk-post-menu')), {
+ source: this.refs.menuButton,
+ post: this.p,
+ compact: true
+ });
+ };
+ </script>
+</mk-timeline-post>
+
+<mk-timeline-post-sub>
+ <article><a class="avatar-anchor" href={ '/' + post.user.username }><img class="avatar" src={ post.user.avatar_url + '?thumbnail&size=96' } alt="avatar"/></a>
+ <div class="main">
+ <header><a class="name" href={ '/' + post.user.username }>{ post.user.name }</a><span class="username">@{ post.user.username }</span><a class="created-at" href={ '/' + post.user.username + '/' + post.id }>
+ <mk-time time={ post.created_at }/></a></header>
+ <div class="body">
+ <mk-sub-post-content class="text" post={ post }/>
+ </div>
+ </div>
+ </article>
+ <style>
+ :scope
+ display block
+ margin 0
+ padding 0
+ font-size 0.9em
+
+ > article
+ padding 16px
+
+ &:after
+ content ""
+ display block
+ clear both
+
+ &:hover
+ > .main > footer > button
+ color #888
+
+ > .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>
+ <script>this.post = this.opts.post</script>
+</mk-timeline-post-sub>
diff --git a/src/web/app/mobile/tags/ui-header.tag b/src/web/app/mobile/tags/ui-header.tag
deleted file mode 100644
index 10b44b2153..0000000000
--- a/src/web/app/mobile/tags/ui-header.tag
+++ /dev/null
@@ -1,156 +0,0 @@
-<mk-ui-header>
- <mk-special-message/>
- <div class="main">
- <div class="backdrop"></div>
- <div class="content">
- <button class="nav" onclick={ parent.toggleDrawer }><i class="fa fa-bars"></i></button>
- <i class="fa fa-circle" if={ hasUnreadMessagingMessages }></i>
- <h1 ref="title">Misskey</h1>
- <button if={ func } onclick={ func }><i class="fa fa-{ funcIcon }"></i></button>
- </div>
- </div>
- <style>
- :scope
- $height = 48px
-
- display block
- position fixed
- top 0
- z-index 1024
- width 100%
- box-shadow 0 1px 0 rgba(#000, 0.075)
-
- > .main
- color rgba(#fff, 0.9)
-
- > .backdrop
- position absolute
- top 0
- z-index 1023
- width 100%
- height $height
- -webkit-backdrop-filter blur(12px)
- backdrop-filter blur(12px)
- background-color rgba(#1b2023, 0.75)
-
- > .content
- z-index 1024
-
- > h1
- display block
- margin 0 auto
- padding 0
- width 100%
- max-width calc(100% - 112px)
- text-align center
- font-size 1.1em
- font-weight normal
- line-height $height
- white-space nowrap
- overflow hidden
- text-overflow ellipsis
-
- > i
- > .icon
- margin-right 8px
-
- > img
- display inline-block
- vertical-align bottom
- width ($height - 16px)
- height ($height - 16px)
- margin 8px
- border-radius 6px
-
- > .nav
- display block
- position absolute
- top 0
- left 0
- width $height
- font-size 1.4em
- line-height $height
- border-right solid 1px rgba(#000, 0.1)
-
- > i
- transition all 0.2s ease
-
- > i
- position absolute
- top 8px
- left 8px
- pointer-events none
- font-size 10px
- color $theme-color
-
- > button:last-child
- display block
- position absolute
- top 0
- right 0
- width $height
- text-align center
- font-size 1.4em
- color inherit
- line-height $height
- border-left solid 1px rgba(#000, 0.1)
-
- </style>
- <script>
- import ui from '../scripts/ui-event';
-
- this.mixin('api');
- this.mixin('stream');
-
- this.func = null;
- this.funcIcon = null;
-
- this.on('mount', () => {
- this.stream.on('read_all_messaging_messages', this.onReadAllMessagingMessages);
- this.stream.on('unread_messaging_message', this.onUnreadMessagingMessage);
-
- // Fetch count of unread messaging messages
- this.api('messaging/unread').then(res => {
- if (res.count > 0) {
- this.update({
- hasUnreadMessagingMessages: true
- });
- }
- });
- });
-
- this.on('unmount', () => {
- this.stream.off('read_all_messaging_messages', this.onReadAllMessagingMessages);
- this.stream.off('unread_messaging_message', this.onUnreadMessagingMessage);
-
- ui.off('title', this.setTitle);
- ui.off('func', this.setFunc);
- });
-
- this.onReadAllMessagingMessages = () => {
- this.update({
- hasUnreadMessagingMessages: false
- });
- };
-
- this.onUnreadMessagingMessage = () => {
- this.update({
- hasUnreadMessagingMessages: true
- });
- };
-
- this.setTitle = title => {
- this.refs.title.innerHTML = title;
- };
-
- this.setFunc = (fn, icon) => {
- this.update({
- func: fn,
- funcIcon: icon
- });
- };
-
- ui.on('title', this.setTitle);
- ui.on('func', this.setFunc);
- </script>
-</mk-ui-header>
diff --git a/src/web/app/mobile/tags/ui-nav.tag b/src/web/app/mobile/tags/ui-nav.tag
deleted file mode 100644
index 76c43ade66..0000000000
--- a/src/web/app/mobile/tags/ui-nav.tag
+++ /dev/null
@@ -1,169 +0,0 @@
-<mk-ui-nav>
- <div class="backdrop" onclick={ parent.toggleDrawer }></div>
- <div class="body">
- <a class="me" if={ SIGNIN } href={ '/' + I.username }>
- <img class="avatar" src={ I.avatar_url + '?thumbnail&size=128' } alt="avatar"/>
- <p class="name">{ I.name }</p>
- </a>
- <div class="links">
- <ul>
- <li><a href="/"><i class="fa fa-home"></i>%i18n:mobile.tags.mk-ui-nav.home%<i class="fa fa-angle-right"></i></a></li>
- <li><a href="/i/notifications"><i class="fa fa-bell-o"></i>%i18n:mobile.tags.mk-ui-nav.notifications%<i class="fa fa-angle-right"></i></a></li>
- <li><a href="/i/messaging"><i class="fa fa-comments-o"></i>%i18n:mobile.tags.mk-ui-nav.messaging%<i class="i fa fa-circle" if={ hasUnreadMessagingMessages }></i><i class="fa fa-angle-right"></i></a></li>
- </ul>
- <ul>
- <li><a onclick={ search }><i class="fa fa-search"></i>%i18n:mobile.tags.mk-ui-nav.search%<i class="fa fa-angle-right"></i></a></li>
- </ul>
- <ul>
- <li><a href="/i/drive"><i class="fa fa-cloud"></i>%i18n:mobile.tags.mk-ui-nav.drive%<i class="fa fa-angle-right"></i></a></li>
- </ul>
- <ul>
- <li><a href="/i/settings"><i class="fa fa-cog"></i>%i18n:mobile.tags.mk-ui-nav.settings%<i class="fa fa-angle-right"></i></a></li>
- </ul>
- </div>
- <a href={ CONFIG.aboutUrl }><p class="about">%i18n:mobile.tags.mk-ui-nav.about%</p></a>
- </div>
- <style>
- :scope
- display none
-
- .backdrop
- position fixed
- top 0
- left 0
- z-index 1025
- width 100%
- height 100%
- background rgba(0, 0, 0, 0.2)
-
- .body
- position fixed
- top 0
- left 0
- z-index 1026
- width 240px
- height 100%
- overflow auto
- color #777
- background #fff
-
- .me
- display block
- margin 0
- padding 16px
-
- .avatar
- display inline
- max-width 64px
- border-radius 32px
- vertical-align middle
-
- .name
- display block
- margin 0 16px
- position absolute
- top 0
- left 80px
- padding 0
- width calc(100% - 112px)
- color #777
- line-height 96px
- overflow hidden
- text-overflow ellipsis
- white-space nowrap
-
- ul
- display block
- margin 16px 0
- padding 0
- list-style none
-
- &:first-child
- margin-top 0
-
- li
- display block
- font-size 1em
- line-height 1em
-
- a
- display block
- padding 0 20px
- line-height 3rem
- line-height calc(1rem + 30px)
- color #777
- text-decoration none
-
- > i:first-child
- margin-right 0.5em
-
- > .i
- margin-left 6px
- vertical-align super
- font-size 10px
- color $theme-color
-
- > i:last-child
- position absolute
- top 0
- right 0
- padding 0 20px
- font-size 1.2em
- line-height calc(1rem + 30px)
- color #ccc
-
- .about
- margin 0
- padding 1em 0
- text-align center
- font-size 0.8em
- opacity 0.5
-
- a
- color #777
-
- </style>
- <script>
- this.mixin('i');
- this.mixin('page');
- this.mixin('api');
- this.mixin('stream');
-
- this.on('mount', () => {
- this.stream.on('read_all_messaging_messages', this.onReadAllMessagingMessages);
- this.stream.on('unread_messaging_message', this.onUnreadMessagingMessage);
-
- // Fetch count of unread messaging messages
- this.api('messaging/unread').then(res => {
- if (res.count > 0) {
- this.update({
- hasUnreadMessagingMessages: true
- });
- }
- });
- });
-
- this.on('unmount', () => {
- this.stream.off('read_all_messaging_messages', this.onReadAllMessagingMessages);
- this.stream.off('unread_messaging_message', this.onUnreadMessagingMessage);
- });
-
- this.onReadAllMessagingMessages = () => {
- this.update({
- hasUnreadMessagingMessages: false
- });
- };
-
- this.onUnreadMessagingMessage = () => {
- this.update({
- hasUnreadMessagingMessages: true
- });
- };
-
- this.search = () => {
- const query = window.prompt('%i18n:mobile.tags.mk-ui-nav.search%');
- if (query == null || query == '') return;
- this.page('/search:' + query);
- };
- </script>
-</mk-ui-nav>
diff --git a/src/web/app/mobile/tags/ui.tag b/src/web/app/mobile/tags/ui.tag
index b2f738dc2e..b2d96f6b8b 100644
--- a/src/web/app/mobile/tags/ui.tag
+++ b/src/web/app/mobile/tags/ui.tag
@@ -4,7 +4,7 @@
<div class="content">
<yield />
</div>
- <mk-stream-indicator/>
+ <mk-stream-indicator if={ SIGNIN }/>
<style>
:scope
display block
@@ -30,9 +30,378 @@
};
this.onStreamNotification = notification => {
+ // TODO: ユーザーが画面を見てないと思われるとき(ブラウザやタブがアクティブじゃないなど)は送信しない
+ this.stream.send({
+ type: 'read_notification',
+ id: notification.id
+ });
+
riot.mount(document.body.appendChild(document.createElement('mk-notify')), {
notification: notification
});
};
</script>
</mk-ui>
+
+<mk-ui-header>
+ <mk-special-message/>
+ <div class="main">
+ <div class="backdrop"></div>
+ <div class="content">
+ <button class="nav" onclick={ parent.toggleDrawer }><i class="fa fa-bars"></i></button>
+ <i class="fa fa-circle" if={ hasUnreadNotifications || hasUnreadMessagingMessages }></i>
+ <h1 ref="title">Misskey</h1>
+ <button if={ func } onclick={ func }><i class="fa fa-{ funcIcon }"></i></button>
+ </div>
+ </div>
+ <style>
+ :scope
+ $height = 48px
+
+ display block
+ position fixed
+ top 0
+ z-index 1024
+ width 100%
+ box-shadow 0 1px 0 rgba(#000, 0.075)
+
+ > .main
+ color rgba(#fff, 0.9)
+
+ > .backdrop
+ position absolute
+ top 0
+ z-index 1023
+ width 100%
+ height $height
+ -webkit-backdrop-filter blur(12px)
+ backdrop-filter blur(12px)
+ background-color rgba(#1b2023, 0.75)
+
+ > .content
+ z-index 1024
+
+ > h1
+ display block
+ margin 0 auto
+ padding 0
+ width 100%
+ max-width calc(100% - 112px)
+ text-align center
+ font-size 1.1em
+ font-weight normal
+ line-height $height
+ white-space nowrap
+ overflow hidden
+ text-overflow ellipsis
+
+ > i
+ > .icon
+ margin-right 8px
+
+ > img
+ display inline-block
+ vertical-align bottom
+ width ($height - 16px)
+ height ($height - 16px)
+ margin 8px
+ border-radius 6px
+
+ > .nav
+ display block
+ position absolute
+ top 0
+ left 0
+ width $height
+ font-size 1.4em
+ line-height $height
+ border-right solid 1px rgba(#000, 0.1)
+
+ > i
+ transition all 0.2s ease
+
+ > i
+ position absolute
+ top 8px
+ left 8px
+ pointer-events none
+ font-size 10px
+ color $theme-color
+
+ > button:last-child
+ display block
+ position absolute
+ top 0
+ right 0
+ width $height
+ text-align center
+ font-size 1.4em
+ color inherit
+ line-height $height
+ border-left solid 1px rgba(#000, 0.1)
+
+ </style>
+ <script>
+ import ui from '../scripts/ui-event';
+
+ this.mixin('api');
+ this.mixin('stream');
+
+ this.func = null;
+ this.funcIcon = null;
+
+ this.on('mount', () => {
+ this.stream.on('read_all_notifications', this.onReadAllNotifications);
+ this.stream.on('read_all_messaging_messages', this.onReadAllMessagingMessages);
+ this.stream.on('unread_messaging_message', this.onUnreadMessagingMessage);
+
+ // Fetch count of unread notifications
+ this.api('notifications/get_unread_count').then(res => {
+ if (res.count > 0) {
+ this.update({
+ hasUnreadNotifications: true
+ });
+ }
+ });
+
+ // Fetch count of unread messaging messages
+ this.api('messaging/unread').then(res => {
+ if (res.count > 0) {
+ this.update({
+ hasUnreadMessagingMessages: true
+ });
+ }
+ });
+ });
+
+ this.on('unmount', () => {
+ this.stream.off('read_all_notifications', this.onReadAllNotifications);
+ this.stream.off('read_all_messaging_messages', this.onReadAllMessagingMessages);
+ this.stream.off('unread_messaging_message', this.onUnreadMessagingMessage);
+
+ ui.off('title', this.setTitle);
+ ui.off('func', this.setFunc);
+ });
+
+ this.onReadAllNotifications = () => {
+ this.update({
+ hasUnreadNotifications: false
+ });
+ };
+
+ this.onReadAllMessagingMessages = () => {
+ this.update({
+ hasUnreadMessagingMessages: false
+ });
+ };
+
+ this.onUnreadMessagingMessage = () => {
+ this.update({
+ hasUnreadMessagingMessages: true
+ });
+ };
+
+ this.setTitle = title => {
+ this.refs.title.innerHTML = title;
+ };
+
+ this.setFunc = (fn, icon) => {
+ this.update({
+ func: fn,
+ funcIcon: icon
+ });
+ };
+
+ ui.on('title', this.setTitle);
+ ui.on('func', this.setFunc);
+ </script>
+</mk-ui-header>
+
+<mk-ui-nav>
+ <div class="backdrop" onclick={ parent.toggleDrawer }></div>
+ <div class="body">
+ <a class="me" if={ SIGNIN } href={ '/' + I.username }>
+ <img class="avatar" src={ I.avatar_url + '?thumbnail&size=128' } alt="avatar"/>
+ <p class="name">{ I.name }</p>
+ </a>
+ <div class="links">
+ <ul>
+ <li><a href="/"><i class="fa fa-home"></i>%i18n:mobile.tags.mk-ui-nav.home%<i class="fa fa-angle-right"></i></a></li>
+ <li><a href="/i/notifications"><i class="fa fa-bell-o"></i>%i18n:mobile.tags.mk-ui-nav.notifications%<i class="i fa fa-circle" if={ hasUnreadNotifications }></i><i class="fa fa-angle-right"></i></a></li>
+ <li><a href="/i/messaging"><i class="fa fa-comments-o"></i>%i18n:mobile.tags.mk-ui-nav.messaging%<i class="i fa fa-circle" if={ hasUnreadMessagingMessages }></i><i class="fa fa-angle-right"></i></a></li>
+ </ul>
+ <ul>
+ <li><a href={ CONFIG.chUrl } target="_blank"><i class="fa fa-television"></i>%i18n:mobile.tags.mk-ui-nav.ch%<i class="fa fa-angle-right"></i></a></li>
+ <li><a href="/i/drive"><i class="fa fa-cloud"></i>%i18n:mobile.tags.mk-ui-nav.drive%<i class="fa fa-angle-right"></i></a></li>
+ </ul>
+ <ul>
+ <li><a onclick={ search }><i class="fa fa-search"></i>%i18n:mobile.tags.mk-ui-nav.search%<i class="fa fa-angle-right"></i></a></li>
+ </ul>
+ <ul>
+ <li><a href="/i/settings"><i class="fa fa-cog"></i>%i18n:mobile.tags.mk-ui-nav.settings%<i class="fa fa-angle-right"></i></a></li>
+ </ul>
+ </div>
+ <a href={ CONFIG.aboutUrl }><p class="about">%i18n:mobile.tags.mk-ui-nav.about%</p></a>
+ </div>
+ <style>
+ :scope
+ display none
+
+ .backdrop
+ position fixed
+ top 0
+ left 0
+ z-index 1025
+ width 100%
+ height 100%
+ background rgba(0, 0, 0, 0.2)
+
+ .body
+ position fixed
+ top 0
+ left 0
+ z-index 1026
+ width 240px
+ height 100%
+ overflow auto
+ -webkit-overflow-scrolling touch
+ color #777
+ background #fff
+
+ .me
+ display block
+ margin 0
+ padding 16px
+
+ .avatar
+ display inline
+ max-width 64px
+ border-radius 32px
+ vertical-align middle
+
+ .name
+ display block
+ margin 0 16px
+ position absolute
+ top 0
+ left 80px
+ padding 0
+ width calc(100% - 112px)
+ color #777
+ line-height 96px
+ overflow hidden
+ text-overflow ellipsis
+ white-space nowrap
+
+ ul
+ display block
+ margin 16px 0
+ padding 0
+ list-style none
+
+ &:first-child
+ margin-top 0
+
+ li
+ display block
+ font-size 1em
+ line-height 1em
+
+ a
+ display block
+ padding 0 20px
+ line-height 3rem
+ line-height calc(1rem + 30px)
+ color #777
+ text-decoration none
+
+ > i:first-child
+ margin-right 0.5em
+
+ > .i
+ margin-left 6px
+ vertical-align super
+ font-size 10px
+ color $theme-color
+
+ > i:last-child
+ position absolute
+ top 0
+ right 0
+ padding 0 20px
+ font-size 1.2em
+ line-height calc(1rem + 30px)
+ color #ccc
+
+ .about
+ margin 0
+ padding 1em 0
+ text-align center
+ font-size 0.8em
+ opacity 0.5
+
+ a
+ color #777
+
+ </style>
+ <script>
+ this.mixin('i');
+ this.mixin('page');
+ this.mixin('api');
+ this.mixin('stream');
+
+ this.on('mount', () => {
+ this.stream.on('read_all_notifications', this.onReadAllNotifications);
+ this.stream.on('read_all_messaging_messages', this.onReadAllMessagingMessages);
+ this.stream.on('unread_messaging_message', this.onUnreadMessagingMessage);
+
+ // Fetch count of unread notifications
+ this.api('notifications/get_unread_count').then(res => {
+ if (res.count > 0) {
+ this.update({
+ hasUnreadNotifications: true
+ });
+ }
+ });
+
+ // Fetch count of unread messaging messages
+ this.api('messaging/unread').then(res => {
+ if (res.count > 0) {
+ this.update({
+ hasUnreadMessagingMessages: true
+ });
+ }
+ });
+ });
+
+ this.on('unmount', () => {
+ this.stream.off('read_all_notifications', this.onReadAllNotifications);
+ this.stream.off('read_all_messaging_messages', this.onReadAllMessagingMessages);
+ this.stream.off('unread_messaging_message', this.onUnreadMessagingMessage);
+ });
+
+ this.onReadAllNotifications = () => {
+ this.update({
+ hasUnreadNotifications: false
+ });
+ };
+
+ this.onReadAllMessagingMessages = () => {
+ this.update({
+ hasUnreadMessagingMessages: false
+ });
+ };
+
+ this.onUnreadMessagingMessage = () => {
+ this.update({
+ hasUnreadMessagingMessages: true
+ });
+ };
+
+ this.search = () => {
+ const query = window.prompt('%i18n:mobile.tags.mk-ui-nav.search%');
+ if (query == null || query == '') return;
+ this.page('/search:' + query);
+ };
+ </script>
+</mk-ui-nav>
diff --git a/src/web/app/mobile/tags/user-card.tag b/src/web/app/mobile/tags/user-card.tag
new file mode 100644
index 0000000000..d0c79698c5
--- /dev/null
+++ b/src/web/app/mobile/tags/user-card.tag
@@ -0,0 +1,55 @@
+<mk-user-card>
+ <header style={ user.banner_url ? 'background-image: url(' + user.banner_url + '?thumbnail&size=1024)' : '' }>
+ <a href={ '/' + user.username }>
+ <img src={ user.avatar_url + '?thumbnail&size=200' } alt="avatar"/>
+ </a>
+ </header>
+ <a class="name" href={ '/' + user.username } target="_blank">{ user.name }</a>
+ <p class="username">@{ user.username }</p>
+ <mk-follow-button user={ user }/>
+ <style>
+ :scope
+ 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>
+ <script>
+ this.user = this.opts.user;
+ </script>
+</mk-user-card>
diff --git a/src/web/app/mobile/tags/user-timeline.tag b/src/web/app/mobile/tags/user-timeline.tag
index f7b2b36da0..4dbe719f5a 100644
--- a/src/web/app/mobile/tags/user-timeline.tag
+++ b/src/web/app/mobile/tags/user-timeline.tag
@@ -5,8 +5,6 @@
display block
max-width 600px
margin 0 auto
- background #fff
-
</style>
<script>
this.mixin('api');
diff --git a/src/web/app/mobile/tags/user.tag b/src/web/app/mobile/tags/user.tag
index 81eb6ba2e4..a332e930e2 100644
--- a/src/web/app/mobile/tags/user.tag
+++ b/src/web/app/mobile/tags/user.tag
@@ -12,7 +12,7 @@
<div class="title">
<h1>{ user.name }</h1>
<span class="username">@{ user.username }</span>
- <span class="followed" if={ user.is_followed }>%i18n:mobile.tags.mk-user.is-followed%</span>
+ <span class="followed" if={ user.is_followed }>%i18n:mobile.tags.mk-user.follows-you%</span>
</div>
<div class="description">{ user.description }</div>
<div class="info">
@@ -26,7 +26,7 @@
<div class="status">
<a>
<b>{ user.posts_count }</b>
- <i>%i18n:mobile.tags.mk-user.posts-count%</i>
+ <i>%i18n:mobile.tags.mk-user.posts%</i>
</a>
<a href="{ user.username }/following">
<b>{ user.following_count }</b>
@@ -37,14 +37,15 @@
<i>%i18n:mobile.tags.mk-user.followers%</i>
</a>
</div>
- <mk-activity-table user={ user }/>
</div>
<nav>
- <a data-is-active={ page == 'posts' } onclick={ go.bind(null, 'posts') }>%i18n:mobile.tags.mk-user.posts%</a>
+ <a data-is-active={ page == 'overview' } onclick={ go.bind(null, 'overview') }>%i18n:mobile.tags.mk-user.overview%</a>
+ <a data-is-active={ page == 'posts' } onclick={ go.bind(null, 'posts') }>%i18n:mobile.tags.mk-user.timeline%</a>
<a data-is-active={ page == 'media' } onclick={ go.bind(null, 'media') }>%i18n:mobile.tags.mk-user.media%</a>
</nav>
</header>
<div class="body">
+ <mk-user-overview if={ page == 'overview' } user={ user }/>
<mk-user-timeline if={ page == 'posts' } user={ user }/>
<mk-user-timeline if={ page == 'media' } user={ user } with-media={ true }/>
</div>
@@ -55,9 +56,11 @@
> .user
> header
+ box-shadow 0 4px 4px rgba(0, 0, 0, 0.3)
+
> .banner
padding-bottom 33.3%
- background-color #f5f5f5
+ background-color #1b1b1b
background-size cover
background-position center
@@ -84,13 +87,13 @@
left -2px
bottom -2px
width 100%
- border 2px solid #fff
+ border 2px solid #313a42
border-radius 6px
@media (min-width 500px)
left -4px
bottom -4px
- border 4px solid #fff
+ border 4px solid #313a42
border-radius 12px
> mk-follow-button
@@ -104,7 +107,7 @@
margin 0
line-height 22px
font-size 20px
- color #222
+ color #fff
> .username
display inline-block
@@ -123,7 +126,7 @@
> .description
margin 8px 0
- color #333
+ color #fff
> .info
margin 8px 0
@@ -131,7 +134,7 @@
> p
display inline
margin 0 16px 0 0
- color #555
+ color #a9b9c1
> i
margin-right 4px
@@ -140,13 +143,13 @@
> a
color #657786
- &:first-child
+ &:not(:last-child)
margin-right 16px
> b
margin-right 4px
font-size 16px
- color #14171a
+ color #fff
> i
font-size 14px
@@ -159,7 +162,6 @@
justify-content center
margin 0 auto
max-width 600px
- border-bottom solid 1px #ddd
> a
display block
@@ -177,8 +179,10 @@
border-color $theme-color
> .body
+ padding 8px
+
@media (min-width 500px)
- padding 16px 0 0 0
+ padding 16px
</style>
<script>
@@ -188,7 +192,7 @@
this.mixin('api');
this.username = this.opts.user;
- this.page = this.opts.page ? this.opts.page : 'posts';
+ this.page = this.opts.page ? this.opts.page : 'overview';
this.fetching = true;
this.on('mount', () => {
@@ -209,3 +213,523 @@
};
</script>
</mk-user>
+
+<mk-user-overview>
+ <mk-post-detail if={ user.pinned_post } post={ user.pinned_post } compact={ true }/>
+ <section class="recent-posts">
+ <h2><i class="fa fa-comments-o"></i>%i18n:mobile.tags.mk-user-overview.recent-posts%</h2>
+ <div>
+ <mk-user-overview-posts user={ user }/>
+ </div>
+ </section>
+ <section class="images">
+ <h2><i class="fa fa-picture-o"></i>%i18n:mobile.tags.mk-user-overview.images%</h2>
+ <div>
+ <mk-user-overview-photos user={ user }/>
+ </div>
+ </section>
+ <section class="activity">
+ <h2><i class="fa fa-bar-chart"></i>%i18n:mobile.tags.mk-user-overview.activity%</h2>
+ <div>
+ <mk-user-overview-activity-chart user={ user }/>
+ </div>
+ </section>
+ <section class="keywords">
+ <h2><i class="fa fa-comment-o"></i>%i18n:mobile.tags.mk-user-overview.keywords%</h2>
+ <div>
+ <mk-user-overview-keywords user={ user }/>
+ </div>
+ </section>
+ <section class="domains">
+ <h2><i class="fa fa-globe"></i>%i18n:mobile.tags.mk-user-overview.domains%</h2>
+ <div>
+ <mk-user-overview-domains user={ user }/>
+ </div>
+ </section>
+ <section class="frequently-replied-users">
+ <h2><i class="fa fa-users"></i>%i18n:mobile.tags.mk-user-overview.frequently-replied-users%</h2>
+ <div>
+ <mk-user-overview-frequently-replied-users user={ user }/>
+ </div>
+ </section>
+ <section class="followers-you-know" if={ SIGNIN && I.id !== user.id }>
+ <h2><i class="fa fa-users"></i>%i18n:mobile.tags.mk-user-overview.followers-you-know%</h2>
+ <div>
+ <mk-user-overview-followers-you-know user={ user }/>
+ </div>
+ </section>
+ <p>%i18n:mobile.tags.mk-user-overview.last-used-at%: <b><mk-time time={ user.last_used_at }/></b></p>
+ <style>
+ :scope
+ display block
+ 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>
+ <script>
+ this.mixin('i');
+
+ this.user = this.opts.user;
+ </script>
+</mk-user-overview>
+
+<mk-user-overview-posts>
+ <p class="initializing" if={ initializing }><i class="fa fa-spinner fa-pulse fa-fw"></i>%i18n:mobile.tags.mk-user-overview-posts.loading%<mk-ellipsis/></p>
+ <div if={ !initializing && posts.length > 0 }>
+ <virtual each={ posts }>
+ <mk-user-overview-posts-post-card post={ this }/>
+ </virtual>
+ </div>
+ <p class="empty" if={ !initializing && posts.length == 0 }>%i18n:mobile.tags.mk-user-overview-posts.no-posts%</p>
+ <style>
+ :scope
+ display block
+
+ > div
+ overflow-x scroll
+ -webkit-overflow-scrolling touch
+ white-space nowrap
+ padding 8px
+
+ > *
+ vertical-align top
+
+ &:not(:last-child)
+ margin-right 8px
+
+ > .initializing
+ > .empty
+ margin 0
+ padding 16px
+ text-align center
+ color #aaa
+
+ > i
+ margin-right 4px
+
+ </style>
+ <script>
+ this.mixin('api');
+
+ this.user = this.opts.user;
+ this.initializing = true;
+
+ this.on('mount', () => {
+ this.api('users/posts', {
+ user_id: this.user.id
+ }).then(posts => {
+ this.update({
+ posts: posts,
+ initializing: false
+ });
+ });
+ });
+ </script>
+</mk-user-overview-posts>
+
+<mk-user-overview-posts-post-card>
+ <a href={ '/' + post.user.username + '/' + post.id }>
+ <header>
+ <img src={ post.user.avatar_url + '?thumbnail&size=64' } alt="avatar"/><h3>{ post.user.name }</h3>
+ </header>
+ <div>
+ { text }
+ </div>
+ <mk-time time={ post.created_at }/>
+ </a>
+ <style>
+ :scope
+ 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>
+ <script>
+ import summary from '../../../../common/get-post-summary.ts';
+
+ this.post = this.opts.post;
+ this.text = summary(this.post);
+ </script>
+</mk-user-overview-posts-post-card>
+
+<mk-user-overview-photos>
+ <p class="initializing" if={ initializing }><i class="fa fa-spinner fa-pulse fa-fw"></i>%i18n:mobile.tags.mk-user-overview-photos.loading%<mk-ellipsis/></p>
+ <div class="stream" if={ !initializing && images.length > 0 }>
+ <virtual each={ image in images }>
+ <a class="img" style={ 'background-image: url(' + image.media.url + '?thumbnail&size=256)' } href={ '/' + image.post.user.username + '/' + image.post.id }></a>
+ </virtual>
+ </div>
+ <p class="empty" if={ !initializing && images.length == 0 }>%i18n:mobile.tags.mk-user-overview-photos.no-photos%</p>
+ <style>
+ :scope
+ display block
+
+ > .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>
+ <script>
+ this.mixin('api');
+
+ this.images = [];
+ this.initializing = true;
+ this.user = this.opts.user;
+
+ this.on('mount', () => {
+ this.api('users/posts', {
+ user_id: this.user.id,
+ with_media: true,
+ limit: 6
+ }).then(posts => {
+ this.initializing = false;
+ posts.forEach(post => {
+ post.media.forEach(media => {
+ if (this.images.length < 9) this.images.push({
+ post,
+ media
+ });
+ });
+ });
+ this.update();
+ });
+ });
+ </script>
+</mk-user-overview-photos>
+
+<mk-user-overview-activity-chart>
+ <svg if={ data } ref="canvas" viewBox="0 0 30 1" preserveAspectRatio="none">
+ <g each={ d, i in data.reverse() }>
+ <rect width="0.8" riot-height={ d.postsH }
+ riot-x={ i + 0.1 } riot-y={ 1 - d.postsH - d.repliesH - d.repostsH }
+ fill="#41ddde"/>
+ <rect width="0.8" riot-height={ d.repliesH }
+ riot-x={ i + 0.1 } riot-y={ 1 - d.repliesH - d.repostsH }
+ fill="#f7796c"/>
+ <rect width="0.8" riot-height={ d.repostsH }
+ riot-x={ i + 0.1 } riot-y={ 1 - d.repostsH }
+ fill="#a1de41"/>
+ </g>
+ </svg>
+ <style>
+ :scope
+ display block
+ max-width 600px
+ margin 0 auto
+
+ > svg
+ display block
+ width 100%
+ height 80px
+
+ > rect
+ transform-origin center
+
+ </style>
+ <script>
+ this.mixin('api');
+
+ this.user = this.opts.user;
+
+ this.on('mount', () => {
+ this.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;
+ });
+ this.update({ data });
+ });
+ });
+ </script>
+</mk-user-overview-activity-chart>
+
+<mk-user-overview-keywords>
+ <div if={ user.keywords != null && user.keywords.length > 1 }>
+ <virtual each={ keyword in user.keywords }>
+ <a>{ keyword }</a>
+ </virtual>
+ </div>
+ <p class="empty" if={ user.keywords == null || user.keywords.length == 0 }>%i18n:mobile.tags.mk-user-overview-keywords.no-keywords%</p>
+ <style>
+ :scope
+ display block
+
+ > div
+ padding 4px
+
+ > a
+ display inline-block
+ margin 4px
+ color #555
+
+ > .empty
+ margin 0
+ padding 16px
+ text-align center
+ color #aaa
+
+ > i
+ margin-right 4px
+
+ </style>
+ <script>
+ this.user = this.opts.user;
+ </script>
+</mk-user-overview-keywords>
+
+<mk-user-overview-domains>
+ <div if={ user.domains != null && user.domains.length > 1 }>
+ <virtual each={ domain in user.domains }>
+ <a style="opacity: { 0.5 + (domain.weight / 2) }">{ domain.domain }</a>
+ </virtual>
+ </div>
+ <p class="empty" if={ user.domains == null || user.domains.length == 0 }>%i18n:mobile.tags.mk-user-overview-domains.no-domains%</p>
+ <style>
+ :scope
+ display block
+
+ > div
+ padding 4px
+
+ > a
+ display inline-block
+ margin 4px
+ color #555
+
+ > .empty
+ margin 0
+ padding 16px
+ text-align center
+ color #aaa
+
+ > i
+ margin-right 4px
+
+ </style>
+ <script>
+ this.user = this.opts.user;
+ </script>
+</mk-user-overview-domains>
+
+<mk-user-overview-frequently-replied-users>
+ <p class="initializing" if={ initializing }><i class="fa fa-spinner fa-pulse fa-fw"></i>%i18n:mobile.tags.mk-user-overview-frequently-replied-users.loading%<mk-ellipsis/></p>
+ <div if={ !initializing && users.length > 0 }>
+ <virtual each={ users }>
+ <mk-user-card user={ this.user }/>
+ </virtual>
+ </div>
+ <p class="empty" if={ !initializing && users.length == 0 }>%i18n:mobile.tags.mk-user-overview-frequently-replied-users.no-users%</p>
+ <style>
+ :scope
+ display block
+
+ > div
+ overflow-x scroll
+ -webkit-overflow-scrolling touch
+ white-space nowrap
+ padding 8px
+
+ > mk-user-card
+ &:not(:last-child)
+ margin-right 8px
+
+ > .initializing
+ > .empty
+ margin 0
+ padding 16px
+ text-align center
+ color #aaa
+
+ > i
+ margin-right 4px
+
+ </style>
+ <script>
+ this.mixin('api');
+
+ this.user = this.opts.user;
+ this.initializing = true;
+
+ this.on('mount', () => {
+ this.api('users/get_frequently_replied_users', {
+ user_id: this.user.id
+ }).then(x => {
+ this.update({
+ users: x,
+ initializing: false
+ });
+ });
+ });
+ </script>
+</mk-user-overview-frequently-replied-users>
+
+<mk-user-overview-followers-you-know>
+ <p class="initializing" if={ initializing }><i class="fa fa-spinner fa-pulse fa-fw"></i>%i18n:mobile.tags.mk-user-overview-followers-you-know.loading%<mk-ellipsis/></p>
+ <div if={ !initializing && users.length > 0 }>
+ <virtual each={ user in users }>
+ <a href={ '/' + user.username }><img src={ user.avatar_url + '?thumbnail&size=64' } alt={ user.name }/></a>
+ </virtual>
+ </div>
+ <p class="empty" if={ !initializing && users.length == 0 }>%i18n:mobile.tags.mk-user-overview-followers-you-know.no-users%</p>
+ <style>
+ :scope
+ display block
+
+ > 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>
+ <script>
+ this.mixin('api');
+
+ this.user = this.opts.user;
+ this.initializing = true;
+
+ this.on('mount', () => {
+ this.api('users/followers', {
+ user_id: this.user.id,
+ iknow: true,
+ limit: 30
+ }).then(x => {
+ this.update({
+ users: x.users,
+ initializing: false
+ });
+ });
+ });
+ </script>
+</mk-user-overview-followers-you-know>
diff --git a/src/web/app/mobile/tags/users-list.tag b/src/web/app/mobile/tags/users-list.tag
index fb70f184d5..295ae06694 100644
--- a/src/web/app/mobile/tags/users-list.tag
+++ b/src/web/app/mobile/tags/users-list.tag
@@ -14,14 +14,13 @@
<style>
:scope
display block
- background #fff
> nav
display flex
justify-content center
margin 0 auto
max-width 600px
- border-bottom solid 1px #ddd
+ border-bottom solid 1px rgba(0, 0, 0, 0.2)
> span
display block
@@ -43,14 +42,23 @@
padding 2px 5px
font-size 12px
line-height 1
- color #888
- background #eee
+ 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)
+
> *
- max-width 600px
- margin 0 auto
border-bottom solid 1px rgba(0, 0, 0, 0.05)
> .no
diff --git a/src/web/app/reset.styl b/src/web/app/reset.styl
index 940a9ed18e..3d4b06dbdf 100644
--- a/src/web/app/reset.styl
+++ b/src/web/app/reset.styl
@@ -1,19 +1,7 @@
-*
- position relative
- box-sizing border-box
- background-clip padding-box !important
-
-html
-body
- margin 0
- padding 0
-
-body
- overflow-wrap break-word
-
input:not([type])
input[type='text']
input[type='password']
+input[type='search']
input[type='email']
textarea
button
diff --git a/src/web/app/safe.js b/src/web/app/safe.js
index c5fbb83a92..77293be81d 100644
--- a/src/web/app/safe.js
+++ b/src/web/app/safe.js
@@ -7,5 +7,8 @@
if (!('fetch' in window)) {
alert(
'お使いのブラウザが古いためMisskeyを動作させることができません。' +
- 'バージョンを最新のものに更新するか、別のブラウザをお試しください。');
+ 'バージョンを最新のものに更新するか、別のブラウザをお試しください。' +
+ '\n\n' +
+ 'Your browser seems outdated.' +
+ 'To run Misskey, please update your browser to latest version or try other browsers.');
}
diff --git a/src/web/app/stats/style.styl b/src/web/app/stats/style.styl
index b48d7aeb9e..5ae230ea56 100644
--- a/src/web/app/stats/style.styl
+++ b/src/web/app/stats/style.styl
@@ -1,4 +1,5 @@
-@import "../base"
+@import "../app"
+@import "../reset"
html
color #456267
diff --git a/src/web/app/status/style.styl b/src/web/app/status/style.styl
index b48d7aeb9e..5ae230ea56 100644
--- a/src/web/app/status/style.styl
+++ b/src/web/app/status/style.styl
@@ -1,4 +1,5 @@
-@import "../base"
+@import "../app"
+@import "../reset"
html
color #456267