summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorこぴなたみぽ <Syuilotan@yahoo.co.jp>2017-11-01 04:18:32 +0900
committerGitHub <noreply@github.com>2017-11-01 04:18:32 +0900
commit2a00930150207c983a2f6e111d03f2db33b897b9 (patch)
treef41dc9bcc5498ac89839057e07910b9c2d81fed3
parentv2752 (diff)
parentv2769 (diff)
downloadsharkey-2a00930150207c983a2f6e111d03f2db33b897b9.tar.gz
sharkey-2a00930150207c983a2f6e111d03f2db33b897b9.tar.bz2
sharkey-2a00930150207c983a2f6e111d03f2db33b897b9.zip
Merge pull request #854 from syuilo/bbs
Bbs
-rw-r--r--CHANGELOG.md4
-rw-r--r--docs/setup.en.md1
-rw-r--r--docs/setup.ja.md1
-rw-r--r--locales/en.yml11
-rw-r--r--locales/ja.yml11
-rw-r--r--package.json2
-rw-r--r--src/api/endpoints.ts21
-rw-r--r--src/api/endpoints/channels.ts59
-rw-r--r--src/api/endpoints/channels/create.ts30
-rw-r--r--src/api/endpoints/channels/posts.ts79
-rw-r--r--src/api/endpoints/channels/show.ts31
-rw-r--r--src/api/endpoints/posts/create.ts81
-rw-r--r--src/api/event.ts6
-rw-r--r--src/api/models/channel.ts14
-rw-r--r--src/api/models/post.ts1
-rw-r--r--src/api/serializers/channel.ts44
-rw-r--r--src/api/serializers/post.ts8
-rw-r--r--src/api/stream/channel.ts12
-rw-r--r--src/api/streaming.ts22
-rw-r--r--src/common/get-post-summary.ts8
-rw-r--r--src/config.ts2
-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.styl4
-rw-r--r--src/web/app/ch/tags/channel.tag223
-rw-r--r--src/web/app/ch/tags/index.js2
-rw-r--r--src/web/app/ch/tags/index.tag33
-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/desktop/router.js22
-rw-r--r--src/web/app/desktop/tags/index.js1
-rw-r--r--src/web/app/desktop/tags/pages/selectdrive.tag159
-rw-r--r--src/web/app/desktop/tags/pages/user.tag2
-rw-r--r--src/web/app/desktop/tags/timeline.tag4
-rw-r--r--src/web/app/desktop/tags/ui.tag32
-rw-r--r--src/web/app/mobile/router.js5
-rw-r--r--src/web/app/mobile/tags/drive.tag6
-rw-r--r--src/web/app/mobile/tags/index.js1
-rw-r--r--src/web/app/mobile/tags/page/selectdrive.tag83
-rw-r--r--src/web/app/mobile/tags/timeline.tag4
-rw-r--r--src/web/app/mobile/tags/ui.tag5
-rw-r--r--webpack/webpack.config.ts1
42 files changed, 1054 insertions, 49 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2f75462e5f..4bf0f6abc1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,10 @@ ChangeLog (Release Notes)
=========================
主に notable な changes を書いていきます
+2769 (2017/11/01)
+-----------------
+* New: チャンネルシステム
+
2752 (2017/10/30)
-----------------
* New: 未読の通知がある場合アイコンを表示するように
diff --git a/docs/setup.en.md b/docs/setup.en.md
index 3e48935346..dbc0599b5a 100644
--- a/docs/setup.en.md
+++ b/docs/setup.en.md
@@ -25,6 +25,7 @@ Note that Misskey uses following subdomains:
* **api**.*{primary domain}*
* **auth**.*{primary domain}*
* **about**.*{primary domain}*
+* **ch**.*{primary domain}*
* **stats**.*{primary domain}*
* **status**.*{primary domain}*
* **dev**.*{primary domain}*
diff --git a/docs/setup.ja.md b/docs/setup.ja.md
index 4f48a08088..602fd9b6a1 100644
--- a/docs/setup.ja.md
+++ b/docs/setup.ja.md
@@ -26,6 +26,7 @@ Misskeyは以下のサブドメインを使います:
* **api**.*{primary domain}*
* **auth**.*{primary domain}*
* **about**.*{primary domain}*
+* **ch**.*{primary domain}*
* **stats**.*{primary domain}*
* **status**.*{primary domain}*
* **dev**.*{primary domain}*
diff --git a/locales/en.yml b/locales/en.yml
index 020813ddbb..afb6d2f2fb 100644
--- a/locales/en.yml
+++ b/locales/en.yml
@@ -164,6 +164,12 @@ common:
mk-uploader:
waiting: "Waiting"
+ch:
+ tags:
+ mk-index:
+ new: "Create new channel"
+ channel-title: "Channel title"
+
desktop:
tags:
mk-api-info:
@@ -241,6 +247,7 @@ desktop:
mk-ui-header-nav:
home: "Home"
messaging: "Messages"
+ ch: "Channels"
info: "News"
mk-ui-header-search:
@@ -353,6 +360,9 @@ desktop:
mobile:
tags:
+ mk-selectdrive-page:
+ select-file: "Select file(s)"
+
mk-drive-file-viewer:
download: "Download"
rename: "Rename"
@@ -491,6 +501,7 @@ mobile:
home: "Home"
notifications: "Notifications"
messaging: "Messages"
+ ch: "Channels"
drive: "Drive"
settings: "Settings"
about: "About Misskey"
diff --git a/locales/ja.yml b/locales/ja.yml
index 1b3058fe02..03975556b5 100644
--- a/locales/ja.yml
+++ b/locales/ja.yml
@@ -164,6 +164,12 @@ common:
mk-uploader:
waiting: "待機中"
+ch:
+ tags:
+ mk-index:
+ new: "チャンネルを作成"
+ channel-title: "チャンネルのタイトル"
+
desktop:
tags:
mk-api-info:
@@ -241,6 +247,7 @@ desktop:
mk-ui-header-nav:
home: "ホーム"
messaging: "メッセージ"
+ ch: "チャンネル"
info: "お知らせ"
mk-ui-header-search:
@@ -353,6 +360,9 @@ desktop:
mobile:
tags:
+ mk-selectdrive-page:
+ select-file: "ファイルを選択"
+
mk-drive-file-viewer:
download: "ダウンロード"
rename: "名前を変更"
@@ -491,6 +501,7 @@ mobile:
home: "ホーム"
notifications: "通知"
messaging: "メッセージ"
+ ch: "チャンネル"
search: "検索"
drive: "ドライブ"
settings: "設定"
diff --git a/package.json b/package.json
index 7a81bed7a6..57b3439d65 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "misskey",
"author": "syuilo <i@syuilo.com>",
- "version": "0.0.2752",
+ "version": "0.0.2769",
"license": "MIT",
"description": "A miniblog-based SNS",
"bugs": "https://github.com/syuilo/misskey/issues",
diff --git a/src/api/endpoints.ts b/src/api/endpoints.ts
index 29a97bcb8a..c4dacad857 100644
--- a/src/api/endpoints.ts
+++ b/src/api/endpoints.ts
@@ -474,8 +474,25 @@ 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'
+ },
];
export default endpoints;
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..e0c0e0192a
--- /dev/null
+++ b/src/api/endpoints/channels/create.ts
@@ -0,0 +1,30 @@
+/**
+ * Module dependencies
+ */
+import $ from 'cafy';
+import Channel from '../../models/channel';
+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
+ });
+
+ // Response
+ res(await serialize(channel));
+});
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/posts/create.ts b/src/api/endpoints/posts/create.ts
index 805dba7f83..34265dcbc3 100644
--- a/src/api/endpoints/posts/create.ts
+++ b/src/api/endpoints/posts/create.ts
@@ -4,16 +4,16 @@
import $ from 'cafy';
import deepEqual = require('deep-equal');
import parse from '../../common/text';
-import Post from '../../models/post';
-import { isValidText } from '../../models/post';
+import { default as Post, IPost, isValidText } from '../../models/post';
import { default as User, IUser } from '../../models/user';
+import { default as Channel, IChannel } from '../../models/channel';
import Following from '../../models/following';
import DriveFile from '../../models/drive-file';
import Watching from '../../models/post-watching';
import 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';
/**
@@ -62,7 +62,8 @@ module.exports = (params, user: IUser, app) => new Promise(async (res, rej) => {
const [repostId, repostIdErr] = $(params.repost_id).optional.id().$;
if (repostIdErr) return rej('invalid repost_id');
- let repost = null;
+ let repost: IPost = null;
+ let isQuote = false;
if (repostId !== undefined) {
// Fetch repost to post
repost = await Post.findOne({
@@ -84,18 +85,20 @@ module.exports = (params, user: IUser, app) => new Promise(async (res, rej) => {
}
});
+ isQuote = text != null || files != null;
+
// 直近と同じRepost対象かつ引用じゃなかったらエラー
if (latestPost &&
latestPost.repost_id &&
latestPost.repost_id.equals(repost._id) &&
- text === undefined && files === null) {
+ !isQuote) {
return rej('cannot repost same post that already reposted in your latest post');
}
// 直近がRepost対象かつ引用じゃなかったらエラー
if (latestPost &&
latestPost._id.equals(repost._id) &&
- text === undefined && files === null) {
+ !isQuote) {
return rej('cannot repost your latest post');
}
}
@@ -104,7 +107,7 @@ module.exports = (params, user: IUser, app) => new Promise(async (res, rej) => {
const [inReplyToPostId, inReplyToPostIdErr] = $(params.reply_to_id).optional.id().$;
if (inReplyToPostIdErr) return rej('invalid in_reply_to_post_id');
- let inReplyToPost = null;
+ let inReplyToPost: IPost = null;
if (inReplyToPostId !== undefined) {
// Fetch reply
inReplyToPost = await Post.findOne({
@@ -121,6 +124,47 @@ module.exports = (params, user: IUser, app) => new Promise(async (res, rej) => {
}
}
+ // 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 (inReplyToPost && !channelId.equals(inReplyToPost.channel_id)) {
+ return rej('チャンネル内部からチャンネル外部の投稿に返信することはできません');
+ }
+
+ // Repost対象の投稿がこのチャンネルじゃなかったらダメ
+ if (repost && !channelId.equals(repost.channel_id)) {
+ return rej('チャンネル内部からチャンネル外部の投稿をRepostすることはできません');
+ }
+
+ // 引用ではないRepostはダメ
+ if (repost && !isQuote) {
+ return rej('チャンネル内部では引用ではないRepostをすることはできません');
+ }
+ } else {
+ // 返信対象の投稿がチャンネルへの投稿だったらダメ
+ if (inReplyToPost && inReplyToPost.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')
@@ -152,11 +196,11 @@ module.exports = (params, user: IUser, app) => new Promise(async (res, rej) => {
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: inReplyToPost ? inReplyToPost._id.toString() : null,
+ repost: repost ? repost._id.toString() : null,
+ media_ids: (files || []).map(file => file._id.toString())
+ })) {
return rej('duplicate');
}
}
@@ -164,6 +208,8 @@ module.exports = (params, user: IUser, app) => new Promise(async (res, rej) => {
// 投稿を作成
const post = await Post.insert({
created_at: new Date(),
+ channel_id: channel ? channel._id : undefined,
+ index: channel ? channel.index + 1 : undefined,
media_ids: files ? files.map(file => file._id) : undefined,
reply_to_id: inReplyToPost ? inReplyToPost._id : undefined,
repost_id: repost ? repost._id : undefined,
@@ -182,6 +228,12 @@ module.exports = (params, user: IUser, app) => new Promise(async (res, rej) => {
// -----------------------------------------------------------
// Post processes
+ Channel.update({ _id: channel._id }, {
+ $inc: {
+ index: 1
+ }
+ });
+
User.update({ _id: user._id }, {
$set: {
latest_post: post
@@ -206,6 +258,11 @@ module.exports = (params, user: IUser, app) => new Promise(async (res, rej) => {
// Publish event to myself's stream
event(user._id, 'post', postObj);
+ // Publish event to channel
+ if (channel) {
+ publishChannelStream(channel._id, 'post', postObj);
+ }
+
// Fetch all followers
const followers = await Following
.find({
diff --git a/src/api/event.ts b/src/api/event.ts
index 9613a9f7cc..909b0d2556 100644
--- a/src/api/event.ts
+++ b/src/api/event.ts
@@ -25,6 +25,10 @@ class MisskeyEvent {
this.publish(`messaging-stream:${userId}-${otherpartyId}`, type, typeof value === 'undefined' ? null : value);
}
+ public publishChannelStream(channelId: ID, type: string, value?: any): void {
+ this.publish(`channel-stream:${channelId}`, type, typeof value === 'undefined' ? null : value);
+ }
+
private publish(channel: string, type: string, value?: any): void {
const message = value == null ?
{ type: type } :
@@ -41,3 +45,5 @@ export default ev.publishUserStream.bind(ev);
export const publishPostStream = ev.publishPostStream.bind(ev);
export const publishMessagingStream = ev.publishMessagingStream.bind(ev);
+
+export const publishChannelStream = ev.publishChannelStream.bind(ev);
diff --git a/src/api/models/channel.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/post.ts b/src/api/models/post.ts
index 8b9f7f5ef6..fe07dcb0b1 100644
--- a/src/api/models/post.ts
+++ b/src/api/models/post.ts
@@ -10,6 +10,7 @@ export function isValidText(text: string): boolean {
export type IPost = {
_id: mongo.ObjectID;
+ channel_id: mongo.ObjectID;
created_at: Date;
media_ids: mongo.ObjectID[];
reply_to_id: mongo.ObjectID;
diff --git a/src/api/serializers/channel.ts b/src/api/serializers/channel.ts
new file mode 100644
index 0000000000..d4e16d6be3
--- /dev/null
+++ b/src/api/serializers/channel.ts
@@ -0,0 +1,44 @@
+/**
+ * Module dependencies
+ */
+import * as mongo from 'mongodb';
+import deepcopy = require('deepcopy');
+import { IUser } from '../models/user';
+import { default as Channel, IChannel } from '../models/channel';
+
+/**
+ * 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;
+
+ resolve(_channel);
+});
diff --git a/src/api/serializers/post.ts b/src/api/serializers/post.ts
index df917a8595..7d40df2d6a 100644
--- a/src/api/serializers/post.ts
+++ b/src/api/serializers/post.ts
@@ -8,6 +8,7 @@ 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';
@@ -76,8 +77,13 @@ const self = (
_post.app = await serializeApp(_post.app_id);
}
+ // Populate channel
+ if (_post.channel_id) {
+ _post.channel = await serializeChannel(_post.channel_id);
+ }
+
+ // Populate media
if (_post.media_ids) {
- // Populate media
_post.media = await Promise.all(_post.media_ids.map(async fileId =>
await serializeDriveFile(fileId)
));
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/streaming.ts b/src/api/streaming.ts
index db600013b9..0e512fb210 100644
--- a/src/api/streaming.ts
+++ b/src/api/streaming.ts
@@ -9,6 +9,7 @@ import isNativeToken from './common/is-native-token';
import homeStream from './stream/home';
import messagingStream from './stream/messaging';
import serverStream from './stream/server';
+import channelStream from './stream/channel';
module.exports = (server: http.Server) => {
/**
@@ -26,14 +27,6 @@ module.exports = (server: http.Server) => {
return;
}
- const user = await authenticate(request.resourceURL.query.i);
-
- if (user == null) {
- connection.send('authentication-failed');
- connection.close();
- return;
- }
-
// Connect to Redis
const subscriber = redis.createClient(
config.redis.port, config.redis.host);
@@ -43,6 +36,19 @@ module.exports = (server: http.Server) => {
subscriber.quit();
});
+ if (request.resourceURL.pathname === '/channel') {
+ channelStream(request, connection, subscriber);
+ return;
+ }
+
+ const user = await authenticate(request.resourceURL.query.i);
+
+ if (user == null) {
+ connection.send('authentication-failed');
+ connection.close();
+ return;
+ }
+
const channel =
request.resourceURL.pathname === '/' ? homeStream :
request.resourceURL.pathname === '/messaging' ? messagingStream :
diff --git a/src/common/get-post-summary.ts b/src/common/get-post-summary.ts
index f628a32b41..ac15077b28 100644
--- a/src/common/get-post-summary.ts
+++ b/src/common/get-post-summary.ts
@@ -3,7 +3,13 @@
* @param {*} post 投稿
*/
const summarize = (post: any): string => {
- let summary = post.text ? post.text : '';
+ let summary = '';
+
+ // チャンネル
+ summary += post.channel ? `${post.channel.title}:` : '';
+
+ // 本文
+ summary += post.text ? post.text : '';
// メディアが添付されているとき
if (post.media) {
diff --git a/src/config.ts b/src/config.ts
index 46a93f5fef..18017e9740 100644
--- a/src/config.ts
+++ b/src/config.ts
@@ -88,6 +88,7 @@ type Mixin = {
api_url: string;
auth_url: string;
about_url: string;
+ ch_url: stirng;
stats_url: string;
status_url: string;
dev_url: string;
@@ -122,6 +123,7 @@ export default function load() {
mixin.secondary_scheme = config.secondary_url.substr(0, config.secondary_url.indexOf('://'));
mixin.api_url = `${mixin.scheme}://api.${mixin.host}`;
mixin.auth_url = `${mixin.scheme}://auth.${mixin.host}`;
+ mixin.ch_url = `${mixin.scheme}://ch.${mixin.host}`;
mixin.dev_url = `${mixin.scheme}://dev.${mixin.host}`;
mixin.about_url = `${mixin.scheme}://about.${mixin.host}`;
mixin.stats_url = `${mixin.scheme}://stats.${mixin.host}`;
diff --git a/src/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..2fc3ac3fca
--- /dev/null
+++ b/src/web/app/ch/style.styl
@@ -0,0 +1,4 @@
+@import "../base"
+
+html
+ background #efefef
diff --git a/src/web/app/ch/tags/channel.tag b/src/web/app/ch/tags/channel.tag
new file mode 100644
index 0000000000..12a6b5a3b9
--- /dev/null
+++ b/src/web/app/ch/tags/channel.tag
@@ -0,0 +1,223 @@
+<mk-channel>
+ <header><a href={ CONFIG.chUrl }>Misskey Channels</a></header>
+ <hr>
+ <main if={ !fetching }>
+ <h1>{ channel.title }</h1>
+ <p if={ postsFetching }>読み込み中<mk-ellipsis/></p>
+ <div if={ !postsFetching }>
+ <p if={ posts == null }>まだ投稿がありません</p>
+ <virtual if={ posts != null }>
+ <mk-channel-post each={ posts.slice().reverse() } post={ this } form={ parent.refs.form }/>
+ </virtual>
+ </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
+ padding 8px
+
+ > main
+ > h1
+ color #f00
+ </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.on('mount', () => {
+ document.documentElement.style.background = '#efefef';
+
+ Progress.start();
+
+ this.api('channels/show', {
+ channel_id: this.id
+ }).then(channel => {
+ Progress.done();
+
+ this.update({
+ fetching: false,
+ channel: channel
+ });
+
+ document.title = channel.title + ' | Misskey'
+ });
+
+ this.api('channels/posts', {
+ channel_id: this.id
+ }).then(posts => {
+ this.update({
+ postsFetching: false,
+ posts: posts
+ });
+ });
+
+ this.connection.on('post', this.onPost);
+ });
+
+ this.on('unmount', () => {
+ this.connection.off('post', this.onPost);
+ this.connection.close();
+ });
+
+ this.onPost = post => {
+ this.posts.unshift(post);
+ this.update();
+ };
+
+ </script>
+</mk-channel>
+
+<mk-channel-post>
+ <header>
+ <a class="index" onclick={ reply }>{ post.index }:</a>
+ <a class="name" href={ '/' + 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_to }>&gt;&gt;{ post.reply_to.index }</a>
+ { post.text }
+ <div class="media" if={ post.media }>
+ <virtual each={ file in post.media }>
+ <img src={ file.url + '?thumbnail&size=512' } alt={ file.name } title={ file.name }/>
+ </virtual>
+ </div>
+ </div>
+ <style>
+ :scope
+ display block
+ margin 0
+ padding 0
+
+ > header
+ > .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
+
+ </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 }></textarea>
+ <button class={ wait: wait } ref="submit" disabled={ wait || (refs.text.value.length == 0) } onclick={ post }>
+ { wait ? 'やってます' : 'やる' }<mk-ellipsis if={ wait }/>
+ </button>
+ <br>
+ <button onclick={ drive }>ドライブ</button>
+ <ol if={ files }>
+ <li each={ files }>{ name }</li>
+ </ol>
+ <style>
+ :scope
+ display block
+
+ </style>
+ <script>
+ import CONFIG from '../../common/scripts/config';
+
+ this.mixin('api');
+
+ this.channel = this.opts.channel;
+
+ this.clearReply = () => {
+ this.update({
+ reply: null
+ });
+ };
+
+ this.clear = () => {
+ this.clearReply();
+ this.update({
+ files: null
+ });
+ this.refs.text.value = '';
+ };
+
+ this.post = e => {
+ 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,
+ media_ids: files,
+ reply_to_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.drive = () => {
+ window['cb'] = files => {
+ this.update({
+ files: files
+ });
+ };
+ window.open(CONFIG.url + '/selectdrive?multiple=true', '_blank');
+ };
+ </script>
+</mk-channel-form>
diff --git a/src/web/app/ch/tags/index.js b/src/web/app/ch/tags/index.js
new file mode 100644
index 0000000000..1e99ccd43e
--- /dev/null
+++ b/src/web/app/ch/tags/index.js
@@ -0,0 +1,2 @@
+require('./index.tag');
+require('./channel.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..a64ddb6ccd
--- /dev/null
+++ b/src/web/app/ch/tags/index.tag
@@ -0,0 +1,33 @@
+<mk-index>
+ <button onclick={ n }>%i18n:ch.tags.mk-index.new%</button>
+ <hr>
+ <ul if={ channels }>
+ <li each={ channels }><a href={ '/' + this.id }>{ this.title }</a></li>
+ </ul>
+ <style>
+ :scope
+ display block
+
+ </style>
+ <script>
+ this.mixin('api');
+
+ this.on('mount', () => {
+ this.api('channels').then(channels => {
+ this.update({
+ channels: channels
+ });
+ });
+ });
+
+ this.n = () => {
+ const title = window.prompt('%i18n:ch.tags.mk-index.channel-title%');
+
+ this.api('channels/create', {
+ title: title
+ }).then(channel => {
+ location.href = '/' + channel.id;
+ });
+ };
+ </script>
+</mk-index>
diff --git a/src/web/app/common/scripts/channel-stream.js b/src/web/app/common/scripts/channel-stream.js
new file mode 100644
index 0000000000..17944dbe45
--- /dev/null
+++ b/src/web/app/common/scripts/channel-stream.js
@@ -0,0 +1,16 @@
+'use strict';
+
+import Stream from './stream';
+
+/**
+ * Channel stream connection
+ */
+class Connection extends Stream {
+ constructor(channelId) {
+ super('channel', {
+ channel: channelId
+ });
+ }
+}
+
+export default Connection;
diff --git a/src/web/app/common/scripts/config.js b/src/web/app/common/scripts/config.js
index 75a7abba29..c5015622f0 100644
--- a/src/web/app/common/scripts/config.js
+++ b/src/web/app/common/scripts/config.js
@@ -6,6 +6,7 @@ const host = isRoot ? Url.host : Url.host.substring(Url.host.indexOf('.') + 1, U
const scheme = Url.protocol;
const url = `${scheme}//${host}`;
const apiUrl = `${scheme}//api.${host}`;
+const chUrl = `${scheme}//ch.${host}`;
const devUrl = `${scheme}//dev.${host}`;
const aboutUrl = `${scheme}//about.${host}`;
const statsUrl = `${scheme}//stats.${host}`;
@@ -16,6 +17,7 @@ export default {
scheme,
url,
apiUrl,
+ chUrl,
devUrl,
aboutUrl,
statsUrl,
diff --git a/src/web/app/desktop/router.js b/src/web/app/desktop/router.js
index afa8a2dce3..977e3fa9a6 100644
--- a/src/web/app/desktop/router.js
+++ b/src/web/app/desktop/router.js
@@ -7,14 +7,15 @@ const route = require('page');
let page = null;
export default me => {
- route('/', index);
- route('/i>mentions', mentions);
- route('/post::post', post);
- route('/search::query', search);
- route('/:user', user.bind(null, 'home'));
- route('/:user/graphs', user.bind(null, 'graphs'));
- route('/:user/:post', post);
- route('*', notFound);
+ route('/', index);
+ route('/selectdrive', selectDrive);
+ route('/i>mentions', mentions);
+ route('/post::post', post);
+ route('/search::query', search);
+ route('/:user', user.bind(null, 'home'));
+ route('/:user/graphs', user.bind(null, 'graphs'));
+ route('/:user/:post', post);
+ route('*', notFound);
function index() {
me ? home() : entrance();
@@ -54,6 +55,10 @@ export default me => {
mount(el);
}
+ function selectDrive() {
+ mount(document.createElement('mk-selectdrive-page'));
+ }
+
function notFound() {
mount(document.createElement('mk-not-found'));
}
@@ -67,6 +72,7 @@ export default me => {
};
function mount(content) {
+ document.documentElement.style.background = '#313a42';
document.documentElement.removeAttribute('data-page');
if (page) page.unmount();
const body = document.getElementById('app');
diff --git a/src/web/app/desktop/tags/index.js b/src/web/app/desktop/tags/index.js
index 4e286013a1..37fdfe37e4 100644
--- a/src/web/app/desktop/tags/index.js
+++ b/src/web/app/desktop/tags/index.js
@@ -61,6 +61,7 @@ require('./pages/user.tag');
require('./pages/post.tag');
require('./pages/search.tag');
require('./pages/not-found.tag');
+require('./pages/selectdrive.tag');
require('./autocomplete-suggestion.tag');
require('./progress-dialog.tag');
require('./user-preview.tag');
diff --git a/src/web/app/desktop/tags/pages/selectdrive.tag b/src/web/app/desktop/tags/pages/selectdrive.tag
new file mode 100644
index 0000000000..b196357d85
--- /dev/null
+++ b/src/web/app/desktop/tags/pages/selectdrive.tag
@@ -0,0 +1,159 @@
+<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
+ 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/timeline.tag b/src/web/app/desktop/tags/timeline.tag
index 2d6b439e38..64b64f902f 100644
--- a/src/web/app/desktop/tags/timeline.tag
+++ b/src/web/app/desktop/tags/timeline.tag
@@ -112,6 +112,7 @@
</header>
<div class="body">
<div class="text" ref="text">
+ <p class="channel" if={ p.channel != null }><a href={ CONFIG.chUrl + '/' + p.channel.id } target="_blank">{ p.channel.title }</a>:</p>
<a class="reply" if={ p.reply_to }>
<i class="fa fa-reply"></i>
</a>
@@ -333,6 +334,9 @@
font-weight 400
font-style normal
+ > .channel
+ margin 0
+
> .reply
margin-right 8px
color #717171
diff --git a/src/web/app/desktop/tags/ui.tag b/src/web/app/desktop/tags/ui.tag
index e0d7393b08..3123c34f4f 100644
--- a/src/web/app/desktop/tags/ui.tag
+++ b/src/web/app/desktop/tags/ui.tag
@@ -319,18 +319,26 @@
</mk-ui-header-notifications>
<mk-ui-header-nav>
- <ul if={ SIGNIN }>
- <li class="home { active: page == 'home' }">
- <a href={ CONFIG.url }>
- <i class="fa fa-home"></i>
- <p>%i18n:desktop.tags.mk-ui-header-nav.home%</p>
- </a>
- </li>
- <li class="messaging">
- <a onclick={ messaging }>
- <i class="fa fa-comments"></i>
- <p>%i18n:desktop.tags.mk-ui-header-nav.messaging%</p>
- <i class="fa fa-circle" if={ hasUnreadMessagingMessages }></i>
+ <ul>
+ <virtual if={ SIGNIN }>
+ <li class="home { active: page == 'home' }">
+ <a href={ CONFIG.url }>
+ <i class="fa fa-home"></i>
+ <p>%i18n:desktop.tags.mk-ui-header-nav.home%</p>
+ </a>
+ </li>
+ <li class="messaging">
+ <a onclick={ messaging }>
+ <i class="fa fa-comments"></i>
+ <p>%i18n:desktop.tags.mk-ui-header-nav.messaging%</p>
+ <i class="fa fa-circle" if={ hasUnreadMessagingMessages }></i>
+ </a>
+ </li>
+ </virtual>
+ <li class="ch">
+ <a href={ CONFIG.chUrl } target="_blank">
+ <i class="fa fa-television"></i>
+ <p>%i18n:desktop.tags.mk-ui-header-nav.ch%</p>
</a>
</li>
<li class="info">
diff --git a/src/web/app/mobile/router.js b/src/web/app/mobile/router.js
index d59b2ec3a1..01eb3c8145 100644
--- a/src/web/app/mobile/router.js
+++ b/src/web/app/mobile/router.js
@@ -8,6 +8,7 @@ let page = null;
export default me => {
route('/', index);
+ route('/selectdrive', selectDrive);
route('/i/notifications', notifications);
route('/i/messaging', messaging);
route('/i/messaging/:username', messaging);
@@ -122,6 +123,10 @@ export default me => {
mount(el);
}
+ function selectDrive() {
+ mount(document.createElement('mk-selectdrive-page'));
+ }
+
function notFound() {
mount(document.createElement('mk-not-found'));
}
diff --git a/src/web/app/mobile/tags/drive.tag b/src/web/app/mobile/tags/drive.tag
index 9f3e647735..c17b7ce579 100644
--- a/src/web/app/mobile/tags/drive.tag
+++ b/src/web/app/mobile/tags/drive.tag
@@ -483,7 +483,7 @@
if (fn == null || fn == '') return;
switch (fn) {
case '1':
- this.refs.file.click();
+ this.selectLocalFile();
break;
case '2':
this.urlUpload();
@@ -503,6 +503,10 @@
}
};
+ this.selectLocalFile = () => {
+ this.refs.file.click();
+ };
+
this.createFolder = () => {
const name = window.prompt('フォルダー名');
if (name == null || name == '') return;
diff --git a/src/web/app/mobile/tags/index.js b/src/web/app/mobile/tags/index.js
index a79f4f7e7e..19952c20cd 100644
--- a/src/web/app/mobile/tags/index.js
+++ b/src/web/app/mobile/tags/index.js
@@ -19,6 +19,7 @@ require('./page/settings/authorized-apps.tag');
require('./page/settings/twitter.tag');
require('./page/messaging.tag');
require('./page/messaging-room.tag');
+require('./page/selectdrive.tag');
require('./home.tag');
require('./home-timeline.tag');
require('./timeline.tag');
diff --git a/src/web/app/mobile/tags/page/selectdrive.tag b/src/web/app/mobile/tags/page/selectdrive.tag
new file mode 100644
index 0000000000..d9e7d95c41
--- /dev/null
+++ b/src/web/app/mobile/tags/page/selectdrive.tag
@@ -0,0 +1,83 @@
+<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 }/>
+
+ <style>
+ :scope
+ display block
+ width 100%
+ height 100%
+ background #fff
+
+ > header
+ border-bottom solid 1px #eee
+
+ > h1
+ margin 0
+ padding 0
+ text-align center
+ line-height 42px
+ font-size 1em
+ font-weight normal
+
+ > .count
+ margin-left 4px
+ opacity 0.5
+
+ > .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
+ height calc(100% - 42px)
+ overflow scroll
+ -webkit-overflow-scrolling touch
+
+ </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/timeline.tag b/src/web/app/mobile/tags/timeline.tag
index c7f5bfd681..ad18521df6 100644
--- a/src/web/app/mobile/tags/timeline.tag
+++ b/src/web/app/mobile/tags/timeline.tag
@@ -164,6 +164,7 @@
</header>
<div class="body">
<div class="text" ref="text">
+ <p class="channel" if={ p.channel != null }><a href={ CONFIG.chUrl + '/' + p.channel.id } target="_blank">{ p.channel.title }</a>:</p>
<a class="reply" if={ p.reply_to }>
<i class="fa fa-reply"></i>
</a>
@@ -373,6 +374,9 @@
mk-url-preview
margin-top 8px
+ > .channel
+ margin 0
+
> .reply
margin-right 8px
color #717171
diff --git a/src/web/app/mobile/tags/ui.tag b/src/web/app/mobile/tags/ui.tag
index fb8cbcdbd2..b2d96f6b8b 100644
--- a/src/web/app/mobile/tags/ui.tag
+++ b/src/web/app/mobile/tags/ui.tag
@@ -231,10 +231,11 @@
<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>
+ <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 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>
+ <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>
diff --git a/webpack/webpack.config.ts b/webpack/webpack.config.ts
index 5199285d55..066df18157 100644
--- a/webpack/webpack.config.ts
+++ b/webpack/webpack.config.ts
@@ -16,6 +16,7 @@ module.exports = langs.map(([lang, locale]) => {
const entry = {
desktop: './src/web/app/desktop/script.js',
mobile: './src/web/app/mobile/script.js',
+ ch: './src/web/app/ch/script.js',
stats: './src/web/app/stats/script.js',
status: './src/web/app/status/script.js',
dev: './src/web/app/dev/script.js',