1
0
mirror of https://github.com/hotomoe/hotomoe synced 2024-12-25 20:18:07 +09:00
This commit is contained in:
ha-dai 2017-11-02 13:58:47 +09:00
commit 4e83106853
88 changed files with 2167 additions and 558 deletions

View File

@ -2,6 +2,50 @@ ChangeLog (Release Notes)
=========================
主に notable な changes を書いていきます
2807 (2017/11/02)
-----------------
* いい感じに
2805 (2017/11/02)
-----------------
* いい感じに
2801 (2017/11/01)
-----------------
* チャンネルのWatch実装
2799 (2017/11/01)
-----------------
* いい感じに
2795 (2017/11/01)
-----------------
* いい感じに
2793 (2017/11/01)
-----------------
* なんか
2783 (2017/11/01)
-----------------
* なんか
2777 (2017/11/01)
-----------------
* 細かいブラッシュアップ
2775 (2017/11/01)
-----------------
* Fix: バグ修正
2769 (2017/11/01)
-----------------
* New: チャンネルシステム
2752 (2017/10/30)
-----------------
* New: 未読の通知がある場合アイコンを表示するように
2747 (2017/10/25)
-----------------
* Fix: 非ログイン状態ですべてのページが致命的な問題を発生させる (#89)

View File

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

View File

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

View File

@ -164,6 +164,19 @@ common:
mk-uploader:
waiting: "Waiting"
ch:
tags:
mk-index:
new: "Create new channel"
channel-title: "Channel title"
mk-channel-form:
textarea: "Write here"
upload: "Upload"
drive: "Drive"
post: "Do"
posting: "Doing"
desktop:
tags:
mk-api-info:
@ -241,6 +254,7 @@ desktop:
mk-ui-header-nav:
home: "Home"
messaging: "Messages"
ch: "Channels"
info: "News"
mk-ui-header-search:
@ -353,6 +367,9 @@ desktop:
mobile:
tags:
mk-selectdrive-page:
select-file: "Select file(s)"
mk-drive-file-viewer:
download: "Download"
rename: "Rename"
@ -389,6 +406,7 @@ mobile:
mk-notifications-page:
notifications: "Notifications"
read-all: "Are you sure you want to mark all unread notifications as read?"
mk-post-page:
title: "Post"
@ -490,6 +508,7 @@ mobile:
home: "Home"
notifications: "Notifications"
messaging: "Messages"
ch: "Channels"
drive: "Drive"
settings: "Settings"
about: "About Misskey"

View File

@ -164,6 +164,19 @@ common:
mk-uploader:
waiting: "待機中"
ch:
tags:
mk-index:
new: "チャンネルを作成"
channel-title: "チャンネルのタイトル"
mk-channel-form:
textarea: "書いて"
upload: "アップロード"
drive: "ドライブ"
post: "やる"
posting: "やってます"
desktop:
tags:
mk-api-info:
@ -241,6 +254,7 @@ desktop:
mk-ui-header-nav:
home: "ホーム"
messaging: "メッセージ"
ch: "チャンネル"
info: "お知らせ"
mk-ui-header-search:
@ -353,6 +367,9 @@ desktop:
mobile:
tags:
mk-selectdrive-page:
select-file: "ファイルを選択"
mk-drive-file-viewer:
download: "ダウンロード"
rename: "名前を変更"
@ -389,6 +406,7 @@ mobile:
mk-notifications-page:
notifications: "通知"
read-all: "すべての通知を既読にしますか?"
mk-post-page:
title: "投稿"
@ -490,6 +508,7 @@ mobile:
home: "ホーム"
notifications: "通知"
messaging: "メッセージ"
ch: "チャンネル"
search: "検索"
drive: "ドライブ"
settings: "設定"

View File

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

View File

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

View File

@ -195,6 +195,11 @@ const endpoints: Endpoint[] = [
withCredential: true,
kind: 'notification-read'
},
{
name: 'notifications/get_unread_count',
withCredential: true,
kind: 'notification-read'
},
{
name: 'notifications/delete',
withCredential: true,
@ -205,11 +210,6 @@ const endpoints: Endpoint[] = [
withCredential: true,
kind: 'notification-write'
},
{
name: 'notifications/mark_as_read',
withCredential: true,
kind: 'notification-write'
},
{
name: 'notifications/mark_as_read_all',
withCredential: true,
@ -474,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;

View File

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

View File

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

View File

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

View File

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 { 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';
/**
@ -62,7 +63,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,43 +86,86 @@ 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');
}
}
// 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,12 +193,12 @@ module.exports = (params, user: IUser, 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,
reply: reply ? reply._id.toString() : null,
repost: repost ? repost._id.toString() : null,
media_ids: (files || []).map(file => file._id.toString())
})) {
@ -164,8 +209,10 @@ 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,
reply_id: reply ? reply._id : undefined,
repost_id: repost ? repost._id : undefined,
poll: poll,
text: text,
@ -179,8 +226,7 @@ module.exports = (params, user: IUser, app) => new Promise(async (res, rej) => {
// Reponse
res(postObj);
// -----------------------------------------------------------
// Post processes
//#region Post processes
User.update({ _id: user._id }, {
$set: {
@ -203,6 +249,8 @@ module.exports = (params, user: IUser, app) => new Promise(async (res, rej) => {
}
}
// タイムラインへの投稿
if (!channel) {
// Publish event to myself's stream
event(user._id, 'post', postObj);
@ -220,6 +268,32 @@ module.exports = (params, user: IUser, app) => new Promise(async (res, rej) => {
// Publish event to followers stream
followers.forEach(following =>
event(following.follower_id, 'post', postObj));
}
// チャンネルへの投稿
if (channel) {
// Increment channel index(posts count)
Channel.update({ _id: channel._id }, {
$inc: {
index: 1
}
});
// Publish event to channel
publishChannelStream(channel._id, 'post', postObj);
// Get channel watchers
const watches = await ChannelWatching.find({
channel_id: channel._id,
// 削除されたドキュメントは除く
deleted_at: { $exists: false }
});
// チャンネルの視聴者(のタイムライン)に配信
watches.forEach(w => {
event(w.user_id, 'post', postObj);
});
}
// Increment my posts count
User.update({ _id: user._id }, {
@ -229,23 +303,23 @@ module.exports = (params, user: IUser, 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 +339,10 @@ module.exports = (params, user: IUser, 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 +443,7 @@ module.exports = (params, user: IUser, 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 +480,6 @@ module.exports = (params, user: IUser, app) => new Promise(async (res, rej) => {
}
});
}
//#endregion
});

View File

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

View File

@ -3,6 +3,7 @@
*/
import $ from 'cafy';
import Post from '../../models/post';
import ChannelWatching from '../../models/channel-watching';
import getFriends from '../../common/get-friends';
import serialize from '../../serializers/post';
@ -32,18 +33,43 @@ module.exports = (params, user, app) => new Promise(async (res, rej) => {
return rej('cannot set since_id and max_id');
}
// ID list of the user $self and other users who the user follows
// ID list of the user itself and other users who the user follows
const followingIds = await getFriends(user._id);
// Construct query
// Watchしているチャンネルを取得
const watches = await ChannelWatching.find({
user_id: user._id,
// 削除されたドキュメントは除く
deleted_at: { $exists: false }
});
//#region Construct query
const sort = {
_id: -1
};
const query = {
$or: [{
// フォローしている人のタイムラインへの投稿
user_id: {
$in: followingIds
},
// 「タイムラインへの」投稿に限定するためにチャンネルが指定されていないもののみに限る
$or: [{
channel_id: {
$exists: false
}
}, {
channel_id: null
}]
}, {
// Watchしているチャンネルへの投稿
channel_id: {
$in: watches.map(w => w.channel_id)
}
}]
} as any;
if (sinceId) {
sort._id = 1;
query._id = {
@ -54,6 +80,7 @@ module.exports = (params, user, app) => new Promise(async (res, rej) => {
$lt: maxId
};
}
//#endregion
// Issue query
const timeline = await Post

View File

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

View File

@ -27,7 +27,7 @@ module.exports = (params, me) => new Promise(async (res, rej) => {
// Fetch recent posts
const recentPosts = await Post.find({
user_id: user._id,
reply_to_id: {
reply_id: {
$exists: true,
$ne: null
}
@ -38,7 +38,7 @@ module.exports = (params, me) => new Promise(async (res, rej) => {
limit: 1000,
fields: {
_id: false,
reply_to_id: true
reply_id: true
}
});
@ -49,7 +49,7 @@ module.exports = (params, me) => new Promise(async (res, rej) => {
const replyTargetPosts = await Post.find({
_id: {
$in: recentPosts.map(p => p.reply_to_id)
$in: recentPosts.map(p => p.reply_id)
},
user_id: {
$ne: user._id

View File

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

View File

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

View File

@ -0,0 +1,3 @@
import db from '../../db/mongodb';
export default db.get('channel_watching') as any; // fuck type definition

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

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

View File

@ -10,9 +10,10 @@ 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;
reply_id: mongo.ObjectID;
repost_id: mongo.ObjectID;
poll: {}; // todo
text: string;

View File

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

View File

@ -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);
}
if (_post.media_ids) {
// Populate channel
if (_post.channel_id) {
_post.channel = await serializeChannel(_post.channel_id);
}
// Populate media
if (_post.media_ids) {
_post.media = await Promise.all(_post.media_ids.map(async fileId =>
await serializeDriveFile(fileId)
));
@ -117,9 +123,9 @@ const self = (
});
_post.next = next ? next._id : null;
if (_post.reply_to_id) {
if (_post.reply_id) {
// Populate reply to post
_post.reply_to = await self(_post.reply_to_id, meId, {
_post.reply = await self(_post.reply_id, meId, {
detail: false
});
}

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

@ -4,6 +4,7 @@ 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');
@ -45,6 +46,11 @@ export default function homeStream(request: websocket.request, connection: webso
});
break;
case 'read_notification':
if (!msg.id) return;
readNotification(user._id, msg.id);
break;
case 'capture':
if (!msg.id) return;
const postId = msg.id;

View File

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

View File

@ -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) {
@ -16,9 +22,9 @@ const summarize = (post: any): string => {
}
// 返信のとき
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: ...';
}

View File

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

View File

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

View File

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

View File

@ -1,4 +1,5 @@
@import "../base"
@import "../app"
@import "../reset"
html
background #eee

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

10
src/web/app/ch/style.styl Normal file
View File

@ -0,0 +1,10 @@
@import "../app"
html
padding 8px
background #efefef
#wait
top auto
bottom 15px
left 15px

View File

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

View File

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

View File

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

View File

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

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

View File

@ -8,6 +8,7 @@ let page = null;
export default me => {
route('/', index);
route('/selectdrive', selectDrive);
route('/i>mentions', mentions);
route('/post::post', post);
route('/search::query', search);
@ -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');

View File

@ -1,4 +1,5 @@
@import "../base"
@import "../app"
@import "../reset"
@import "../../../../node_modules/cropperjs/dist/cropper.css"
*::input-placeholder

View File

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

View File

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

View File

@ -252,6 +252,12 @@
});
this.onNotification = notification => {
// TODO: ユーザーが画面を見てないと思われるとき(ブラウザやタブがアクティブじゃないなど)は送信しない
this.stream.send({
type: 'read_notification',
id: notification.id
});
this.notifications.unshift(notification);
this.update();
};

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 => {
Progress.set(0.5);
document.title = user.name + ' | Misskey'
document.title = user.name + ' | Misskey';
});
this.refs.ui.refs.user.on('loaded', () => {

View File

@ -1,6 +1,6 @@
<mk-post-detail title={ title }>
<div class="main">
<button class="read-more" if={ p.reply_to && p.reply_to.reply_to_id && context == null } title="会話をもっと読み込む" onclick={ loadContext } disabled={ contextFetching }>
<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>
@ -9,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>
@ -329,7 +329,7 @@
// 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,

View File

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

View File

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

View File

@ -82,8 +82,8 @@
</mk-timeline>
<mk-timeline-post tabindex="-1" title={ title } onkeydown={ onKeyDown } dblclick={ onDblClick }>
<div class="reply-to" if={ p.reply_to }>
<mk-timeline-post-sub post={ p.reply_to }/>
<div class="reply-to" if={ p.reply }>
<mk-timeline-post-sub post={ p.reply }/>
</div>
<div class="repost" if={ isRepost }>
<p>
@ -112,7 +112,8 @@
</header>
<div class="body">
<div class="text" ref="text">
<a class="reply" if={ p.reply_to }>
<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>
@ -333,6 +334,9 @@
font-weight 400
font-style normal
> .channel
margin 0
> .reply
margin-right 8px
color #717171

View File

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

View File

@ -1,4 +1,5 @@
@import "../base"
@import "../app"
@import "../reset"
html
background-color #fff

View File

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

View File

@ -1,4 +1,5 @@
@import "../base"
@import "../app"
@import "../reset"
#wait
top auto

View File

@ -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
@ -205,6 +201,10 @@
} else {
this.fetch();
}
if (this.opts.isNaked) {
this.refs.nav.style.top = `${this.opts.top}px`;
}
});
this.on('unmount', () => {
@ -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;

View File

@ -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');
@ -21,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');

View File

@ -123,6 +123,12 @@
});
this.onNotification = notification => {
// TODO: ユーザーが画面を見てないと思われるとき(ブラウザやタブがアクティブじゃないなど)は送信しない
this.stream.send({
type: 'read_notification',
id: notification.id
});
this.notifications.unshift(notification);
this.update();
};

View File

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

View File

@ -10,16 +10,30 @@
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();
this.refs.ui.refs.notifications.on('fetched', () => {
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>

View File

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

View File

@ -1,5 +1,5 @@
<mk-post-detail>
<button class="read-more" if={ p.reply_to && p.reply_to.reply_to_id && context == null } onclick={ loadContext } disabled={ loadingContext }>
<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>
@ -8,8 +8,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>
@ -348,7 +348,7 @@
// 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,

View File

@ -267,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');

View File

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

View File

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

View File

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

View File

@ -1,170 +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
-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_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>

View File

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

View File

@ -1,16 +1,3 @@
*
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']

View File

@ -1,4 +1,5 @@
@import "../base"
@import "../app"
@import "../reset"
html
color #456267

View File

@ -1,4 +1,5 @@
@import "../base"
@import "../app"
@import "../reset"
html
color #456267

View File

@ -277,15 +277,15 @@ describe('API', () => {
const me = await insertSakurako();
const post = {
text: 'さく',
reply_to_id: himaPost._id.toString()
reply_id: himaPost._id.toString()
};
const res = await request('/posts/create', post, me);
res.should.have.status(200);
res.body.should.be.a('object');
res.body.should.have.property('text').eql(post.text);
res.body.should.have.property('reply_to_id').eql(post.reply_to_id);
res.body.should.have.property('reply_to');
res.body.reply_to.should.have.property('text').eql(himaPost.text);
res.body.should.have.property('reply_id').eql(post.reply_id);
res.body.should.have.property('reply');
res.body.reply.should.have.property('text').eql(himaPost.text);
}));
it('repostできる', async(async () => {
@ -350,7 +350,7 @@ describe('API', () => {
const me = await insertSakurako();
const post = {
text: 'さく',
reply_to_id: '000000000000000000000000'
reply_id: '000000000000000000000000'
};
const res = await request('/posts/create', post, me);
res.should.have.status(400);
@ -369,7 +369,7 @@ describe('API', () => {
const me = await insertSakurako();
const post = {
text: 'さく',
reply_to_id: 'kyoppie'
reply_id: 'kyoppie'
};
const res = await request('/posts/create', post, me);
res.should.have.status(400);

View File

@ -0,0 +1,5 @@
db.posts.update({}, {
$rename: {
reply_to_id: 'reply_id'
}
}, false, true);

View File

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