diff --git a/CHANGELOG.md b/CHANGELOG.md index c64a8ea04d..2669bfee74 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ChangeLog (Release Notes) ========================= 主に notable な changes を書いていきます +unreleased +---------- +* New: 投稿のピン留め (#746) + 2508 (2017/08/30) ----------------- * New: モバイル版のユーザーページのアクティビティチャートを変更 diff --git a/locales/en.yml b/locales/en.yml index 29a6a764d3..15d278c370 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -77,6 +77,10 @@ common: show-result: "Show result" voted: "Voted" + mk-post-menu: + pin: "Pin" + pinned: "Pinned" + mk-reaction-picker: choose-reaction: "Pick your reaction" diff --git a/locales/ja.yml b/locales/ja.yml index 21a07640d0..7ed4262f1c 100644 --- a/locales/ja.yml +++ b/locales/ja.yml @@ -77,6 +77,10 @@ common: show-result: "結果を見る" voted: "投票済み" + mk-post-menu: + pin: "ピン留め" + pinned: "ピン留めしました" + mk-reaction-picker: choose-reaction: "リアクションを選択" diff --git a/src/api/endpoints.ts b/src/api/endpoints.ts index c6661533e8..e5be68c096 100644 --- a/src/api/endpoints.ts +++ b/src/api/endpoints.ts @@ -167,6 +167,10 @@ const endpoints: Endpoint[] = [ name: 'i/regenerate_token', withCredential: true }, + { + name: 'i/pin', + kind: 'account-write' + }, { name: 'i/appdata/get', withCredential: true diff --git a/src/api/endpoints/i/pin.ts b/src/api/endpoints/i/pin.ts new file mode 100644 index 0000000000..a94950d22b --- /dev/null +++ b/src/api/endpoints/i/pin.ts @@ -0,0 +1,44 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import User from '../../models/user'; +import Post from '../../models/post'; +import serialize from '../../serializers/user'; + +/** + * Pin post + * + * @param {any} params + * @param {any} user + * @return {Promise<any>} + */ +module.exports = async (params, user) => new Promise(async (res, rej) => { + // Get 'post_id' parameter + const [postId, postIdErr] = $(params.post_id).id().$; + if (postIdErr) return rej('invalid post_id param'); + + // Fetch pinee + const post = await Post.findOne({ + _id: postId, + user_id: user._id + }); + + if (post === null) { + return rej('post not found'); + } + + await User.update(user._id, { + $set: { + pinned_post_id: post._id + } + }); + + // Serialize + const iObj = await serialize(user, user, { + detail: true + }); + + // Send response + res(iObj); +}); diff --git a/src/api/serializers/user.ts b/src/api/serializers/user.ts index bdbc749589..c9189d9034 100644 --- a/src/api/serializers/user.ts +++ b/src/api/serializers/user.ts @@ -4,6 +4,7 @@ import * as mongo from 'mongodb'; import deepcopy = require('deepcopy'); import User from '../models/user'; +import serializePost from './post'; import Following from '../models/following'; import getFriends from '../common/get-friends'; import config from '../../conf'; @@ -116,24 +117,32 @@ export default ( _user.is_followed = follow2 !== null; } - if (me && !me.equals(_user.id) && opts.detail) { - const myFollowingIds = await getFriends(me); + if (opts.detail) { + if (_user.pinned_post_id) { + _user.pinned_post = await serializePost(_user.pinned_post_id, me, { + detail: true + }); + } - // Get following you know count - const followingYouKnowCount = await Following.count({ - followee_id: { $in: myFollowingIds }, - follower_id: _user.id, - deleted_at: { $exists: false } - }); - _user.following_you_know_count = followingYouKnowCount; + if (me && !me.equals(_user.id)) { + const myFollowingIds = await getFriends(me); - // Get followers you know count - const followersYouKnowCount = await Following.count({ - followee_id: _user.id, - follower_id: { $in: myFollowingIds }, - deleted_at: { $exists: false } - }); - _user.followers_you_know_count = followersYouKnowCount; + // Get following you know count + const followingYouKnowCount = await Following.count({ + followee_id: { $in: myFollowingIds }, + follower_id: _user.id, + deleted_at: { $exists: false } + }); + _user.following_you_know_count = followingYouKnowCount; + + // Get followers you know count + const followersYouKnowCount = await Following.count({ + followee_id: _user.id, + follower_id: { $in: myFollowingIds }, + deleted_at: { $exists: false } + }); + _user.followers_you_know_count = followersYouKnowCount; + } } resolve(_user); diff --git a/src/web/app/common/tags/index.js b/src/web/app/common/tags/index.js index dd6ba75d7a..6e6081da9b 100644 --- a/src/web/app/common/tags/index.js +++ b/src/web/app/common/tags/index.js @@ -28,3 +28,4 @@ require('./reaction-picker.tag'); require('./reactions-viewer.tag'); require('./reaction-icon.tag'); require('./weekly-activity-chart.tag'); +require('./post-menu.tag'); diff --git a/src/web/app/common/tags/post-menu.tag b/src/web/app/common/tags/post-menu.tag new file mode 100644 index 0000000000..33895212bc --- /dev/null +++ b/src/web/app/common/tags/post-menu.tag @@ -0,0 +1,134 @@ +<mk-post-menu> + <div class="backdrop" ref="backdrop" onclick={ close }></div> + <div class="popover { compact: opts.compact }" ref="popover"> + <button if={ post.user_id === I.id } onclick={ pin }>%i18n:common.tags.mk-post-menu.pin%</button> + </div> + <style> + $border-color = rgba(27, 31, 35, 0.15) + + :scope + display block + position initial + + > .backdrop + position fixed + top 0 + left 0 + z-index 10000 + width 100% + height 100% + background rgba(0, 0, 0, 0.1) + opacity 0 + + > .popover + position absolute + z-index 10001 + background #fff + border 1px solid $border-color + border-radius 4px + box-shadow 0 3px 12px rgba(27, 31, 35, 0.15) + transform scale(0.5) + opacity 0 + + $balloon-size = 16px + + &:not(.compact) + margin-top $balloon-size + transform-origin center -($balloon-size) + + &:before + content "" + display block + position absolute + top -($balloon-size * 2) + left s('calc(50% - %s)', $balloon-size) + border-top solid $balloon-size transparent + border-left solid $balloon-size transparent + border-right solid $balloon-size transparent + border-bottom solid $balloon-size $border-color + + &:after + content "" + display block + position absolute + top -($balloon-size * 2) + 1.5px + left s('calc(50% - %s)', $balloon-size) + border-top solid $balloon-size transparent + border-left solid $balloon-size transparent + border-right solid $balloon-size transparent + border-bottom solid $balloon-size #fff + + > button + display block + + </style> + <script> + import anime from 'animejs'; + + this.mixin('i'); + this.mixin('api'); + + this.post = this.opts.post; + this.source = this.opts.source; + + this.on('mount', () => { + const rect = this.source.getBoundingClientRect(); + const width = this.refs.popover.offsetWidth; + const height = this.refs.popover.offsetHeight; + if (this.opts.compact) { + const x = rect.left + window.pageXOffset + (this.source.offsetWidth / 2); + const y = rect.top + window.pageYOffset + (this.source.offsetHeight / 2); + this.refs.popover.style.left = (x - (width / 2)) + 'px'; + this.refs.popover.style.top = (y - (height / 2)) + 'px'; + } else { + const x = rect.left + window.pageXOffset + (this.source.offsetWidth / 2); + const y = rect.top + window.pageYOffset + this.source.offsetHeight; + this.refs.popover.style.left = (x - (width / 2)) + 'px'; + this.refs.popover.style.top = y + 'px'; + } + + anime({ + targets: this.refs.backdrop, + opacity: 1, + duration: 100, + easing: 'linear' + }); + + anime({ + targets: this.refs.popover, + opacity: 1, + scale: [0.5, 1], + duration: 500 + }); + }); + + this.pin = () => { + this.api('i/pin', { + post_id: this.post.id + }).then(() => { + if (this.opts.cb) this.opts.cb('pinned', '%i18n:common.tags.mk-post-menu.pinned%'); + this.unmount(); + }); + }; + + this.close = () => { + this.refs.backdrop.style.pointerEvents = 'none'; + anime({ + targets: this.refs.backdrop, + opacity: 0, + duration: 200, + easing: 'linear' + }); + + this.refs.popover.style.pointerEvents = 'none'; + anime({ + targets: this.refs.popover, + opacity: 0, + scale: 0.5, + duration: 200, + easing: 'easeInBack', + complete: () => this.unmount() + }); + }; + </script> +</mk-post-menu> diff --git a/src/web/app/desktop/tags/post-detail.tag b/src/web/app/desktop/tags/post-detail.tag index 7a90dccf39..58343482d0 100644 --- a/src/web/app/desktop/tags/post-detail.tag +++ b/src/web/app/desktop/tags/post-detail.tag @@ -43,16 +43,18 @@ </div> <footer> <mk-reactions-viewer post={ p }/> - <button onclick={ reply } title="返信"><i class="fa fa-reply"></i> - <p class="count" if={ p.replies_count > 0 }>{ p.replies_count }</p> + <button onclick={ reply } title="返信"> + <i class="fa fa-reply"></i><p class="count" if={ p.replies_count > 0 }>{ p.replies_count }</p> </button> - <button onclick={ repost } title="Repost"><i class="fa fa-retweet"></i> - <p class="count" if={ p.repost_count > 0 }>{ p.repost_count }</p> + <button onclick={ repost } title="Repost"> + <i class="fa fa-retweet"></i><p class="count" if={ p.repost_count > 0 }>{ p.repost_count }</p> </button> - <button class={ reacted: p.my_reaction != null } onclick={ react } ref="reactButton" title="リアクション"><i class="fa fa-plus"></i> - <p class="count" if={ p.reactions_count > 0 }>{ p.reactions_count }</p> + <button class={ reacted: p.my_reaction != null } onclick={ react } ref="reactButton" title="リアクション"> + <i class="fa fa-plus"></i><p class="count" if={ p.reactions_count > 0 }>{ p.reactions_count }</p> + </button> + <button onclick={ menu } ref="menuButton"> + <i class="fa fa-ellipsis-h"></i> </button> - <button><i class="fa fa-ellipsis-h"></i></button> </footer> </article> <div class="replies"> @@ -315,6 +317,13 @@ }); }; + this.menu = () => { + riot.mount(document.body.appendChild(document.createElement('mk-post-menu')), { + source: this.refs.menuButton, + post: this.p + }); + }; + this.loadContext = () => { this.contextFetching = true; diff --git a/src/web/app/desktop/tags/timeline.tag b/src/web/app/desktop/tags/timeline.tag index bce27cd7f3..cd7ac7d207 100644 --- a/src/web/app/desktop/tags/timeline.tag +++ b/src/web/app/desktop/tags/timeline.tag @@ -128,16 +128,16 @@ </div> <footer> <mk-reactions-viewer post={ p } ref="reactionsViewer"/> - <button onclick={ reply } title="%i18n:desktop.tags.mk-timeline-post.reply%"><i class="fa fa-reply"></i> - <p class="count" if={ p.replies_count > 0 }>{ p.replies_count }</p> + <button onclick={ reply } title="%i18n:desktop.tags.mk-timeline-post.reply%"> + <i class="fa fa-reply"></i><p class="count" if={ p.replies_count > 0 }>{ p.replies_count }</p> </button> - <button onclick={ repost } title="%i18n:desktop.tags.mk-timeline-post.repost%"><i class="fa fa-retweet"></i> - <p class="count" if={ p.repost_count > 0 }>{ p.repost_count }</p> + <button onclick={ repost } title="%i18n:desktop.tags.mk-timeline-post.repost%"> + <i class="fa fa-retweet"></i><p class="count" if={ p.repost_count > 0 }>{ p.repost_count }</p> </button> - <button class={ reacted: p.my_reaction != null } onclick={ react } ref="reactButton" title="%i18n:desktop.tags.mk-timeline-post.add-reaction%"><i class="fa fa-plus"></i> - <p class="count" if={ p.reactions_count > 0 }>{ p.reactions_count }</p> + <button class={ reacted: p.my_reaction != null } onclick={ react } ref="reactButton" title="%i18n:desktop.tags.mk-timeline-post.add-reaction%"> + <i class="fa fa-plus"></i><p class="count" if={ p.reactions_count > 0 }>{ p.reactions_count }</p> </button> - <button> + <button onclick={ menu } ref="menuButton"> <i class="fa fa-ellipsis-h"></i> </button> <button onclick={ toggleDetail } title="%i18n:desktop.tags.mk-timeline-post.detail"> @@ -525,6 +525,13 @@ }); }; + this.menu = () => { + riot.mount(document.body.appendChild(document.createElement('mk-post-menu')), { + source: this.refs.menuButton, + post: this.p + }); + }; + this.toggleDetail = () => { this.update({ isDetailOpened: !this.isDetailOpened diff --git a/src/web/app/mobile/tags/page/post.tag b/src/web/app/mobile/tags/page/post.tag index 198acf1798..6888229f89 100644 --- a/src/web/app/mobile/tags/page/post.tag +++ b/src/web/app/mobile/tags/page/post.tag @@ -2,7 +2,9 @@ <mk-ui ref="ui"> <main if={ !parent.fetching }> <a if={ parent.post.next } href={ parent.post.next }><i class="fa fa-angle-up"></i>%i18n:mobile.tags.mk-post-page.next%</a> - <mk-post-detail ref="post" post={ parent.post }/> + <div> + <mk-post-detail ref="post" post={ parent.post }/> + </div> <a if={ parent.post.prev } href={ parent.post.prev }><i class="fa fa-angle-down"></i>%i18n:mobile.tags.mk-post-page.prev%</a> </main> </mk-ui> @@ -13,6 +15,16 @@ main text-align center + > div + margin 8px auto + padding 0 + max-width 500px + width calc(100% - 16px) + + @media (min-width 500px) + margin 16px auto + width calc(100% - 32px) + > a display inline-block diff --git a/src/web/app/mobile/tags/post-detail.tag b/src/web/app/mobile/tags/post-detail.tag index 9215bafdbc..cf09434400 100644 --- a/src/web/app/mobile/tags/post-detail.tag +++ b/src/web/app/mobile/tags/post-detail.tag @@ -38,24 +38,26 @@ </div> <mk-poll if={ p.poll } post={ p }/> </div> - <a class="time" href={ url }> + <a class="time" href={ '/' + p.user.username + '/' + p.id }> <mk-time time={ p.created_at } mode="detail"/> </a> <footer> <mk-reactions-viewer post={ p }/> - <button onclick={ reply } title="%i18n:mobile.tags.mk-post-detail.reply%"><i class="fa fa-reply"></i> - <p class="count" if={ p.replies_count > 0 }>{ p.replies_count }</p> + <button onclick={ reply } title="%i18n:mobile.tags.mk-post-detail.reply%"> + <i class="fa fa-reply"></i><p class="count" if={ p.replies_count > 0 }>{ p.replies_count }</p> </button> - <button onclick={ repost } title="Repost"><i class="fa fa-retweet"></i> - <p class="count" if={ p.repost_count > 0 }>{ p.repost_count }</p> + <button onclick={ repost } title="Repost"> + <i class="fa fa-retweet"></i><p class="count" if={ p.repost_count > 0 }>{ p.repost_count }</p> </button> - <button class={ reacted: p.my_reaction != null } onclick={ react } ref="reactButton" title="%i18n:mobile.tags.mk-post-detail.reaction%"><i class="fa fa-plus"></i> - <p class="count" if={ p.reactions_count > 0 }>{ p.reactions_count }</p> + <button class={ reacted: p.my_reaction != null } onclick={ react } ref="reactButton" title="%i18n:mobile.tags.mk-post-detail.reaction%"> + <i class="fa fa-plus"></i><p class="count" if={ p.reactions_count > 0 }>{ p.reactions_count }</p> + </button> + <button onclick={ menu } ref="menuButton"> + <i class="fa fa-ellipsis-h"></i> </button> - <button><i class="fa fa-ellipsis-h"></i></button> </footer> </article> - <div class="replies"> + <div class="replies" if={ !compact }> <virtual each={ post in replies }> <mk-post-detail-sub post={ post }/> </virtual> @@ -64,19 +66,14 @@ :scope display block overflow hidden - margin 8px auto + margin 0 auto padding 0 - max-width 500px - width calc(100% - 16px) + width 100% text-align left background #fff border-radius 8px box-shadow 0 0 0 1px rgba(0, 0, 0, 0.2) - @media (min-width 500px) - margin 16px auto - width calc(100% - 32px) - > .fetching padding 64px 0 @@ -269,6 +266,7 @@ this.mixin('api'); + this.compact = this.opts.compact; this.post = this.opts.post; this.isRepost = this.post.repost != null; this.p = this.isRepost ? this.post.repost : this.post; @@ -299,14 +297,16 @@ } // Get replies - this.api('posts/replies', { - post_id: this.p.id, - limit: 8 - }).then(replies => { - this.update({ - replies: replies + if (!this.compact) { + this.api('posts/replies', { + post_id: this.p.id, + limit: 8 + }).then(replies => { + this.update({ + replies: replies + }); }); - }); + } }); this.reply = () => { @@ -332,6 +332,14 @@ }); }; + this.menu = () => { + riot.mount(document.body.appendChild(document.createElement('mk-post-menu')), { + source: this.refs.menuButton, + post: this.p, + compact: true + }); + }; + this.loadContext = () => { this.contextFetching = true; diff --git a/src/web/app/mobile/tags/timeline.tag b/src/web/app/mobile/tags/timeline.tag index 43470d197e..d8df8b2663 100644 --- a/src/web/app/mobile/tags/timeline.tag +++ b/src/web/app/mobile/tags/timeline.tag @@ -181,14 +181,17 @@ </div> <footer> <mk-reactions-viewer post={ p } ref="reactionsViewer"/> - <button onclick={ reply }><i class="fa fa-reply"></i> - <p class="count" if={ p.replies_count > 0 }>{ p.replies_count }</p> + <button onclick={ reply }> + <i class="fa fa-reply"></i><p class="count" if={ p.replies_count > 0 }>{ p.replies_count }</p> </button> - <button onclick={ repost } title="Repost"><i class="fa fa-retweet"></i> - <p class="count" if={ p.repost_count > 0 }>{ p.repost_count }</p> + <button onclick={ repost } title="Repost"> + <i class="fa fa-retweet"></i><p class="count" if={ p.repost_count > 0 }>{ p.repost_count }</p> </button> - <button class={ reacted: p.my_reaction != null } onclick={ react } ref="reactButton"><i class="fa fa-plus"></i> - <p class="count" if={ p.reactions_count > 0 }>{ p.reactions_count }</p> + <button class={ reacted: p.my_reaction != null } onclick={ react } ref="reactButton"> + <i class="fa fa-plus"></i><p class="count" if={ p.reactions_count > 0 }>{ p.reactions_count }</p> + </button> + <button class={ reacted: p.my_reaction != null } onclick={ react } ref="reactButton"> + <i class="fa fa-ellipsis-h"></i> </button> </footer> </div> @@ -558,6 +561,14 @@ compact: true }); }; + + this.menu = () => { + riot.mount(document.body.appendChild(document.createElement('mk-post-menu')), { + source: this.refs.menuButton, + post: this.p, + compact: true + }); + }; </script> </mk-timeline-post> diff --git a/src/web/app/mobile/tags/user.tag b/src/web/app/mobile/tags/user.tag index d85e3b51fd..0fe4055cf0 100644 --- a/src/web/app/mobile/tags/user.tag +++ b/src/web/app/mobile/tags/user.tag @@ -215,6 +215,7 @@ </mk-user> <mk-user-overview> + <mk-post-detail if={ user.pinned_post } post={ user.pinned_post } compact={ true }/> <section class="recent-posts"> <h2><i class="fa fa-comments-o"></i>%i18n:mobile.tags.mk-user-overview.recent-posts%</h2> <div> @@ -240,6 +241,9 @@ max-width 600px margin 0 auto + > mk-post-detail + margin 0 0 8px 0 + > section background #eee border-radius 8px