From bb77b48efde71917127e7482c9a400cd699ef36e Mon Sep 17 00:00:00 2001
From: syuilo <syuilotan@yahoo.co.jp>
Date: Fri, 12 Oct 2018 14:28:48 +0900
Subject: [PATCH] Refactor and usability improvements

---
 src/client/app/common/scripts/note-mixin.ts   | 141 ++++++++++++
 .../desktop/views/components/notes.note.vue   | 202 +++---------------
 .../desktop/views/pages/deck/deck.note.vue    | 149 ++++++-------
 .../app/mobile/views/components/note.vue      | 148 ++++++-------
 4 files changed, 299 insertions(+), 341 deletions(-)
 create mode 100644 src/client/app/common/scripts/note-mixin.ts

diff --git a/src/client/app/common/scripts/note-mixin.ts b/src/client/app/common/scripts/note-mixin.ts
new file mode 100644
index 000000000..ac276dbe6
--- /dev/null
+++ b/src/client/app/common/scripts/note-mixin.ts
@@ -0,0 +1,141 @@
+import parse from '../../../../mfm/parse';
+import { sum } from '../../../../prelude/array';
+import MkNoteMenu from '..//views/components/note-menu.vue';
+import MkReactionPicker from '../views/components/reaction-picker.vue';
+
+function focus(el, fn) {
+	const target = fn(el);
+	if (target) {
+		if (target.hasAttribute('tabindex')) {
+			target.focus();
+		} else {
+			focus(target, fn);
+		}
+	}
+}
+
+type Opts = {
+	mobile?: boolean;
+};
+
+export default (opts: Opts = {}) => ({
+	data() {
+		return {
+			showContent: false
+		};
+	},
+
+	computed: {
+		keymap(): any {
+			return {
+				'r|left': () => this.reply(true),
+				'e|a|plus': () => this.react(true),
+				'q|right': () => this.renote(true),
+				'ctrl+q|ctrl+right': this.renoteDirectly,
+				'up|k|shift+tab': this.focusBefore,
+				'down|j|tab': this.focusAfter,
+				'esc': this.blur,
+				'm|o': () => this.menu(true),
+				's': this.toggleShowContent,
+				'1': () => this.reactDirectly('like'),
+				'2': () => this.reactDirectly('love'),
+				'3': () => this.reactDirectly('laugh'),
+				'4': () => this.reactDirectly('hmm'),
+				'5': () => this.reactDirectly('surprise'),
+				'6': () => this.reactDirectly('congrats'),
+				'7': () => this.reactDirectly('angry'),
+				'8': () => this.reactDirectly('confused'),
+				'9': () => this.reactDirectly('rip'),
+				'0': () => this.reactDirectly('pudding'),
+			};
+		},
+
+		isRenote(): boolean {
+			return (this.note.renote &&
+				this.note.text == null &&
+				this.note.fileIds.length == 0 &&
+				this.note.poll == null);
+		},
+
+		appearNote(): any {
+			return this.isRenote ? this.note.renote : this.note;
+		},
+
+		reactionsCount(): number {
+			return this.appearNote.reactionCounts
+				? sum(Object.values(this.appearNote.reactionCounts))
+				: 0;
+		},
+
+		title(): string {
+			return new Date(this.appearNote.createdAt).toLocaleString();
+		},
+
+		urls(): string[] {
+			if (this.appearNote.text) {
+				const ast = parse(this.appearNote.text);
+				return ast
+					.filter(t => (t.type == 'url' || t.type == 'link') && !t.silent)
+					.map(t => t.url);
+			} else {
+				return null;
+			}
+		}
+	},
+
+	methods: {
+		renoteDirectly() {
+			(this as any).api('notes/create', {
+				renoteId: this.appearNote.id
+			});
+		},
+
+		react(viaKeyboard = false) {
+			this.blur();
+			(this as any).os.new(MkReactionPicker, {
+				source: this.$refs.reactButton,
+				note: this.appearNote,
+				showFocus: viaKeyboard,
+				animation: !viaKeyboard,
+				compact: opts.mobile,
+				big: opts.mobile
+			}).$once('closed', this.focus);
+		},
+
+		reactDirectly(reaction) {
+			(this as any).api('notes/reactions/create', {
+				noteId: this.appearNote.id,
+				reaction: reaction
+			});
+		},
+
+		menu(viaKeyboard = false) {
+			(this as any).os.new(MkNoteMenu, {
+				source: this.$refs.menuButton,
+				note: this.appearNote,
+				animation: !viaKeyboard,
+				compact: opts.mobile,
+			}).$once('closed', this.focus);
+		},
+
+		toggleShowContent() {
+			this.showContent = !this.showContent;
+		},
+
+		focus() {
+			this.$el.focus();
+		},
+
+		blur() {
+			this.$el.blur();
+		},
+
+		focusBefore() {
+			focus(this.$el, e => e.previousElementSibling);
+		},
+
+		focusAfter() {
+			focus(this.$el, e => e.nextElementSibling);
+		}
+	}
+});
diff --git a/src/client/app/desktop/views/components/notes.note.vue b/src/client/app/desktop/views/components/notes.note.vue
index f6e100a2a..a53759cf4 100644
--- a/src/client/app/desktop/views/components/notes.note.vue
+++ b/src/client/app/desktop/views/components/notes.note.vue
@@ -1,7 +1,7 @@
 <template>
-<div class="note" v-show="p.deletedAt == null" :tabindex="p.deletedAt == null ? '-1' : null" v-hotkey="keymap" :title="title">
-	<div class="reply-to" v-if="p.reply && (!$store.getters.isSignedIn || $store.state.settings.showReplyTarget)">
-		<x-sub :note="p.reply"/>
+<div class="note" v-show="appearNote.deletedAt == null" :tabindex="appearNote.deletedAt == null ? '-1' : null" v-hotkey="keymap" :title="title">
+	<div class="reply-to" v-if="appearNote.reply && (!$store.getters.isSignedIn || $store.state.settings.showReplyTarget)">
+		<x-sub :note="appearNote.reply"/>
 	</div>
 	<div class="renote" v-if="isRenote">
 		<mk-avatar class="avatar" :user="note.user"/>
@@ -12,90 +12,70 @@
 		<mk-time :time="note.createdAt"/>
 	</div>
 	<article>
-		<mk-avatar class="avatar" :user="p.user"/>
+		<mk-avatar class="avatar" :user="appearNote.user"/>
 		<div class="main">
-			<mk-note-header class="header" :note="p"/>
+			<mk-note-header class="header" :note="appearNote"/>
 			<div class="body">
-				<p v-if="p.cw != null" class="cw">
-					<span class="text" v-if="p.cw != ''">{{ p.cw }}</span>
+				<p v-if="appearNote.cw != null" class="cw">
+					<span class="text" v-if="appearNote.cw != ''">{{ appearNote.cw }}</span>
 					<mk-cw-button v-model="showContent"/>
 				</p>
-				<div class="content" v-show="p.cw == null || showContent">
+				<div class="content" v-show="appearNote.cw == null || showContent">
 					<div class="text">
-						<span v-if="p.isHidden" style="opacity: 0.5">%i18n:@private%</span>
-						<span v-if="p.deletedAt" style="opacity: 0.5">%i18n:@deleted%</span>
-						<a class="reply" v-if="p.reply">%fa:reply%</a>
-						<misskey-flavored-markdown v-if="p.text" :text="p.text" :i="$store.state.i" :class="$style.text"/>
-						<a class="rp" v-if="p.renote">RP:</a>
+						<span v-if="appearNote.isHidden" style="opacity: 0.5">%i18n:@private%</span>
+						<a class="reply" v-if="appearNote.reply">%fa:reply%</a>
+						<misskey-flavored-markdown v-if="appearNote.text" :text="appearNote.text" :i="$store.state.i" :class="$style.text"/>
+						<a class="rp" v-if="appearNote.renote">RP:</a>
 					</div>
-					<div class="files" v-if="p.files.length > 0">
-						<mk-media-list :media-list="p.files"/>
+					<div class="files" v-if="appearNote.files.length > 0">
+						<mk-media-list :media-list="appearNote.files"/>
 					</div>
-					<mk-poll v-if="p.poll" :note="p" ref="pollViewer"/>
-					<a class="location" v-if="p.geo" :href="`https://maps.google.com/maps?q=${p.geo.coordinates[1]},${p.geo.coordinates[0]}`" target="_blank">%fa:map-marker-alt% 位置情報</a>
-					<div class="map" v-if="p.geo" ref="map"></div>
-					<div class="renote" v-if="p.renote"><mk-note-preview :note="p.renote"/></div>
+					<mk-poll v-if="appearNote.poll" :note="appearNote" ref="pollViewer"/>
+					<a class="location" v-if="appearNote.geo" :href="`https://maps.google.com/maps?q=${appearNote.geo.coordinates[1]},${appearNote.geo.coordinates[0]}`" target="_blank">%fa:map-marker-alt% 位置情報</a>
+					<div class="renote" v-if="appearNote.renote"><mk-note-preview :note="appearNote.renote"/></div>
 					<mk-url-preview v-for="url in urls" :url="url" :key="url"/>
 				</div>
 			</div>
-			<footer v-if="p.deletedAt == null">
-				<mk-reactions-viewer :note="p" ref="reactionsViewer"/>
+			<footer v-if="appearNote.deletedAt == null">
+				<mk-reactions-viewer :note="appearNote" ref="reactionsViewer"/>
 				<button class="replyButton" @click="reply()" title="%i18n:@reply%">
-					<template v-if="p.reply">%fa:reply-all%</template>
+					<template v-if="appearNote.reply">%fa:reply-all%</template>
 					<template v-else>%fa:reply%</template>
-					<p class="count" v-if="p.repliesCount > 0">{{ p.repliesCount }}</p>
+					<p class="count" v-if="appearNote.repliesCount > 0">{{ appearNote.repliesCount }}</p>
 				</button>
 				<button class="renoteButton" @click="renote()" title="%i18n:@renote%">
-					%fa:retweet%<p class="count" v-if="p.renoteCount > 0">{{ p.renoteCount }}</p>
+					%fa:retweet%<p class="count" v-if="appearNote.renoteCount > 0">{{ appearNote.renoteCount }}</p>
 				</button>
-				<button class="reactionButton" :class="{ reacted: p.myReaction != null }" @click="react()" ref="reactButton" title="%i18n:@add-reaction%">
-					%fa:plus%<p class="count" v-if="p.reactions_count > 0">{{ p.reactions_count }}</p>
+				<button class="reactionButton" :class="{ reacted: appearNote.myReaction != null }" @click="react()" ref="reactButton" title="%i18n:@add-reaction%">
+					%fa:plus%<p class="count" v-if="appearNote.reactions_count > 0">{{ appearNote.reactions_count }}</p>
 				</button>
 				<button @click="menu()" ref="menuButton">
 					%fa:ellipsis-h%
 				</button>
-				<!-- <button title="%i18n:@detail">
-					<template v-if="!isDetailOpened">%fa:caret-down%</template>
-					<template v-if="isDetailOpened">%fa:caret-up%</template>
-				</button> -->
 			</footer>
 		</div>
 	</article>
-	<div class="detail" v-if="isDetailOpened">
-		<mk-note-status-graph width="462" height="130" :note="p"/>
-	</div>
 </div>
 </template>
 
 <script lang="ts">
 import Vue from 'vue';
-import parse from '../../../../../mfm/parse';
 
 import MkPostFormWindow from './post-form-window.vue';
 import MkRenoteFormWindow from './renote-form-window.vue';
-import MkNoteMenu from '../../../common/views/components/note-menu.vue';
-import MkReactionPicker from '../../../common/views/components/reaction-picker.vue';
 import XSub from './notes.note.sub.vue';
-import { sum } from '../../../../../prelude/array';
+import noteMixin from '../../../common/scripts/note-mixin';
 import noteSubscriber from '../../../common/scripts/note-subscriber';
 
-function focus(el, fn) {
-	const target = fn(el);
-	if (target) {
-		if (target.hasAttribute('tabindex')) {
-			target.focus();
-		} else {
-			focus(target, fn);
-		}
-	}
-}
-
 export default Vue.extend({
 	components: {
 		XSub
 	},
 
-	mixins: [noteSubscriber('note')],
+	mixins: [
+		noteMixin(),
+		noteSubscriber('note')
+	],
 
 	props: {
 		note: {
@@ -104,136 +84,20 @@ export default Vue.extend({
 		}
 	},
 
-	data() {
-		return {
-			showContent: false,
-			isDetailOpened: false
-		};
-	},
-
-	computed: {
-		keymap(): any {
-			return {
-				'r|left': () => this.reply(true),
-				'e|a|plus': () => this.react(true),
-				'q|right': () => this.renote(true),
-				'ctrl+q|ctrl+right': this.renoteDirectly,
-				'up|k|shift+tab': this.focusBefore,
-				'down|j|tab': this.focusAfter,
-				'esc': this.blur,
-				'm|o': () => this.menu(true),
-				's': this.toggleShowContent,
-				'1': () => this.reactDirectly('like'),
-				'2': () => this.reactDirectly('love'),
-				'3': () => this.reactDirectly('laugh'),
-				'4': () => this.reactDirectly('hmm'),
-				'5': () => this.reactDirectly('surprise'),
-				'6': () => this.reactDirectly('congrats'),
-				'7': () => this.reactDirectly('angry'),
-				'8': () => this.reactDirectly('confused'),
-				'9': () => this.reactDirectly('rip'),
-				'0': () => this.reactDirectly('pudding'),
-			};
-		},
-
-		isRenote(): boolean {
-			return (this.note.renote &&
-				this.note.text == null &&
-				this.note.fileIds.length == 0 &&
-				this.note.poll == null);
-		},
-
-		p(): any {
-			return this.isRenote ? this.note.renote : this.note;
-		},
-
-		reactionsCount(): number {
-			return this.p.reactionCounts
-				? sum(Object.values(this.p.reactionCounts))
-				: 0;
-		},
-
-		title(): string {
-			return new Date(this.p.createdAt).toLocaleString();
-		},
-
-		urls(): string[] {
-			if (this.p.text) {
-				const ast = parse(this.p.text);
-				return ast
-					.filter(t => (t.type == 'url' || t.type == 'link') && !t.silent)
-					.map(t => t.url);
-			} else {
-				return null;
-			}
-		}
-	},
-
 	methods: {
 		reply(viaKeyboard = false) {
 			(this as any).os.new(MkPostFormWindow, {
-				reply: this.p,
+				reply: this.appearNote,
 				animation: !viaKeyboard
 			}).$once('closed', this.focus);
 		},
 
 		renote(viaKeyboard = false) {
 			(this as any).os.new(MkRenoteFormWindow, {
-				note: this.p,
+				note: this.appearNote,
 				animation: !viaKeyboard
 			}).$once('closed', this.focus);
 		},
-
-		renoteDirectly() {
-			(this as any).api('notes/create', {
-				renoteId: this.p.id
-			});
-		},
-
-		react(viaKeyboard = false) {
-			this.blur();
-			(this as any).os.new(MkReactionPicker, {
-				source: this.$refs.reactButton,
-				note: this.p,
-				showFocus: viaKeyboard,
-				animation: !viaKeyboard
-			}).$once('closed', this.focus);
-		},
-
-		reactDirectly(reaction) {
-			(this as any).api('notes/reactions/create', {
-				noteId: this.p.id,
-				reaction: reaction
-			});
-		},
-
-		menu(viaKeyboard = false) {
-			(this as any).os.new(MkNoteMenu, {
-				source: this.$refs.menuButton,
-				note: this.p,
-				animation: !viaKeyboard
-			}).$once('closed', this.focus);
-		},
-
-		toggleShowContent() {
-			this.showContent = !this.showContent;
-		},
-
-		focus() {
-			this.$el.focus();
-		},
-
-		blur() {
-			this.$el.blur();
-		},
-
-		focusBefore() {
-			focus(this.$el, e => e.previousElementSibling);
-		},
-
-		focusAfter() {
-			focus(this.$el, e => e.nextElementSibling);
-		}
 	}
 });
 </script>
@@ -445,10 +309,6 @@ export default Vue.extend({
 					&.reacted, &.reacted:hover
 						color var(--noteActionsReactionHover)
 
-	> .detail
-		padding-top 4px
-		background rgba(#000, 0.0125)
-
 </style>
 
 <style lang="stylus" module>
diff --git a/src/client/app/desktop/views/pages/deck/deck.note.vue b/src/client/app/desktop/views/pages/deck/deck.note.vue
index f495731c7..055bd7157 100644
--- a/src/client/app/desktop/views/pages/deck/deck.note.vue
+++ b/src/client/app/desktop/views/pages/deck/deck.note.vue
@@ -1,7 +1,15 @@
 <template>
-<div v-if="!mediaView" v-show="p.deletedAt == null" :tabindex="p.deletedAt == null ? '-1' : null" class="zyjjkidcqjnlegkqebitfviomuqmseqk" :class="{ renote: isRenote }">
-	<div class="reply-to" v-if="p.reply && (!$store.getters.isSignedIn || $store.state.settings.showReplyTarget)">
-		<x-sub :note="p.reply"/>
+<div
+	v-if="!mediaView"
+	v-show="appearNote.deletedAt == null"
+	:tabindex="appearNote.deletedAt == null ? '-1' : null"
+	class="zyjjkidcqjnlegkqebitfviomuqmseqk"
+	:class="{ renote: isRenote }"
+	v-hotkey="keymap"
+	:title="title"
+>
+	<div class="reply-to" v-if="appearNote.reply && (!$store.getters.isSignedIn || $store.state.settings.showReplyTarget)">
+		<x-sub :note="appearNote.reply"/>
 	</div>
 	<div class="renote" v-if="isRenote">
 		<mk-avatar class="avatar" :user="note.user"/>
@@ -12,43 +20,42 @@
 		<mk-time :time="note.createdAt"/>
 	</div>
 	<article>
-		<mk-avatar class="avatar" :user="p.user"/>
+		<mk-avatar class="avatar" :user="appearNote.user"/>
 		<div class="main">
-			<mk-note-header class="header" :note="p" :mini="true"/>
+			<mk-note-header class="header" :note="appearNote" :mini="true"/>
 			<div class="body">
-				<p v-if="p.cw != null" class="cw">
-					<span class="text" v-if="p.cw != ''">{{ p.cw }}</span>
+				<p v-if="appearNote.cw != null" class="cw">
+					<span class="text" v-if="appearNote.cw != ''">{{ appearNote.cw }}</span>
 					<mk-cw-button v-model="showContent"/>
 				</p>
-				<div class="content" v-show="p.cw == null || showContent">
+				<div class="content" v-show="appearNote.cw == null || showContent">
 					<div class="text">
-						<span v-if="p.isHidden" style="opacity: 0.5">(%i18n:@private%)</span>
-						<span v-if="p.deletedAt" style="opacity: 0.5">(%i18n:@deleted%)</span>
-						<a class="reply" v-if="p.reply">%fa:reply%</a>
-						<misskey-flavored-markdown v-if="p.text" :text="p.text" :i="$store.state.i"/>
-						<a class="rp" v-if="p.renote != null">RP:</a>
+						<span v-if="appearNote.isHidden" style="opacity: 0.5">(%i18n:@private%)</span>
+						<a class="reply" v-if="appearNote.reply">%fa:reply%</a>
+						<misskey-flavored-markdown v-if="appearNote.text" :text="appearNote.text" :i="$store.state.i"/>
+						<a class="rp" v-if="appearNote.renote != null">RP:</a>
 					</div>
-					<div class="files" v-if="p.files.length > 0">
-						<mk-media-list :media-list="p.files"/>
+					<div class="files" v-if="appearNote.files.length > 0">
+						<mk-media-list :media-list="appearNote.files"/>
 					</div>
-					<mk-poll v-if="p.poll" :note="p" ref="pollViewer"/>
-					<a class="location" v-if="p.geo" :href="`https://maps.google.com/maps?q=${p.geo.coordinates[1]},${p.geo.coordinates[0]}`" target="_blank">%fa:map-marker-alt% %i18n:@location%</a>
-					<div class="renote" v-if="p.renote">
-						<mk-note-preview :note="p.renote" :mini="true"/>
+					<mk-poll v-if="appearNote.poll" :note="appearNote" ref="pollViewer"/>
+					<a class="location" v-if="appearNote.geo" :href="`https://maps.google.com/maps?q=${appearNote.geo.coordinates[1]},${appearNote.geo.coordinates[0]}`" target="_blank">%fa:map-marker-alt% %i18n:@location%</a>
+					<div class="renote" v-if="appearNote.renote">
+						<mk-note-preview :note="appearNote.renote" :mini="true"/>
 					</div>
 					<mk-url-preview v-for="url in urls" :url="url" :key="url" :detail="false" :mini="true"/>
 				</div>
-				<span class="app" v-if="p.app">via <b>{{ p.app.name }}</b></span>
+				<span class="app" v-if="appearNote.app">via <b>{{ appearNote.app.name }}</b></span>
 			</div>
 			<footer>
-				<mk-reactions-viewer :note="p" ref="reactionsViewer"/>
-				<button @click="reply">
-					<template v-if="p.reply">%fa:reply-all%</template>
+				<mk-reactions-viewer :note="appearNote" ref="reactionsViewer"/>
+				<button @click="reply()">
+					<template v-if="appearNote.reply">%fa:reply-all%</template>
 					<template v-else>%fa:reply%</template>
 				</button>
-				<button @click="renote" title="Renote">%fa:retweet%</button>
-				<button :class="{ reacted: p.myReaction != null }" @click="react" ref="reactButton">%fa:plus%</button>
-				<button class="menu" @click="menu" ref="menuButton">%fa:ellipsis-h%</button>
+				<button @click="renote()" title="Renote">%fa:retweet%</button>
+				<button :class="{ reacted: appearNote.myReaction != null }" @click="react()" ref="reactButton">%fa:plus%</button>
+				<button class="menu" @click="menu()" ref="menuButton">%fa:ellipsis-h%</button>
 			</footer>
 		</div>
 	</article>
@@ -65,11 +72,10 @@
 
 <script lang="ts">
 import Vue from 'vue';
-import parse from '../../../../../../mfm/parse';
-
-import MkNoteMenu from '../../../../common/views/components/note-menu.vue';
-import MkReactionPicker from '../../../../common/views/components/reaction-picker.vue';
+import MkPostFormWindow from '../../components/post-form-window.vue';
+import MkRenoteFormWindow from '../../components/renote-form-window.vue';
 import XSub from './deck.note.sub.vue';
+import noteMixin from '../../../../common/scripts/note-mixin';
 import noteSubscriber from '../../../../common/scripts/note-subscriber';
 
 export default Vue.extend({
@@ -77,7 +83,10 @@ export default Vue.extend({
 		XSub
 	},
 
-	mixins: [noteSubscriber('note')],
+	mixins: [
+		noteMixin(),
+		noteSubscriber('note')
+	],
 
 	props: {
 		note: {
@@ -91,64 +100,20 @@ export default Vue.extend({
 		}
 	},
 
-	data() {
-		return {
-			showContent: false
-		};
-	},
-
-	computed: {
-		isRenote(): boolean {
-			return (this.note.renote &&
-				this.note.text == null &&
-				this.note.fileIds.length == 0 &&
-				this.note.poll == null);
-		},
-
-		p(): any {
-			return this.isRenote ? this.note.renote : this.note;
-		},
-
-		urls(): string[] {
-			if (this.p.text) {
-				const ast = parse(this.p.text);
-				return ast
-					.filter(t => (t.type == 'url' || t.type == 'link') && !t.silent)
-					.map(t => t.url);
-			} else {
-				return null;
-			}
-		}
-	},
-
 	methods: {
-		reply() {
-			(this as any).apis.post({
-				reply: this.p
-			});
+		reply(viaKeyboard = false) {
+			(this as any).os.new(MkPostFormWindow, {
+				reply: this.appearNote,
+				animation: !viaKeyboard
+			}).$once('closed', this.focus);
 		},
 
-		renote() {
-			(this as any).apis.post({
-				renote: this.p
-			});
+		renote(viaKeyboard = false) {
+			(this as any).os.new(MkRenoteFormWindow, {
+				note: this.appearNote,
+				animation: !viaKeyboard
+			}).$once('closed', this.focus);
 		},
-
-		react() {
-			(this as any).os.new(MkReactionPicker, {
-				source: this.$refs.reactButton,
-				note: this.p,
-				compact: true
-			});
-		},
-
-		menu() {
-			(this as any).os.new(MkNoteMenu, {
-				source: this.$refs.menuButton,
-				note: this.p,
-				compact: true
-			});
-		}
 	}
 });
 </script>
@@ -168,6 +133,20 @@ export default Vue.extend({
 	font-size 13px
 	border-bottom solid 1px var(--faceDivider)
 
+	&:focus
+		z-index 1
+
+		&:after
+			content ""
+			pointer-events none
+			position absolute
+			top 2px
+			right 2px
+			bottom 2px
+			left 2px
+			border 2px solid var(--primaryAlpha03)
+			border-radius 4px
+
 	&:last-of-type
 		border-bottom none
 
diff --git a/src/client/app/mobile/views/components/note.vue b/src/client/app/mobile/views/components/note.vue
index a63ae3f63..c68e6bf1a 100644
--- a/src/client/app/mobile/views/components/note.vue
+++ b/src/client/app/mobile/views/components/note.vue
@@ -1,7 +1,13 @@
 <template>
-<div class="note" v-show="p.deletedAt == null" :tabindex="p.deletedAt == null ? '-1' : null" :class="{ renote: isRenote, smart: $store.state.device.postStyle == 'smart' }">
-	<div class="reply-to" v-if="p.reply && (!$store.getters.isSignedIn || $store.state.settings.showReplyTarget)">
-		<x-sub :note="p.reply"/>
+<div
+	class="note"
+	v-show="appearNote.deletedAt == null"
+	:tabindex="appearNote.deletedAt == null ? '-1' : null"
+	:class="{ renote: isRenote, smart: $store.state.device.postStyle == 'smart' }"
+	v-hotkey="keymap"
+>
+	<div class="reply-to" v-if="appearNote.reply && (!$store.getters.isSignedIn || $store.state.settings.showReplyTarget)">
+		<x-sub :note="appearNote.reply"/>
 	</div>
 	<div class="renote" v-if="isRenote">
 		<mk-avatar class="avatar" :user="note.user"/>
@@ -12,47 +18,45 @@
 		<mk-time :time="note.createdAt"/>
 	</div>
 	<article>
-		<mk-avatar class="avatar" :user="p.user" v-if="$store.state.device.postStyle != 'smart'"/>
+		<mk-avatar class="avatar" :user="appearNote.user" v-if="$store.state.device.postStyle != 'smart'"/>
 		<div class="main">
-			<mk-note-header class="header" :note="p" :mini="true"/>
+			<mk-note-header class="header" :note="appearNote" :mini="true"/>
 			<div class="body">
-				<p v-if="p.cw != null" class="cw">
-					<span class="text" v-if="p.cw != ''">{{ p.cw }}</span>
+				<p v-if="appearNote.cw != null" class="cw">
+					<span class="text" v-if="appearNote.cw != ''">{{ appearNote.cw }}</span>
 					<mk-cw-button v-model="showContent"/>
 				</p>
-				<div class="content" v-show="p.cw == null || showContent">
+				<div class="content" v-show="appearNote.cw == null || showContent">
 					<div class="text">
-						<span v-if="p.isHidden" style="opacity: 0.5">(%i18n:@private%)</span>
-						<span v-if="p.deletedAt" style="opacity: 0.5">(%i18n:@deleted%)</span>
-						<a class="reply" v-if="p.reply">%fa:reply%</a>
-						<misskey-flavored-markdown v-if="p.text" :text="p.text" :i="$store.state.i" :class="$style.text"/>
-						<a class="rp" v-if="p.renote != null">RP:</a>
+						<span v-if="appearNote.isHidden" style="opacity: 0.5">(%i18n:@private%)</span>
+						<a class="reply" v-if="appearNote.reply">%fa:reply%</a>
+						<misskey-flavored-markdown v-if="appearNote.text" :text="appearNote.text" :i="$store.state.i" :class="$style.text"/>
+						<a class="rp" v-if="appearNote.renote != null">RP:</a>
 					</div>
-					<div class="files" v-if="p.files.length > 0">
-						<mk-media-list :media-list="p.files"/>
+					<div class="files" v-if="appearNote.files.length > 0">
+						<mk-media-list :media-list="appearNote.files"/>
 					</div>
-					<mk-poll v-if="p.poll" :note="p" ref="pollViewer"/>
+					<mk-poll v-if="appearNote.poll" :note="appearNote" ref="pollViewer"/>
 					<mk-url-preview v-for="url in urls" :url="url" :key="url"/>
-					<a class="location" v-if="p.geo" :href="`https://maps.google.com/maps?q=${p.geo.coordinates[1]},${p.geo.coordinates[0]}`" target="_blank">%fa:map-marker-alt% %i18n:@location%</a>
-					<div class="map" v-if="p.geo" ref="map"></div>
-					<div class="renote" v-if="p.renote"><mk-note-preview :note="p.renote"/></div>
+					<a class="location" v-if="appearNote.geo" :href="`https://maps.google.com/maps?q=${appearNote.geo.coordinates[1]},${appearNote.geo.coordinates[0]}`" target="_blank">%fa:map-marker-alt% %i18n:@location%</a>
+					<div class="renote" v-if="appearNote.renote"><mk-note-preview :note="appearNote.renote"/></div>
 				</div>
-				<span class="app" v-if="p.app">via <b>{{ p.app.name }}</b></span>
+				<span class="app" v-if="appearNote.app">via <b>{{ appearNote.apappearNote.name }}</b></span>
 			</div>
-			<footer v-if="p.deletedAt == null">
-				<mk-reactions-viewer :note="p" ref="reactionsViewer"/>
-				<button @click="reply">
-					<template v-if="p.reply">%fa:reply-all%</template>
+			<footer v-if="appearNote.deletedAt == null">
+				<mk-reactions-viewer :note="appearNote" ref="reactionsViewer"/>
+				<button @click="reply()">
+					<template v-if="appearNote.reply">%fa:reply-all%</template>
 					<template v-else>%fa:reply%</template>
-					<p class="count" v-if="p.repliesCount > 0">{{ p.repliesCount }}</p>
+					<p class="count" v-if="appearNote.repliesCount > 0">{{ appearNote.repliesCount }}</p>
 				</button>
-				<button @click="renote" title="Renote">
-					%fa:retweet%<p class="count" v-if="p.renoteCount > 0">{{ p.renoteCount }}</p>
+				<button @click="renote()" title="Renote">
+					%fa:retweet%<p class="count" v-if="appearNote.renoteCount > 0">{{ appearNote.renoteCount }}</p>
 				</button>
-				<button :class="{ reacted: p.myReaction != null }" @click="react" ref="reactButton">
-					%fa:plus%<p class="count" v-if="p.reactions_count > 0">{{ p.reactions_count }}</p>
+				<button :class="{ reacted: appearNote.myReaction != null }" @click="react()" ref="reactButton">
+					%fa:plus%<p class="count" v-if="appearNote.reactions_count > 0">{{ appearNote.reactions_count }}</p>
 				</button>
-				<button class="menu" @click="menu" ref="menuButton">
+				<button class="menu" @click="menu()" ref="menuButton">
 					%fa:ellipsis-h%
 				</button>
 			</footer>
@@ -63,12 +67,9 @@
 
 <script lang="ts">
 import Vue from 'vue';
-import parse from '../../../../../mfm/parse';
 
-import MkNoteMenu from '../../../common/views/components/note-menu.vue';
-import MkReactionPicker from '../../../common/views/components/reaction-picker.vue';
 import XSub from './note.sub.vue';
-import { sum } from '../../../../../prelude/array';
+import noteMixin from '../../../common/scripts/note-mixin';
 import noteSubscriber from '../../../common/scripts/note-subscriber';
 
 export default Vue.extend({
@@ -76,9 +77,19 @@ export default Vue.extend({
 		XSub
 	},
 
-	mixins: [noteSubscriber('note')],
+	mixins: [
+		noteMixin({
+			mobile: true
+		}),
+		noteSubscriber('note')
+	],
 
-	props: ['note'],
+	props: {
+		note: {
+			type: Object,
+			required: true
+		}
+	},
 
 	data() {
 		return {
@@ -86,65 +97,18 @@ export default Vue.extend({
 		};
 	},
 
-	computed: {
-		isRenote(): boolean {
-			return (this.note.renote &&
-				this.note.text == null &&
-				this.note.fileIds.length == 0 &&
-				this.note.poll == null);
-		},
-
-		p(): any {
-			return this.isRenote ? this.note.renote : this.note;
-		},
-
-		reactionsCount(): number {
-			return this.p.reactionCounts
-				? sum(Object.values(this.p.reactionCounts))
-				: 0;
-		},
-
-		urls(): string[] {
-			if (this.p.text) {
-				const ast = parse(this.p.text);
-				return ast
-					.filter(t => (t.type == 'url' || t.type == 'link') && !t.silent)
-					.map(t => t.url);
-			} else {
-				return null;
-			}
-		}
-	},
-
 	methods: {
 		reply() {
 			(this as any).apis.post({
-				reply: this.p
+				reply: this.appearNote
 			});
 		},
 
 		renote() {
 			(this as any).apis.post({
-				renote: this.p
+				renote: this.appearNote
 			});
 		},
-
-		react() {
-			(this as any).os.new(MkReactionPicker, {
-				source: this.$refs.reactButton,
-				note: this.p,
-				compact: true,
-				big: true
-			});
-		},
-
-		menu() {
-			(this as any).os.new(MkNoteMenu, {
-				source: this.$refs.menuButton,
-				note: this.p,
-				compact: true
-			});
-		}
 	}
 });
 </script>
@@ -154,6 +118,20 @@ export default Vue.extend({
 	font-size 12px
 	border-bottom solid 1px var(--faceDivider)
 
+	&:focus
+		z-index 1
+
+		&:after
+			content ""
+			pointer-events none
+			position absolute
+			top 2px
+			right 2px
+			bottom 2px
+			left 2px
+			border 2px solid var(--primaryAlpha03)
+			border-radius 4px
+
 	&:last-of-type
 		border-bottom none