Merge pull request #854 from syuilo/bbs

Bbs
This commit is contained in:
こぴなたみぽ 2017-11-01 04:18:32 +09:00 committed by GitHub
commit 2a00930150
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
42 changed files with 1054 additions and 49 deletions

View file

@ -2,6 +2,10 @@ ChangeLog (Release Notes)
========================= =========================
主に notable な changes を書いていきます 主に notable な changes を書いていきます
2769 (2017/11/01)
-----------------
* New: チャンネルシステム
2752 (2017/10/30) 2752 (2017/10/30)
----------------- -----------------
* New: 未読の通知がある場合アイコンを表示するように * New: 未読の通知がある場合アイコンを表示するように

View file

@ -25,6 +25,7 @@ Note that Misskey uses following subdomains:
* **api**.*{primary domain}* * **api**.*{primary domain}*
* **auth**.*{primary domain}* * **auth**.*{primary domain}*
* **about**.*{primary domain}* * **about**.*{primary domain}*
* **ch**.*{primary domain}*
* **stats**.*{primary domain}* * **stats**.*{primary domain}*
* **status**.*{primary domain}* * **status**.*{primary domain}*
* **dev**.*{primary domain}* * **dev**.*{primary domain}*

View file

@ -26,6 +26,7 @@ Misskeyは以下のサブドメインを使います:
* **api**.*{primary domain}* * **api**.*{primary domain}*
* **auth**.*{primary domain}* * **auth**.*{primary domain}*
* **about**.*{primary domain}* * **about**.*{primary domain}*
* **ch**.*{primary domain}*
* **stats**.*{primary domain}* * **stats**.*{primary domain}*
* **status**.*{primary domain}* * **status**.*{primary domain}*
* **dev**.*{primary domain}* * **dev**.*{primary domain}*

View file

@ -164,6 +164,12 @@ common:
mk-uploader: mk-uploader:
waiting: "Waiting" waiting: "Waiting"
ch:
tags:
mk-index:
new: "Create new channel"
channel-title: "Channel title"
desktop: desktop:
tags: tags:
mk-api-info: mk-api-info:
@ -241,6 +247,7 @@ desktop:
mk-ui-header-nav: mk-ui-header-nav:
home: "Home" home: "Home"
messaging: "Messages" messaging: "Messages"
ch: "Channels"
info: "News" info: "News"
mk-ui-header-search: mk-ui-header-search:
@ -353,6 +360,9 @@ desktop:
mobile: mobile:
tags: tags:
mk-selectdrive-page:
select-file: "Select file(s)"
mk-drive-file-viewer: mk-drive-file-viewer:
download: "Download" download: "Download"
rename: "Rename" rename: "Rename"
@ -491,6 +501,7 @@ mobile:
home: "Home" home: "Home"
notifications: "Notifications" notifications: "Notifications"
messaging: "Messages" messaging: "Messages"
ch: "Channels"
drive: "Drive" drive: "Drive"
settings: "Settings" settings: "Settings"
about: "About Misskey" about: "About Misskey"

View file

@ -164,6 +164,12 @@ common:
mk-uploader: mk-uploader:
waiting: "待機中" waiting: "待機中"
ch:
tags:
mk-index:
new: "チャンネルを作成"
channel-title: "チャンネルのタイトル"
desktop: desktop:
tags: tags:
mk-api-info: mk-api-info:
@ -241,6 +247,7 @@ desktop:
mk-ui-header-nav: mk-ui-header-nav:
home: "ホーム" home: "ホーム"
messaging: "メッセージ" messaging: "メッセージ"
ch: "チャンネル"
info: "お知らせ" info: "お知らせ"
mk-ui-header-search: mk-ui-header-search:
@ -353,6 +360,9 @@ desktop:
mobile: mobile:
tags: tags:
mk-selectdrive-page:
select-file: "ファイルを選択"
mk-drive-file-viewer: mk-drive-file-viewer:
download: "ダウンロード" download: "ダウンロード"
rename: "名前を変更" rename: "名前を変更"
@ -491,6 +501,7 @@ mobile:
home: "ホーム" home: "ホーム"
notifications: "通知" notifications: "通知"
messaging: "メッセージ" messaging: "メッセージ"
ch: "チャンネル"
search: "検索" search: "検索"
drive: "ドライブ" drive: "ドライブ"
settings: "設定" settings: "設定"

View file

@ -1,7 +1,7 @@
{ {
"name": "misskey", "name": "misskey",
"author": "syuilo <i@syuilo.com>", "author": "syuilo <i@syuilo.com>",
"version": "0.0.2752", "version": "0.0.2769",
"license": "MIT", "license": "MIT",
"description": "A miniblog-based SNS", "description": "A miniblog-based SNS",
"bugs": "https://github.com/syuilo/misskey/issues", "bugs": "https://github.com/syuilo/misskey/issues",

View file

@ -474,8 +474,25 @@ const endpoints: Endpoint[] = [
name: 'messaging/messages/create', name: 'messaging/messages/create',
withCredential: true, withCredential: true,
kind: 'messaging-write' 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; export default endpoints;

View file

@ -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))));
});

View file

@ -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));
});

View file

@ -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)
)));
});

View file

@ -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));
});

View file

@ -4,16 +4,16 @@
import $ from 'cafy'; import $ from 'cafy';
import deepEqual = require('deep-equal'); import deepEqual = require('deep-equal');
import parse from '../../common/text'; import parse from '../../common/text';
import Post from '../../models/post'; import { default as Post, IPost, isValidText } from '../../models/post';
import { isValidText } from '../../models/post';
import { default as User, IUser } from '../../models/user'; import { default as User, IUser } from '../../models/user';
import { default as Channel, IChannel } from '../../models/channel';
import Following from '../../models/following'; import Following from '../../models/following';
import DriveFile from '../../models/drive-file'; import DriveFile from '../../models/drive-file';
import Watching from '../../models/post-watching'; import Watching from '../../models/post-watching';
import serialize from '../../serializers/post'; import serialize from '../../serializers/post';
import notify from '../../common/notify'; import notify from '../../common/notify';
import watch from '../../common/watch-post'; import watch from '../../common/watch-post';
import event from '../../event'; import { default as event, publishChannelStream } from '../../event';
import config from '../../../conf'; 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().$; const [repostId, repostIdErr] = $(params.repost_id).optional.id().$;
if (repostIdErr) return rej('invalid repost_id'); if (repostIdErr) return rej('invalid repost_id');
let repost = null; let repost: IPost = null;
let isQuote = false;
if (repostId !== undefined) { if (repostId !== undefined) {
// Fetch repost to post // Fetch repost to post
repost = await Post.findOne({ 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対象かつ引用じゃなかったらエラー // 直近と同じRepost対象かつ引用じゃなかったらエラー
if (latestPost && if (latestPost &&
latestPost.repost_id && latestPost.repost_id &&
latestPost.repost_id.equals(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'); return rej('cannot repost same post that already reposted in your latest post');
} }
// 直近がRepost対象かつ引用じゃなかったらエラー // 直近がRepost対象かつ引用じゃなかったらエラー
if (latestPost && if (latestPost &&
latestPost._id.equals(repost._id) && latestPost._id.equals(repost._id) &&
text === undefined && files === null) { !isQuote) {
return rej('cannot repost your latest post'); 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().$; const [inReplyToPostId, inReplyToPostIdErr] = $(params.reply_to_id).optional.id().$;
if (inReplyToPostIdErr) return rej('invalid in_reply_to_post_id'); if (inReplyToPostIdErr) return rej('invalid in_reply_to_post_id');
let inReplyToPost = null; let inReplyToPost: IPost = null;
if (inReplyToPostId !== undefined) { if (inReplyToPostId !== undefined) {
// Fetch reply // Fetch reply
inReplyToPost = await Post.findOne({ 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 // Get 'poll' parameter
const [poll, pollErr] = $(params.poll).optional.strict.object() const [poll, pollErr] = $(params.poll).optional.strict.object()
.have('choices', $().array('string') .have('choices', $().array('string')
@ -164,6 +208,8 @@ module.exports = (params, user: IUser, app) => new Promise(async (res, rej) => {
// 投稿を作成 // 投稿を作成
const post = await Post.insert({ const post = await Post.insert({
created_at: new Date(), 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, media_ids: files ? files.map(file => file._id) : undefined,
reply_to_id: inReplyToPost ? inReplyToPost._id : undefined, reply_to_id: inReplyToPost ? inReplyToPost._id : undefined,
repost_id: repost ? repost._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 // Post processes
Channel.update({ _id: channel._id }, {
$inc: {
index: 1
}
});
User.update({ _id: user._id }, { User.update({ _id: user._id }, {
$set: { $set: {
latest_post: post latest_post: post
@ -206,6 +258,11 @@ module.exports = (params, user: IUser, app) => new Promise(async (res, rej) => {
// Publish event to myself's stream // Publish event to myself's stream
event(user._id, 'post', postObj); event(user._id, 'post', postObj);
// Publish event to channel
if (channel) {
publishChannelStream(channel._id, 'post', postObj);
}
// Fetch all followers // Fetch all followers
const followers = await Following const followers = await Following
.find({ .find({

View file

@ -25,6 +25,10 @@ class MisskeyEvent {
this.publish(`messaging-stream:${userId}-${otherpartyId}`, type, typeof value === 'undefined' ? null : value); 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 { private publish(channel: string, type: string, value?: any): void {
const message = value == null ? const message = value == null ?
{ type: type } : { type: type } :
@ -41,3 +45,5 @@ export default ev.publishUserStream.bind(ev);
export const publishPostStream = ev.publishPostStream.bind(ev); export const publishPostStream = ev.publishPostStream.bind(ev);
export const publishMessagingStream = ev.publishMessagingStream.bind(ev); export const publishMessagingStream = ev.publishMessagingStream.bind(ev);
export const publishChannelStream = ev.publishChannelStream.bind(ev);

14
src/api/models/channel.ts Normal file
View file

@ -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;
};

View file

@ -10,6 +10,7 @@ export function isValidText(text: string): boolean {
export type IPost = { export type IPost = {
_id: mongo.ObjectID; _id: mongo.ObjectID;
channel_id: mongo.ObjectID;
created_at: Date; created_at: Date;
media_ids: mongo.ObjectID[]; media_ids: mongo.ObjectID[];
reply_to_id: mongo.ObjectID; reply_to_id: mongo.ObjectID;

View file

@ -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);
});

View file

@ -8,6 +8,7 @@ import Reaction from '../models/post-reaction';
import { IUser } from '../models/user'; import { IUser } from '../models/user';
import Vote from '../models/poll-vote'; import Vote from '../models/poll-vote';
import serializeApp from './app'; import serializeApp from './app';
import serializeChannel from './channel';
import serializeUser from './user'; import serializeUser from './user';
import serializeDriveFile from './drive-file'; import serializeDriveFile from './drive-file';
import parse from '../common/text'; import parse from '../common/text';
@ -76,8 +77,13 @@ const self = (
_post.app = await serializeApp(_post.app_id); _post.app = await serializeApp(_post.app_id);
} }
if (_post.media_ids) { // Populate channel
if (_post.channel_id) {
_post.channel = await serializeChannel(_post.channel_id);
}
// Populate media // Populate media
if (_post.media_ids) {
_post.media = await Promise.all(_post.media_ids.map(async fileId => _post.media = await Promise.all(_post.media_ids.map(async fileId =>
await serializeDriveFile(fileId) await serializeDriveFile(fileId)
)); ));

12
src/api/stream/channel.ts Normal file
View file

@ -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);
});
}

View file

@ -9,6 +9,7 @@ import isNativeToken from './common/is-native-token';
import homeStream from './stream/home'; import homeStream from './stream/home';
import messagingStream from './stream/messaging'; import messagingStream from './stream/messaging';
import serverStream from './stream/server'; import serverStream from './stream/server';
import channelStream from './stream/channel';
module.exports = (server: http.Server) => { module.exports = (server: http.Server) => {
/** /**
@ -26,14 +27,6 @@ module.exports = (server: http.Server) => {
return; return;
} }
const user = await authenticate(request.resourceURL.query.i);
if (user == null) {
connection.send('authentication-failed');
connection.close();
return;
}
// Connect to Redis // Connect to Redis
const subscriber = redis.createClient( const subscriber = redis.createClient(
config.redis.port, config.redis.host); config.redis.port, config.redis.host);
@ -43,6 +36,19 @@ module.exports = (server: http.Server) => {
subscriber.quit(); 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 = const channel =
request.resourceURL.pathname === '/' ? homeStream : request.resourceURL.pathname === '/' ? homeStream :
request.resourceURL.pathname === '/messaging' ? messagingStream : request.resourceURL.pathname === '/messaging' ? messagingStream :

View file

@ -3,7 +3,13 @@
* @param {*} post 稿 * @param {*} post 稿
*/ */
const summarize = (post: any): string => { 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) { if (post.media) {

View file

@ -88,6 +88,7 @@ type Mixin = {
api_url: string; api_url: string;
auth_url: string; auth_url: string;
about_url: string; about_url: string;
ch_url: stirng;
stats_url: string; stats_url: string;
status_url: string; status_url: string;
dev_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.secondary_scheme = config.secondary_url.substr(0, config.secondary_url.indexOf('://'));
mixin.api_url = `${mixin.scheme}://api.${mixin.host}`; mixin.api_url = `${mixin.scheme}://api.${mixin.host}`;
mixin.auth_url = `${mixin.scheme}://auth.${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.dev_url = `${mixin.scheme}://dev.${mixin.host}`;
mixin.about_url = `${mixin.scheme}://about.${mixin.host}`; mixin.about_url = `${mixin.scheme}://about.${mixin.host}`;
mixin.stats_url = `${mixin.scheme}://stats.${mixin.host}`; mixin.stats_url = `${mixin.scheme}://stats.${mixin.host}`;

32
src/web/app/ch/router.js Normal file
View file

@ -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];
}

18
src/web/app/ch/script.js Normal file
View file

@ -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);
});

View file

@ -0,0 +1,4 @@
@import "../base"
html
background #efefef

View file

@ -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>

View file

@ -0,0 +1,2 @@
require('./index.tag');
require('./channel.tag');

View file

@ -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>

View file

@ -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;

View file

@ -6,6 +6,7 @@ const host = isRoot ? Url.host : Url.host.substring(Url.host.indexOf('.') + 1, U
const scheme = Url.protocol; const scheme = Url.protocol;
const url = `${scheme}//${host}`; const url = `${scheme}//${host}`;
const apiUrl = `${scheme}//api.${host}`; const apiUrl = `${scheme}//api.${host}`;
const chUrl = `${scheme}//ch.${host}`;
const devUrl = `${scheme}//dev.${host}`; const devUrl = `${scheme}//dev.${host}`;
const aboutUrl = `${scheme}//about.${host}`; const aboutUrl = `${scheme}//about.${host}`;
const statsUrl = `${scheme}//stats.${host}`; const statsUrl = `${scheme}//stats.${host}`;
@ -16,6 +17,7 @@ export default {
scheme, scheme,
url, url,
apiUrl, apiUrl,
chUrl,
devUrl, devUrl,
aboutUrl, aboutUrl,
statsUrl, statsUrl,

View file

@ -8,6 +8,7 @@ let page = null;
export default me => { export default me => {
route('/', index); route('/', index);
route('/selectdrive', selectDrive);
route('/i>mentions', mentions); route('/i>mentions', mentions);
route('/post::post', post); route('/post::post', post);
route('/search::query', search); route('/search::query', search);
@ -54,6 +55,10 @@ export default me => {
mount(el); mount(el);
} }
function selectDrive() {
mount(document.createElement('mk-selectdrive-page'));
}
function notFound() { function notFound() {
mount(document.createElement('mk-not-found')); mount(document.createElement('mk-not-found'));
} }
@ -67,6 +72,7 @@ export default me => {
}; };
function mount(content) { function mount(content) {
document.documentElement.style.background = '#313a42';
document.documentElement.removeAttribute('data-page'); document.documentElement.removeAttribute('data-page');
if (page) page.unmount(); if (page) page.unmount();
const body = document.getElementById('app'); const body = document.getElementById('app');

View file

@ -61,6 +61,7 @@ require('./pages/user.tag');
require('./pages/post.tag'); require('./pages/post.tag');
require('./pages/search.tag'); require('./pages/search.tag');
require('./pages/not-found.tag'); require('./pages/not-found.tag');
require('./pages/selectdrive.tag');
require('./autocomplete-suggestion.tag'); require('./autocomplete-suggestion.tag');
require('./progress-dialog.tag'); require('./progress-dialog.tag');
require('./user-preview.tag'); require('./user-preview.tag');

View file

@ -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>

View file

@ -16,7 +16,7 @@
this.refs.ui.refs.user.on('user-fetched', user => { this.refs.ui.refs.user.on('user-fetched', user => {
Progress.set(0.5); Progress.set(0.5);
document.title = user.name + ' | Misskey' document.title = user.name + ' | Misskey';
}); });
this.refs.ui.refs.user.on('loaded', () => { this.refs.ui.refs.user.on('loaded', () => {

View file

@ -112,6 +112,7 @@
</header> </header>
<div class="body"> <div class="body">
<div class="text" ref="text"> <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 }> <a class="reply" if={ p.reply_to }>
<i class="fa fa-reply"></i> <i class="fa fa-reply"></i>
</a> </a>
@ -333,6 +334,9 @@
font-weight 400 font-weight 400
font-style normal font-style normal
> .channel
margin 0
> .reply > .reply
margin-right 8px margin-right 8px
color #717171 color #717171

View file

@ -319,7 +319,8 @@
</mk-ui-header-notifications> </mk-ui-header-notifications>
<mk-ui-header-nav> <mk-ui-header-nav>
<ul if={ SIGNIN }> <ul>
<virtual if={ SIGNIN }>
<li class="home { active: page == 'home' }"> <li class="home { active: page == 'home' }">
<a href={ CONFIG.url }> <a href={ CONFIG.url }>
<i class="fa fa-home"></i> <i class="fa fa-home"></i>
@ -333,6 +334,13 @@
<i class="fa fa-circle" if={ hasUnreadMessagingMessages }></i> <i class="fa fa-circle" if={ hasUnreadMessagingMessages }></i>
</a> </a>
</li> </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"> <li class="info">
<a href="https://twitter.com/misskey_xyz" target="_blank"> <a href="https://twitter.com/misskey_xyz" target="_blank">
<i class="fa fa-info"></i> <i class="fa fa-info"></i>

View file

@ -8,6 +8,7 @@ let page = null;
export default me => { export default me => {
route('/', index); route('/', index);
route('/selectdrive', selectDrive);
route('/i/notifications', notifications); route('/i/notifications', notifications);
route('/i/messaging', messaging); route('/i/messaging', messaging);
route('/i/messaging/:username', messaging); route('/i/messaging/:username', messaging);
@ -122,6 +123,10 @@ export default me => {
mount(el); mount(el);
} }
function selectDrive() {
mount(document.createElement('mk-selectdrive-page'));
}
function notFound() { function notFound() {
mount(document.createElement('mk-not-found')); mount(document.createElement('mk-not-found'));
} }

View file

@ -483,7 +483,7 @@
if (fn == null || fn == '') return; if (fn == null || fn == '') return;
switch (fn) { switch (fn) {
case '1': case '1':
this.refs.file.click(); this.selectLocalFile();
break; break;
case '2': case '2':
this.urlUpload(); this.urlUpload();
@ -503,6 +503,10 @@
} }
}; };
this.selectLocalFile = () => {
this.refs.file.click();
};
this.createFolder = () => { this.createFolder = () => {
const name = window.prompt('フォルダー名'); const name = window.prompt('フォルダー名');
if (name == null || name == '') return; if (name == null || name == '') return;

View file

@ -19,6 +19,7 @@ require('./page/settings/authorized-apps.tag');
require('./page/settings/twitter.tag'); require('./page/settings/twitter.tag');
require('./page/messaging.tag'); require('./page/messaging.tag');
require('./page/messaging-room.tag'); require('./page/messaging-room.tag');
require('./page/selectdrive.tag');
require('./home.tag'); require('./home.tag');
require('./home-timeline.tag'); require('./home-timeline.tag');
require('./timeline.tag'); require('./timeline.tag');

View file

@ -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>

View file

@ -164,6 +164,7 @@
</header> </header>
<div class="body"> <div class="body">
<div class="text" ref="text"> <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 }> <a class="reply" if={ p.reply_to }>
<i class="fa fa-reply"></i> <i class="fa fa-reply"></i>
</a> </a>
@ -373,6 +374,9 @@
mk-url-preview mk-url-preview
margin-top 8px margin-top 8px
> .channel
margin 0
> .reply > .reply
margin-right 8px margin-right 8px
color #717171 color #717171

View file

@ -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> <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>
<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>
<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>
<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> <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>

View file

@ -16,6 +16,7 @@ module.exports = langs.map(([lang, locale]) => {
const entry = { const entry = {
desktop: './src/web/app/desktop/script.js', desktop: './src/web/app/desktop/script.js',
mobile: './src/web/app/mobile/script.js', mobile: './src/web/app/mobile/script.js',
ch: './src/web/app/ch/script.js',
stats: './src/web/app/stats/script.js', stats: './src/web/app/stats/script.js',
status: './src/web/app/status/script.js', status: './src/web/app/status/script.js',
dev: './src/web/app/dev/script.js', dev: './src/web/app/dev/script.js',