From 3ae824c3542b0e232b9847ae702956e631acb276 Mon Sep 17 00:00:00 2001
From: syuilo <syuilotan@yahoo.co.jp>
Date: Mon, 26 Feb 2018 00:39:05 +0900
Subject: [PATCH] :v:

---
 src/api/endpoints/posts/create.ts             | 24 +++++++-
 .../desktop/views/components/post-detail.vue  | 29 +++++++++
 .../desktop/views/components/posts.post.vue   | 35 ++++++++++-
 .../app/desktop/views/components/timeline.vue | 14 ++++-
 src/web/app/desktop/views/pages/search.vue    | 55 ++++++++++++-----
 .../mobile/views/components/post-detail.vue   | 25 ++++++++
 .../mobile/views/components/posts.post.vue    | 30 ++++++++--
 src/web/app/mobile/views/pages/search.vue     | 59 +++++++++++++------
 8 files changed, 227 insertions(+), 44 deletions(-)

diff --git a/src/api/endpoints/posts/create.ts b/src/api/endpoints/posts/create.ts
index 0fa52221f9..075e1ac9f0 100644
--- a/src/api/endpoints/posts/create.ts
+++ b/src/api/endpoints/posts/create.ts
@@ -31,6 +31,10 @@ module.exports = (params, user: IUser, app) => new Promise(async (res, rej) => {
 	const [text, textErr] = $(params.text).optional.string().pipe(isValidText).$;
 	if (textErr) return rej('invalid text');
 
+	// Get 'tags' parameter
+	const [tags = [], tagsErr] = $(params.tags).optional.array('string').unique().eachQ(t => t.range(1, 32)).$;
+	if (tagsErr) return rej('invalid tags');
+
 	// Get 'media_ids' parameter
 	const [mediaIds, mediaIdsErr] = $(params.media_ids).optional.array('id').unique().range(1, 4).$;
 	if (mediaIdsErr) return rej('invalid media_ids');
@@ -205,6 +209,23 @@ module.exports = (params, user: IUser, app) => new Promise(async (res, rej) => {
 		}
 	}
 
+	let tokens = null;
+	if (text) {
+		// Analyze
+		tokens = parse(text);
+
+		// Extract hashtags
+		const hashtags = tokens
+			.filter(t => t.type == 'hashtag')
+			.map(t => t.hashtag);
+
+		hashtags.forEach(tag => {
+			if (tags.indexOf(tag) == -1) {
+				tags.push(tag);
+			}
+		});
+	}
+
 	// 投稿を作成
 	const post = await Post.insert({
 		created_at: new Date(),
@@ -215,6 +236,7 @@ module.exports = (params, user: IUser, app) => new Promise(async (res, rej) => {
 		repost_id: repost ? repost._id : undefined,
 		poll: poll,
 		text: text,
+		tags: tags,
 		user_id: user._id,
 		app_id: app ? app._id : null,
 
@@ -423,8 +445,6 @@ module.exports = (params, user: IUser, app) => new Promise(async (res, rej) => {
 
 	// If has text content
 	if (text) {
-		// Analyze
-		const tokens = parse(text);
 		/*
 				// Extract a hashtags
 				const hashtags = tokens
diff --git a/src/web/app/desktop/views/components/post-detail.vue b/src/web/app/desktop/views/components/post-detail.vue
index c453867dfb..1e31752fe7 100644
--- a/src/web/app/desktop/views/components/post-detail.vue
+++ b/src/web/app/desktop/views/components/post-detail.vue
@@ -44,6 +44,9 @@
 				<mk-images :images="p.media"/>
 			</div>
 			<mk-poll v-if="p.poll" :post="p"/>
+			<div class="tags" v-if="p.tags && p.tags.length > 0">
+				<router-link v-for="tag in p.tags" :key="tag" :to="`/search?q=${tag}`">{{ tag }}</router-link>
+			</div>
 		</div>
 		<footer>
 			<mk-reactions-viewer :post="p"/>
@@ -306,6 +309,32 @@ export default Vue.extend({
 			> .mk-url-preview
 				margin-top 8px
 
+			> .tags
+				> *
+					margin 0 8px 0 0
+					padding 0 8px 0 16px
+					font-size 90%
+					color #8d969e
+					background #edf0f3
+					border-radius 4px
+
+					&:before
+						content ""
+						display block
+						position absolute
+						top 0
+						bottom 0
+						left 4px
+						width 8px
+						height 8px
+						margin auto 0
+						background #fff
+						border-radius 100%
+
+					&:hover
+						text-decoration none
+						background #e2e7ec
+
 		> footer
 			font-size 1.2em
 
diff --git a/src/web/app/desktop/views/components/posts.post.vue b/src/web/app/desktop/views/components/posts.post.vue
index 4898de0b6b..382a8de97d 100644
--- a/src/web/app/desktop/views/components/posts.post.vue
+++ b/src/web/app/desktop/views/components/posts.post.vue
@@ -38,6 +38,9 @@
 					</p>
 					<a class="reply" v-if="p.reply">%fa:reply%</a>
 					<mk-post-html v-if="p.ast" :ast="p.ast" :i="os.i" :class="$style.text"/>
+					<div class="tags" v-if="p.tags && p.tags.length > 0">
+						<router-link v-for="tag in p.tags" :key="tag" :to="`/search?q=${tag}`">{{ tag }}</router-link>
+					</div>
 					<a class="quote" v-if="p.repost">RP:</a>
 					<mk-url-preview v-for="url in urls" :url="url" :key="url"/>
 				</div>
@@ -342,9 +345,9 @@ export default Vue.extend({
 			display block
 			float left
 			margin 0 16px 10px 0
-			position -webkit-sticky
-			position sticky
-			top 74px
+			//position -webkit-sticky
+			//position sticky
+			//top 74px
 
 			> .avatar
 				display block
@@ -428,6 +431,32 @@ export default Vue.extend({
 						font-style oblique
 						color #a0bf46
 
+					> .tags
+						> *
+							margin 0 8px 0 0
+							padding 0 8px 0 16px
+							font-size 90%
+							color #8d969e
+							background #edf0f3
+							border-radius 4px
+
+							&:before
+								content ""
+								display block
+								position absolute
+								top 0
+								bottom 0
+								left 4px
+								width 8px
+								height 8px
+								margin auto 0
+								background #fff
+								border-radius 100%
+
+							&:hover
+								text-decoration none
+								background #e2e7ec
+
 				> .mk-poll
 					font-size 80%
 
diff --git a/src/web/app/desktop/views/components/timeline.vue b/src/web/app/desktop/views/components/timeline.vue
index eef62104eb..0d16d60df9 100644
--- a/src/web/app/desktop/views/components/timeline.vue
+++ b/src/web/app/desktop/views/components/timeline.vue
@@ -24,6 +24,7 @@ export default Vue.extend({
 		return {
 			fetching: true,
 			moreFetching: false,
+			existMore: false,
 			posts: [],
 			connection: null,
 			connectionId: null,
@@ -62,8 +63,13 @@ export default Vue.extend({
 			this.fetching = true;
 
 			(this as any).api('posts/timeline', {
+				limit: 11,
 				until_date: this.date ? this.date.getTime() : undefined
 			}).then(posts => {
+				if (posts.length == 11) {
+					posts.pop();
+					this.existMore = true;
+				}
 				this.posts = posts;
 				this.fetching = false;
 				this.$emit('loaded');
@@ -71,11 +77,17 @@ export default Vue.extend({
 			});
 		},
 		more() {
-			if (this.moreFetching || this.fetching || this.posts.length == 0) return;
+			if (this.moreFetching || this.fetching || this.posts.length == 0 || !this.existMore) return;
 			this.moreFetching = true;
 			(this as any).api('posts/timeline', {
+				limit: 11,
 				until_id: this.posts[this.posts.length - 1].id
 			}).then(posts => {
+				if (posts.length == 11) {
+					posts.pop();
+				} else {
+					this.existMore = false;
+				}
 				this.posts = this.posts.concat(posts);
 				this.moreFetching = false;
 			});
diff --git a/src/web/app/desktop/views/pages/search.vue b/src/web/app/desktop/views/pages/search.vue
index b8e8db2e79..afd37c8cee 100644
--- a/src/web/app/desktop/views/pages/search.vue
+++ b/src/web/app/desktop/views/pages/search.vue
@@ -1,13 +1,13 @@
 <template>
 <mk-ui>
 	<header :class="$style.header">
-		<h1>{{ query }}</h1>
+		<h1>{{ q }}</h1>
 	</header>
 	<div :class="$style.loading" v-if="fetching">
 		<mk-ellipsis-icon/>
 	</div>
-	<p :class="$style.empty" v-if="empty">%fa:search%「{{ query }}」に関する投稿は見つかりませんでした。</p>
-	<mk-posts ref="timeline" :class="$style.posts">
+	<p :class="$style.empty" v-if="!fetching && empty">%fa:search%「{{ q }}」に関する投稿は見つかりませんでした。</p>
+	<mk-posts ref="timeline" :class="$style.posts" :posts="posts">
 		<div slot="footer">
 			<template v-if="!moreFetching">%fa:search%</template>
 			<template v-if="moreFetching">%fa:spinner .pulse .fw%</template>
@@ -21,33 +21,34 @@ import Vue from 'vue';
 import Progress from '../../../common/scripts/loading';
 import parse from '../../../common/scripts/parse-search-query';
 
-const limit = 30;
+const limit = 20;
 
 export default Vue.extend({
-	props: ['query'],
 	data() {
 		return {
 			fetching: true,
 			moreFetching: false,
+			existMore: false,
 			offset: 0,
 			posts: []
 		};
 	},
+	watch: {
+		$route: 'fetch'
+	},
 	computed: {
 		empty(): boolean {
 			return this.posts.length == 0;
+		},
+		q(): string {
+			return this.$route.query.q;
 		}
 	},
 	mounted() {
-		Progress.start();
-
 		document.addEventListener('keydown', this.onDocumentKeydown);
 		window.addEventListener('scroll', this.onScroll);
 
-		(this as any).api('posts/search', parse(this.query)).then(posts => {
-			this.posts = posts;
-			this.fetching = false;
-		});
+		this.fetch();
 	},
 	beforeDestroy() {
 		document.removeEventListener('keydown', this.onDocumentKeydown);
@@ -61,16 +62,38 @@ export default Vue.extend({
 				}
 			}
 		},
+		fetch() {
+			this.fetching = true;
+			Progress.start();
+
+			(this as any).api('posts/search', Object.assign({
+				limit: limit + 1,
+				offset: this.offset
+			}, parse(this.q))).then(posts => {
+				if (posts.length == limit + 1) {
+					posts.pop();
+					this.existMore = true;
+				}
+				this.posts = posts;
+				this.fetching = false;
+				Progress.done();
+			});
+		},
 		more() {
-			if (this.moreFetching || this.fetching || this.posts.length == 0) return;
+			if (this.moreFetching || this.fetching || this.posts.length == 0 || !this.existMore) return;
 			this.offset += limit;
 			this.moreFetching = true;
-			return (this as any).api('posts/search', Object.assign({}, parse(this.query), {
-				limit: limit,
+			return (this as any).api('posts/search', Object.assign({
+				limit: limit + 1,
 				offset: this.offset
-			})).then(posts => {
-				this.moreFetching = false;
+			}, parse(this.q))).then(posts => {
+				if (posts.length == limit + 1) {
+					posts.pop();
+				} else {
+					this.existMore = false;
+				}
 				this.posts = this.posts.concat(posts);
+				this.moreFetching = false;
 			});
 		},
 		onScroll() {
diff --git a/src/web/app/mobile/views/components/post-detail.vue b/src/web/app/mobile/views/components/post-detail.vue
index 05138607ff..a83744ec4d 100644
--- a/src/web/app/mobile/views/components/post-detail.vue
+++ b/src/web/app/mobile/views/components/post-detail.vue
@@ -39,6 +39,9 @@
 		</header>
 		<div class="body">
 			<mk-post-html v-if="p.ast" :ast="p.ast" :i="os.i" :class="$style.text"/>
+			<div class="tags" v-if="p.tags && p.tags.length > 0">
+				<router-link v-for="tag in p.tags" :key="tag" :to="`/search?q=${tag}`">{{ tag }}</router-link>
+			</div>
 			<mk-url-preview v-for="url in urls" :url="url" :key="url"/>
 			<div class="media" v-if="p.media">
 				<mk-images :images="p.media"/>
@@ -312,6 +315,28 @@ export default Vue.extend({
 					display block
 					max-width 100%
 
+			> .tags
+				> *
+					margin 0 8px 0 0
+					padding 0 8px 0 16px
+					font-size 90%
+					color #8d969e
+					background #edf0f3
+					border-radius 4px
+
+					&:before
+						content ""
+						display block
+						position absolute
+						top 0
+						bottom 0
+						left 4px
+						width 8px
+						height 8px
+						margin auto 0
+						background #fff
+						border-radius 100%
+
 		> .time
 			font-size 16px
 			color #c0c0c0
diff --git a/src/web/app/mobile/views/components/posts.post.vue b/src/web/app/mobile/views/components/posts.post.vue
index ae1dfc59ab..e26c337f5c 100644
--- a/src/web/app/mobile/views/components/posts.post.vue
+++ b/src/web/app/mobile/views/components/posts.post.vue
@@ -35,6 +35,9 @@
 						%fa:reply%
 					</a>
 					<mk-post-html v-if="p.ast" :ast="p.ast" :i="os.i" :class="$style.text"/>
+					<div class="tags" v-if="p.tags && p.tags.length > 0">
+						<router-link v-for="tag in p.tags" :key="tag" :to="`/search?q=${tag}`">{{ tag }}</router-link>
+					</div>
 					<mk-url-preview v-for="url in urls" :url="url" :key="url"/>
 					<a class="quote" v-if="p.repost != null">RP:</a>
 				</div>
@@ -346,10 +349,7 @@ export default Vue.extend({
 					font-size 1.1em
 					color #717171
 
-					> .dummy
-						display none
-
-					mk-url-preview
+					.mk-url-preview
 						margin-top 8px
 
 					> .channel
@@ -364,6 +364,28 @@ export default Vue.extend({
 						font-style oblique
 						color #a0bf46
 
+					> .tags
+						> *
+							margin 0 8px 0 0
+							padding 0 8px 0 16px
+							font-size 90%
+							color #8d969e
+							background #edf0f3
+							border-radius 4px
+
+							&:before
+								content ""
+								display block
+								position absolute
+								top 0
+								bottom 0
+								left 4px
+								width 8px
+								height 8px
+								margin auto 0
+								background #fff
+								border-radius 100%
+
 					[data-is-me]:after
 						content "you"
 						padding 0 4px
diff --git a/src/web/app/mobile/views/pages/search.vue b/src/web/app/mobile/views/pages/search.vue
index b6e114a82b..cbab504e3c 100644
--- a/src/web/app/mobile/views/pages/search.vue
+++ b/src/web/app/mobile/views/pages/search.vue
@@ -1,10 +1,10 @@
 <template>
 <mk-ui>
-	<span slot="header">%fa:search% {{ query }}</span>
+	<span slot="header">%fa:search% {{ q }}</span>
 	<main v-if="!fetching">
 		<mk-posts :class="$style.posts" :posts="posts">
-			<span v-if="posts.length == 0">{{ '%i18n:mobile.tags.mk-search-posts.empty%'.replace('{}', query) }}</span>
-			<button v-if="canFetchMore" @click="more" :disabled="fetching" slot="tail">
+			<span v-if="posts.length == 0">{{ '%i18n:mobile.tags.mk-search-posts.empty%'.replace('{}', q) }}</span>
+			<button v-if="existMore" @click="more" :disabled="fetching" slot="tail">
 				<span v-if="!fetching">%i18n:mobile.tags.mk-timeline.load-more%</span>
 				<span v-if="fetching">%i18n:common.loading%<mk-ellipsis/></span>
 			</button>
@@ -18,38 +18,61 @@ import Vue from 'vue';
 import Progress from '../../../common/scripts/loading';
 import parse from '../../../common/scripts/parse-search-query';
 
-const limit = 30;
+const limit = 20;
 
 export default Vue.extend({
-	props: ['query'],
 	data() {
 		return {
 			fetching: true,
+			existMore: false,
 			posts: [],
 			offset: 0
 		};
 	},
+	watch: {
+		$route: 'fetch'
+	},
+	computed: {
+		q(): string {
+			return this.$route.query.q;
+		}
+	},
 	mounted() {
-		document.title = `%i18n:mobile.tags.mk-search-page.search%: ${this.query} | Misskey`;
+		document.title = `%i18n:mobile.tags.mk-search-page.search%: ${this.q} | Misskey`;
 		document.documentElement.style.background = '#313a42';
 
-		Progress.start();
-
-		(this as any).api('posts/search', Object.assign({}, parse(this.query), {
-			limit: limit
-		})).then(posts => {
-			this.posts = posts;
-			this.fetching = false;
-			Progress.done();
-		});
+		this.fetch();
 	},
 	methods: {
+		fetch() {
+			this.fetching = true;
+			Progress.start();
+
+			(this as any).api('posts/search', Object.assign({
+				limit: limit + 1
+			}, parse(this.q))).then(posts => {
+				if (posts.length == limit + 1) {
+					posts.pop();
+					this.existMore = true;
+				}
+				this.posts = posts;
+				this.fetching = false;
+				Progress.done();
+			});
+		},
 		more() {
 			this.offset += limit;
-			return (this as any).api('posts/search', Object.assign({}, parse(this.query), {
-				limit: limit,
+			(this as any).api('posts/search', Object.assign({
+				limit: limit + 1,
 				offset: this.offset
-			}));
+			}, parse(this.q))).then(posts => {
+				if (posts.length == limit + 1) {
+					posts.pop();
+				} else {
+					this.existMore = false;
+				}
+				this.posts = this.posts.concat(posts);
+			});
 		}
 	}
 });