1
0
mirror of https://github.com/hotomoe/hotomoe synced 2024-12-26 04:28: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 を書いていきます 主に 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) 2747 (2017/10/25)
----------------- -----------------
* Fix: 非ログイン状態ですべてのページが致命的な問題を発生させる (#89) * Fix: 非ログイン状態ですべてのページが致命的な問題を発生させる (#89)

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,19 @@ common:
mk-uploader: mk-uploader:
waiting: "Waiting" 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: desktop:
tags: tags:
mk-api-info: mk-api-info:
@ -241,6 +254,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 +367,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"
@ -389,6 +406,7 @@ mobile:
mk-notifications-page: mk-notifications-page:
notifications: "Notifications" notifications: "Notifications"
read-all: "Are you sure you want to mark all unread notifications as read?"
mk-post-page: mk-post-page:
title: "Post" title: "Post"
@ -490,6 +508,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,19 @@ common:
mk-uploader: mk-uploader:
waiting: "待機中" waiting: "待機中"
ch:
tags:
mk-index:
new: "チャンネルを作成"
channel-title: "チャンネルのタイトル"
mk-channel-form:
textarea: "書いて"
upload: "アップロード"
drive: "ドライブ"
post: "やる"
posting: "やってます"
desktop: desktop:
tags: tags:
mk-api-info: mk-api-info:
@ -241,6 +254,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 +367,9 @@ desktop:
mobile: mobile:
tags: tags:
mk-selectdrive-page:
select-file: "ファイルを選択"
mk-drive-file-viewer: mk-drive-file-viewer:
download: "ダウンロード" download: "ダウンロード"
rename: "名前を変更" rename: "名前を変更"
@ -389,6 +406,7 @@ mobile:
mk-notifications-page: mk-notifications-page:
notifications: "通知" notifications: "通知"
read-all: "すべての通知を既読にしますか?"
mk-post-page: mk-post-page:
title: "投稿" title: "投稿"
@ -490,6 +508,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.2747", "version": "0.0.2807",
"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

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

View File

@ -19,7 +19,7 @@ module.exports = params => new Promise(async (res, rej) => {
.aggregate([ .aggregate([
{ $project: { { $project: {
repost_id: '$repost_id', 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 created_at: { $add: ['$created_at', 9 * 60 * 60 * 1000] } // Convert into JST
}}, }},
{ $project: { { $project: {
@ -34,7 +34,7 @@ module.exports = params => new Promise(async (res, rej) => {
then: 'repost', then: 'repost',
else: { else: {
$cond: { $cond: {
if: { $ne: ['$reply_to_id', null] }, if: { $ne: ['$reply_id', null] },
then: 'reply', then: 'reply',
else: 'post' else: 'post'
} }

View File

@ -26,7 +26,7 @@ module.exports = (params) => new Promise(async (res, rej) => {
const datas = await Post const datas = await Post
.aggregate([ .aggregate([
{ $match: { reply_to: post._id } }, { $match: { reply: post._id } },
{ $project: { { $project: {
created_at: { $add: ['$created_at', 9 * 60 * 60 * 1000] } // Convert into JST 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 } }, { $match: { user_id: user._id } },
{ $project: { { $project: {
repost_id: '$repost_id', 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 created_at: { $add: ['$created_at', 9 * 60 * 60 * 1000] } // Convert into JST
}}, }},
{ $project: { { $project: {
@ -55,7 +55,7 @@ module.exports = (params) => new Promise(async (res, rej) => {
then: 'repost', then: 'repost',
else: { else: {
$cond: { $cond: {
if: { $ne: ['$reply_to_id', null] }, if: { $ne: ['$reply_id', null] },
then: 'reply', then: 'reply',
else: 'post' else: 'post'
} }

View File

@ -34,7 +34,7 @@ module.exports = (params) => new Promise(async (res, rej) => {
{ $match: { user_id: user._id } }, { $match: { user_id: user._id } },
{ $project: { { $project: {
repost_id: '$repost_id', 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 created_at: { $add: ['$created_at', 9 * 60 * 60 * 1000] } // Convert into JST
}}, }},
{ $project: { { $project: {
@ -49,7 +49,7 @@ module.exports = (params) => new Promise(async (res, rej) => {
then: 'repost', then: 'repost',
else: { else: {
$cond: { $cond: {
if: { $ne: ['$reply_to_id', null] }, if: { $ne: ['$reply_id', null] },
then: 'reply', then: 'reply',
else: 'post' 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 Notification from '../../models/notification';
import serialize from '../../serializers/notification'; import serialize from '../../serializers/notification';
import getFriends from '../../common/get-friends'; import getFriends from '../../common/get-friends';
import read from '../../common/read-notification';
/** /**
* Get notifications * Get notifications
@ -91,17 +92,6 @@ module.exports = (params, user) => new Promise(async (res, rej) => {
// Mark as read all // Mark as read all
if (notifications.length > 0 && markAsRead) { if (notifications.length > 0 && markAsRead) {
const ids = notifications read(user._id, 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
});
} }
}); });

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) { 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) { if (repost != undefined) {

View File

@ -49,13 +49,13 @@ module.exports = (params, user) => new Promise(async (res, rej) => {
return; return;
} }
if (p.reply_to_id) { if (p.reply_id) {
await get(p.reply_to_id); await get(p.reply_id);
} }
} }
if (post.reply_to_id) { if (post.reply_id) {
await get(post.reply_to_id); await get(post.reply_id);
} }
// Serialize // Serialize

View File

@ -4,16 +4,17 @@
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 ChannelWatching from '../../models/channel-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 +63,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,43 +86,86 @@ 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');
} }
} }
// Get 'in_reply_to_post_id' parameter // Get 'reply_id' parameter
const [inReplyToPostId, inReplyToPostIdErr] = $(params.reply_to_id).optional.id().$; const [replyId, replyIdErr] = $(params.reply_id).optional.id().$;
if (inReplyToPostIdErr) return rej('invalid in_reply_to_post_id'); if (replyIdErr) return rej('invalid reply_id');
let inReplyToPost = null; let reply: IPost = null;
if (inReplyToPostId !== undefined) { if (replyId !== undefined) {
// Fetch reply // Fetch reply
inReplyToPost = await Post.findOne({ reply = await Post.findOne({
_id: inReplyToPostId _id: replyId
}); });
if (inReplyToPost === null) { if (reply === null) {
return rej('in reply to post is not found'); return rej('in reply to post is not found');
} }
// 返信対象が引用でないRepostだったらエラー // 返信対象が引用でない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'); 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 // 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')
@ -148,15 +193,15 @@ module.exports = (params, user: IUser, app) => new Promise(async (res, rej) => {
if (user.latest_post) { if (user.latest_post) {
if (deepEqual({ if (deepEqual({
text: user.latest_post.text, 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, repost: user.latest_post.repost_id ? user.latest_post.repost_id.toString() : null,
media_ids: (user.latest_post.media_ids || []).map(id => id.toString()) media_ids: (user.latest_post.media_ids || []).map(id => id.toString())
}, { }, {
text: text, text: text,
reply: inReplyToPost ? inReplyToPost._id.toString() : null, reply: reply ? reply._id.toString() : null,
repost: repost ? repost._id.toString() : null, repost: repost ? repost._id.toString() : null,
media_ids: (files || []).map(file => file._id.toString()) media_ids: (files || []).map(file => file._id.toString())
})) { })) {
return rej('duplicate'); return rej('duplicate');
} }
} }
@ -164,8 +209,10 @@ 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_id: reply ? reply._id : undefined,
repost_id: repost ? repost._id : undefined, repost_id: repost ? repost._id : undefined,
poll: poll, poll: poll,
text: text, text: text,
@ -179,8 +226,7 @@ module.exports = (params, user: IUser, app) => new Promise(async (res, rej) => {
// Reponse // Reponse
res(postObj); res(postObj);
// ----------------------------------------------------------- //#region Post processes
// Post processes
User.update({ _id: user._id }, { User.update({ _id: user._id }, {
$set: { $set: {
@ -203,23 +249,51 @@ module.exports = (params, user: IUser, app) => new Promise(async (res, rej) => {
} }
} }
// Publish event to myself's stream // タイムラインへの投稿
event(user._id, 'post', postObj); if (!channel) {
// Publish event to myself's stream
event(user._id, 'post', postObj);
// Fetch all followers // Fetch all followers
const followers = await Following const followers = await Following
.find({ .find({
followee_id: user._id, followee_id: user._id,
// 削除されたドキュメントは除く // 削除されたドキュメントは除く
deleted_at: { $exists: false } deleted_at: { $exists: false }
}, { }, {
follower_id: true, follower_id: true,
_id: false _id: false
});
// Publish event to followers stream
followers.forEach(following =>
event(following.follower_id, 'post', postObj));
}
// チャンネルへの投稿
if (channel) {
// Increment channel index(posts count)
Channel.update({ _id: channel._id }, {
$inc: {
index: 1
}
}); });
// Publish event to followers stream // Publish event to channel
followers.forEach(following => publishChannelStream(channel._id, 'post', postObj);
event(following.follower_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 // Increment my posts count
User.update({ _id: user._id }, { 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 has in reply to post
if (inReplyToPost) { if (reply) {
// Increment replies count // Increment replies count
Post.update({ _id: inReplyToPost._id }, { Post.update({ _id: reply._id }, {
$inc: { $inc: {
replies_count: 1 replies_count: 1
} }
}); });
// 自分自身へのリプライでない限りは通知を作成 // 自分自身へのリプライでない限りは通知を作成
notify(inReplyToPost.user_id, user._id, 'reply', { notify(reply.user_id, user._id, 'reply', {
post_id: post._id post_id: post._id
}); });
// Fetch watchers // Fetch watchers
Watching Watching
.find({ .find({
post_id: inReplyToPost._id, post_id: reply._id,
user_id: { $ne: user._id }, user_id: { $ne: user._id },
// 削除されたドキュメントは除く // 削除されたドキュメントは除く
deleted_at: { $exists: false } deleted_at: { $exists: false }
@ -265,10 +339,10 @@ module.exports = (params, user: IUser, app) => new Promise(async (res, rej) => {
// この投稿をWatchする // この投稿をWatchする
// TODO: ユーザーが「返信したときに自動でWatchする」設定を // TODO: ユーザーが「返信したときに自動でWatchする」設定を
// オフにしていた場合はしない // オフにしていた場合はしない
watch(user._id, inReplyToPost); watch(user._id, reply);
// Add mention // Add mention
addMention(inReplyToPost.user_id, 'reply'); addMention(reply.user_id, 'reply');
} }
// If it is repost // If it is repost
@ -369,7 +443,7 @@ module.exports = (params, user: IUser, app) => new Promise(async (res, rej) => {
if (mentionee == null) return; if (mentionee == null) return;
// 既に言及されたユーザーに対する返信や引用repostの場合も無視 // 既に言及されたユーザーに対する返信や引用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; if (repost && repost.user_id.equals(mentionee._id)) return;
// Add mention // 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 // Issue query
const replies = await Post const replies = await Post
.find({ reply_to_id: post._id }, { .find({ reply_id: post._id }, {
limit: limit, limit: limit,
skip: offset, skip: offset,
sort: { sort: {

View File

@ -3,6 +3,7 @@
*/ */
import $ from 'cafy'; import $ from 'cafy';
import Post from '../../models/post'; import Post from '../../models/post';
import ChannelWatching from '../../models/channel-watching';
import getFriends from '../../common/get-friends'; import getFriends from '../../common/get-friends';
import serialize from '../../serializers/post'; 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'); 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); 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 = { const sort = {
_id: -1 _id: -1
}; };
const query = { const query = {
user_id: { $or: [{
$in: followingIds // フォローしている人のタイムラインへの投稿
} user_id: {
$in: followingIds
},
// 「タイムラインへの」投稿に限定するためにチャンネルが指定されていないもののみに限る
$or: [{
channel_id: {
$exists: false
}
}, {
channel_id: null
}]
}, {
// Watchしているチャンネルへの投稿
channel_id: {
$in: watches.map(w => w.channel_id)
}
}]
} as any; } as any;
if (sinceId) { if (sinceId) {
sort._id = 1; sort._id = 1;
query._id = { query._id = {
@ -54,6 +80,7 @@ module.exports = (params, user, app) => new Promise(async (res, rej) => {
$lt: maxId $lt: maxId
}; };
} }
//#endregion
// Issue query // Issue query
const timeline = await Post const timeline = await Post

View File

@ -48,7 +48,7 @@ module.exports = (params, user) => new Promise(async (res, rej) => {
} as any; } as any;
if (reply != undefined) { 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) { if (repost != undefined) {

View File

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

View File

@ -85,7 +85,7 @@ module.exports = (params, me) => new Promise(async (res, rej) => {
} }
if (!includeReplies) { if (!includeReplies) {
query.reply_to_id = null; query.reply_id = null;
} }
if (withMedia) { if (withMedia) {

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

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'; import db from '../../db/mongodb';
export default db.get('notifications') as any; // fuck type definition 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 = { 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_id: mongo.ObjectID;
repost_id: mongo.ObjectID; repost_id: mongo.ObjectID;
poll: {}; // todo poll: {}; // todo
text: string; 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 { 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);
} }
// Populate channel
if (_post.channel_id) {
_post.channel = await serializeChannel(_post.channel_id);
}
// Populate media
if (_post.media_ids) { if (_post.media_ids) {
// Populate media
_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)
)); ));
@ -117,9 +123,9 @@ const self = (
}); });
_post.next = next ? next._id : null; _post.next = next ? next._id : null;
if (_post.reply_to_id) { if (_post.reply_id) {
// Populate reply to post // Populate reply to post
_post.reply_to = await self(_post.reply_to_id, meId, { _post.reply = await self(_post.reply_id, meId, {
detail: false 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 User from '../models/user';
import serializePost from '../serializers/post'; import serializePost from '../serializers/post';
import readNotification from '../common/read-notification';
const log = debug('misskey'); const log = debug('misskey');
@ -45,6 +46,11 @@ export default function homeStream(request: websocket.request, connection: webso
}); });
break; break;
case 'read_notification':
if (!msg.id) return;
readNotification(user._id, msg.id);
break;
case 'capture': case 'capture':
if (!msg.id) return; if (!msg.id) return;
const postId = msg.id; const postId = msg.id;

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) {
@ -16,9 +22,9 @@ const summarize = (post: any): string => {
} }
// 返信のとき // 返信のとき
if (post.reply_to_id) { if (post.reply_id) {
if (post.reply_to) { if (post.reply) {
summary += ` RE: ${summarize(post.reply_to)}`; summary += ` RE: ${summarize(post.reply)}`;
} else { } else {
summary += ' RE: ...'; summary += ' RE: ...';
} }

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

View File

@ -52,11 +52,11 @@ block content
td Number td Number
td 返信数 td 返信数
tr.optional tr.optional
td reply_to td reply
td: a(href='./post', target='_blank') Post td: a(href='./post', target='_blank') Post
td 返信先の投稿 td 返信先の投稿
tr.nullable tr.nullable
td reply_to_id td reply_id
td ID td ID
td 返信先の投稿のID td 返信先の投稿のID
tr.optional tr.optional
@ -90,7 +90,7 @@ block content
{ {
"created_at": "2016-12-10T00:28:50.114Z", "created_at": "2016-12-10T00:28:50.114Z",
"media_ids": null, "media_ids": null,
"reply_to_id": "584a16b15860fc52320137e3", "reply_id": "584a16b15860fc52320137e3",
"repost_id": null, "repost_id": null,
"text": "小日向美穂だぞ!", "text": "小日向美穂だぞ!",
"user_id": "5848bf7764e572683f4402f8", "user_id": "5848bf7764e572683f4402f8",
@ -117,10 +117,10 @@ block content
"is_following": true, "is_following": true,
"is_followed": true "is_followed": true
}, },
"reply_to": { "reply": {
"created_at": "2016-12-09T02:28:01.563Z", "created_at": "2016-12-09T02:28:01.563Z",
"media_ids": null, "media_ids": null,
"reply_to_id": "5849d35e547e4249be329884", "reply_id": "5849d35e547e4249be329884",
"repost_id": null, "repost_id": null,
"text": "アイコン小日向美穂?", "text": "アイコン小日向美穂?",
"user_id": "57d01a501fdf2d07be417afe", "user_id": "57d01a501fdf2d07be417afe",

View File

@ -5,8 +5,6 @@ json('../../const.json')
$theme-color = themeColor $theme-color = themeColor
$theme-color-foreground = themeColorForeground $theme-color-foreground = themeColorForeground
@import './reset'
/* /*
::selection ::selection
background $theme-color 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) tap-highlight-color rgba($theme-color, 0.7)
-webkit-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 cursor progress !important
body
overflow-wrap break-word
#error #error
padding 32px padding 32px
color #fff color #fff

View File

@ -1,4 +1,5 @@
@import "../base" @import "../app"
@import "../reset"
html html
background #eee 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 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

@ -7,14 +7,15 @@ const route = require('page');
let page = null; let page = null;
export default me => { export default me => {
route('/', index); route('/', index);
route('/i>mentions', mentions); route('/selectdrive', selectDrive);
route('/post::post', post); route('/i>mentions', mentions);
route('/search::query', search); route('/post::post', post);
route('/:user', user.bind(null, 'home')); route('/search::query', search);
route('/:user/graphs', user.bind(null, 'graphs')); route('/:user', user.bind(null, 'home'));
route('/:user/:post', post); route('/:user/graphs', user.bind(null, 'graphs'));
route('*', notFound); route('/:user/:post', post);
route('*', notFound);
function index() { function index() {
me ? home() : entrance(); me ? home() : entrance();
@ -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

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

View File

@ -4,7 +4,7 @@
<div class="feed" if={ !initializing }> <div class="feed" if={ !initializing }>
<virtual each={ item in items }><a href={ item.link } target="_blank">{ item.title }</a></virtual> <virtual each={ item in items }><a href={ item.link } target="_blank">{ item.title }</a></virtual>
</div> </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> <style>
:scope :scope
display block display block

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

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

@ -1,6 +1,6 @@
<mk-post-detail title={ title }> <mk-post-detail title={ title }>
<div class="main"> <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-ellipsis-v" if={ !contextFetching }></i>
<i class="fa fa-spinner fa-pulse" if={ contextFetching }></i> <i class="fa fa-spinner fa-pulse" if={ contextFetching }></i>
</button> </button>
@ -9,8 +9,8 @@
<mk-post-detail-sub post={ post }/> <mk-post-detail-sub post={ post }/>
</virtual> </virtual>
</div> </div>
<div class="reply-to" if={ p.reply_to }> <div class="reply-to" if={ p.reply }>
<mk-post-detail-sub post={ p.reply_to }/> <mk-post-detail-sub post={ p.reply }/>
</div> </div>
<div class="repost" if={ isRepost }> <div class="repost" if={ isRepost }>
<p> <p>
@ -329,7 +329,7 @@
// Fetch context // Fetch context
this.api('posts/context', { this.api('posts/context', {
post_id: this.p.reply_to_id post_id: this.p.reply_id
}).then(context => { }).then(context => {
this.update({ this.update({
contextFetching: false, contextFetching: false,

View File

@ -475,7 +475,7 @@
this.api('posts/create', { this.api('posts/create', {
text: this.refs.text.value == '' ? undefined : this.refs.text.value, text: this.refs.text.value == '' ? undefined : this.refs.text.value,
media_ids: files, 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, repost_id: this.repost ? this.repost.id : undefined,
poll: this.poll ? this.refs.poll.get() : undefined poll: this.poll ? this.refs.poll.get() : undefined
}).then(data => { }).then(data => {

View File

@ -1,6 +1,6 @@
<mk-sub-post-content> <mk-sub-post-content>
<div class="body"> <div class="body">
<a class="reply" if={ post.reply_to_id }> <a class="reply" if={ post.reply_id }>
<i class="fa fa-reply"></i> <i class="fa fa-reply"></i>
</a> </a>
<span ref="text"></span> <span ref="text"></span>

View File

@ -82,8 +82,8 @@
</mk-timeline> </mk-timeline>
<mk-timeline-post tabindex="-1" title={ title } onkeydown={ onKeyDown } dblclick={ onDblClick }> <mk-timeline-post tabindex="-1" title={ title } onkeydown={ onKeyDown } dblclick={ onDblClick }>
<div class="reply-to" if={ p.reply_to }> <div class="reply-to" if={ p.reply }>
<mk-timeline-post-sub post={ p.reply_to }/> <mk-timeline-post-sub post={ p.reply }/>
</div> </div>
<div class="repost" if={ isRepost }> <div class="repost" if={ isRepost }>
<p> <p>
@ -112,7 +112,8 @@
</header> </header>
<div class="body"> <div class="body">
<div class="text" ref="text"> <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> <i class="fa fa-reply"></i>
</a> </a>
<p class="dummy"></p> <p class="dummy"></p>
@ -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,18 +319,26 @@
</mk-ui-header-notifications> </mk-ui-header-notifications>
<mk-ui-header-nav> <mk-ui-header-nav>
<ul if={ SIGNIN }> <ul>
<li class="home { active: page == 'home' }"> <virtual if={ SIGNIN }>
<a href={ CONFIG.url }> <li class="home { active: page == 'home' }">
<i class="fa fa-home"></i> <a href={ CONFIG.url }>
<p>%i18n:desktop.tags.mk-ui-header-nav.home%</p> <i class="fa fa-home"></i>
</a> <p>%i18n:desktop.tags.mk-ui-header-nav.home%</p>
</li> </a>
<li class="messaging"> </li>
<a onclick={ messaging }> <li class="messaging">
<i class="fa fa-comments"></i> <a onclick={ messaging }>
<p>%i18n:desktop.tags.mk-ui-header-nav.messaging%</p> <i class="fa fa-comments"></i>
<i class="fa fa-circle" if={ hasUnreadMessagingMessages }></i> <p>%i18n:desktop.tags.mk-ui-header-nav.messaging%</p>
<i class="fa fa-circle" if={ hasUnreadMessagingMessages }></i>
</a>
</li>
</virtual>
<li class="ch">
<a href={ CONFIG.chUrl } target="_blank">
<i class="fa fa-television"></i>
<p>%i18n:desktop.tags.mk-ui-header-nav.ch%</p>
</a> </a>
</li> </li>
<li class="info"> <li class="info">

View File

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

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

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

View File

@ -1,5 +1,5 @@
<mk-drive> <mk-drive>
<nav> <nav ref="nav">
<p onclick={ goRoot }><i class="fa fa-cloud"></i>%i18n:mobile.tags.mk-drive.drive%</p> <p onclick={ goRoot }><i class="fa fa-cloud"></i>%i18n:mobile.tags.mk-drive.drive%</p>
<virtual each={ folder in hierarchyFolders }> <virtual each={ folder in hierarchyFolders }>
<span><i class="fa fa-angle-right"></i></span> <span><i class="fa fa-angle-right"></i></span>
@ -56,10 +56,6 @@
display block display block
background #fff background #fff
&[data-is-naked]
> nav
top 48px
> nav > nav
display block display block
position sticky position sticky
@ -205,6 +201,10 @@
} else { } else {
this.fetch(); this.fetch();
} }
if (this.opts.isNaked) {
this.refs.nav.style.top = `${this.opts.top}px`;
}
}); });
this.on('unmount', () => { this.on('unmount', () => {
@ -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

@ -1,6 +1,4 @@
require('./ui.tag'); require('./ui.tag');
require('./ui-header.tag');
require('./ui-nav.tag');
require('./page/entrance.tag'); require('./page/entrance.tag');
require('./page/entrance/signin.tag'); require('./page/entrance/signin.tag');
require('./page/entrance/signup.tag'); require('./page/entrance/signup.tag');
@ -21,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

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

View File

@ -1,6 +1,6 @@
<mk-drive-page> <mk-drive-page>
<mk-ui ref="ui"> <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> </mk-ui>
<style> <style>
:scope :scope

View File

@ -10,16 +10,30 @@
import ui from '../../scripts/ui-event'; import ui from '../../scripts/ui-event';
import Progress from '../../../common/scripts/loading'; import Progress from '../../../common/scripts/loading';
this.mixin('api');
this.on('mount', () => { this.on('mount', () => {
document.title = 'Misskey | %i18n:mobile.tags.mk-notifications-page.notifications%'; 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%'); ui.trigger('title', '<i class="fa fa-bell-o"></i>%i18n:mobile.tags.mk-notifications-page.notifications%');
document.documentElement.style.background = '#313a42'; document.documentElement.style.background = '#313a42';
ui.trigger('func', () => {
this.readAll();
}, 'check');
Progress.start(); Progress.start();
this.refs.ui.refs.notifications.on('fetched', () => { this.refs.ui.refs.notifications.on('fetched', () => {
Progress.done(); 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> </script>
</mk-notifications-page> </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> <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-ellipsis-v" if={ !contextFetching }></i>
<i class="fa fa-spinner fa-pulse" if={ contextFetching }></i> <i class="fa fa-spinner fa-pulse" if={ contextFetching }></i>
</button> </button>
@ -8,8 +8,8 @@
<mk-post-detail-sub post={ post }/> <mk-post-detail-sub post={ post }/>
</virtual> </virtual>
</div> </div>
<div class="reply-to" if={ p.reply_to }> <div class="reply-to" if={ p.reply }>
<mk-post-detail-sub post={ p.reply_to }/> <mk-post-detail-sub post={ p.reply }/>
</div> </div>
<div class="repost" if={ isRepost }> <div class="repost" if={ isRepost }>
<p> <p>
@ -348,7 +348,7 @@
// Fetch context // Fetch context
this.api('posts/context', { this.api('posts/context', {
post_id: this.p.reply_to_id post_id: this.p.reply_id
}).then(context => { }).then(context => {
this.update({ this.update({
contextFetching: false, contextFetching: false,

View File

@ -267,7 +267,7 @@
this.api('posts/create', { this.api('posts/create', {
text: this.refs.text.value == '' ? undefined : this.refs.text.value, text: this.refs.text.value == '' ? undefined : this.refs.text.value,
media_ids: files, 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 poll: this.poll ? this.refs.poll.get() : undefined
}).then(data => { }).then(data => {
this.trigger('post'); this.trigger('post');

View File

@ -1,5 +1,5 @@
<mk-sub-post-content> <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 }> <details if={ post.media }>
<summary>({ post.media.length }個のメディア)</summary> <summary>({ post.media.length }個のメディア)</summary>
<mk-images-viewer images={ post.media }/> <mk-images-viewer images={ post.media }/>

View File

@ -137,8 +137,8 @@
</mk-timeline> </mk-timeline>
<mk-timeline-post class={ repost: isRepost }> <mk-timeline-post class={ repost: isRepost }>
<div class="reply-to" if={ p.reply_to }> <div class="reply-to" if={ p.reply }>
<mk-timeline-post-sub post={ p.reply_to }/> <mk-timeline-post-sub post={ p.reply }/>
</div> </div>
<div class="repost" if={ isRepost }> <div class="repost" if={ isRepost }>
<p> <p>
@ -164,7 +164,8 @@
</header> </header>
<div class="body"> <div class="body">
<div class="text" ref="text"> <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> <i class="fa fa-reply"></i>
</a> </a>
<p class="dummy"></p> <p class="dummy"></p>
@ -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

@ -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 => { this.onStreamNotification = notification => {
// TODO: ユーザーが画面を見てないと思われるとき(ブラウザやタブがアクティブじゃないなど)は送信しない
this.stream.send({
type: 'read_notification',
id: notification.id
});
riot.mount(document.body.appendChild(document.createElement('mk-notify')), { riot.mount(document.body.appendChild(document.createElement('mk-notify')), {
notification: notification notification: notification
}); });
}; };
</script> </script>
</mk-ui> </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:not([type])
input[type='text'] input[type='text']
input[type='password'] input[type='password']

View File

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

View File

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

View File

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