summaryrefslogtreecommitdiff
path: root/src/client/components/note.vue
diff options
context:
space:
mode:
authorsyuilo <Syuilotan@yahoo.co.jp>2020-01-30 04:37:25 +0900
committerGitHub <noreply@github.com>2020-01-30 04:37:25 +0900
commitf6154dc0af1a0d65819e87240f4385f9573095cb (patch)
tree699a5ca07d6727b7f8497d4769f25d6d62f94b5a /src/client/components/note.vue
parentAdd Event activity-type support (#5785) (diff)
downloadmisskey-f6154dc0af1a0d65819e87240f4385f9573095cb.tar.gz
misskey-f6154dc0af1a0d65819e87240f4385f9573095cb.tar.bz2
misskey-f6154dc0af1a0d65819e87240f4385f9573095cb.zip
v12 (#5712)
Co-authored-by: MeiMei <30769358+mei23@users.noreply.github.com> Co-authored-by: Satsuki Yanagi <17376330+u1-liquid@users.noreply.github.com>
Diffstat (limited to 'src/client/components/note.vue')
-rw-r--r--src/client/components/note.vue729
1 files changed, 729 insertions, 0 deletions
diff --git a/src/client/components/note.vue b/src/client/components/note.vue
new file mode 100644
index 0000000000..8b3fa61a65
--- /dev/null
+++ b/src/client/components/note.vue
@@ -0,0 +1,729 @@
+<template>
+<div
+ class="note _panel"
+ v-show="appearNote.deletedAt == null && !hideThisNote"
+ :tabindex="appearNote.deletedAt == null ? '-1' : null"
+ :class="{ renote: isRenote }"
+ v-hotkey="keymap"
+ v-size="[{ max: 500 }, { max: 450 }, { max: 350 }, { max: 300 }]"
+>
+ <x-sub v-for="note in conversation" :key="note.id" :note="note"/>
+ <x-sub :note="appearNote.reply" class="reply-to" v-if="appearNote.reply"/>
+ <div class="pinned" v-if="pinned"><fa :icon="faThumbtack"/> {{ $t('pinnedNote') }}</div>
+ <div class="renote" v-if="isRenote">
+ <mk-avatar class="avatar" :user="note.user"/>
+ <fa :icon="faRetweet"/>
+ <i18n path="renotedBy" tag="span">
+ <router-link class="name" :to="note.user | userPage" v-user-preview="note.userId" place="user">
+ <mk-user-name :user="note.user"/>
+ </router-link>
+ </i18n>
+ <div class="info">
+ <mk-time :time="note.createdAt"/>
+ <span class="visibility" v-if="note.visibility != 'public'">
+ <fa v-if="note.visibility == 'home'" :icon="faHome"/>
+ <fa v-if="note.visibility == 'followers'" :icon="faUnlock"/>
+ <fa v-if="note.visibility == 'specified'" :icon="faEnvelope"/>
+ </span>
+ </div>
+ </div>
+ <article class="article">
+ <mk-avatar class="avatar" :user="appearNote.user"/>
+ <div class="main">
+ <x-note-header class="header" :note="appearNote" :mini="true"/>
+ <div class="body" v-if="appearNote.deletedAt == null">
+ <p v-if="appearNote.cw != null" class="cw">
+ <mfm v-if="appearNote.cw != ''" class="text" :text="appearNote.cw" :author="appearNote.user" :i="$store.state.i" :custom-emojis="appearNote.emojis" />
+ <x-cw-button v-model="showContent" :note="appearNote"/>
+ </p>
+ <div class="content" v-show="appearNote.cw == null || showContent">
+ <div class="text">
+ <span v-if="appearNote.isHidden" style="opacity: 0.5">({{ $t('private') }})</span>
+ <router-link class="reply" v-if="appearNote.replyId" :to="`/notes/${appearNote.replyId}`"><fa :icon="faReply"/></router-link>
+ <mfm v-if="appearNote.text" :text="appearNote.text" :author="appearNote.user" :i="$store.state.i" :custom-emojis="appearNote.emojis"/>
+ <a class="rp" v-if="appearNote.renote != null">RN:</a>
+ </div>
+ <div class="files" v-if="appearNote.files.length > 0">
+ <x-media-list :media-list="appearNote.files"/>
+ </div>
+ <x-poll v-if="appearNote.poll" :note="appearNote" ref="pollViewer"/>
+ <x-url-preview v-for="url in urls" :url="url" :key="url" :compact="true" class="url-preview"/>
+ <div class="renote" v-if="appearNote.renote"><x-note-preview :note="appearNote.renote"/></div>
+ </div>
+ </div>
+ <footer v-if="appearNote.deletedAt == null" class="footer">
+ <x-reactions-viewer :note="appearNote" ref="reactionsViewer"/>
+ <button @click="reply()" class="button _button">
+ <template v-if="appearNote.reply"><fa :icon="faReplyAll"/></template>
+ <template v-else><fa :icon="faReply"/></template>
+ <p class="count" v-if="appearNote.repliesCount > 0">{{ appearNote.repliesCount }}</p>
+ </button>
+ <button v-if="['public', 'home'].includes(appearNote.visibility)" @click="renote()" class="button _button" ref="renoteButton">
+ <fa :icon="faRetweet"/><p class="count" v-if="appearNote.renoteCount > 0">{{ appearNote.renoteCount }}</p>
+ </button>
+ <button v-else class="button _button">
+ <fa :icon="faBan"/>
+ </button>
+ <button v-if="!isMyNote && appearNote.myReaction == null" class="button _button" @click="react()" ref="reactButton">
+ <fa :icon="faPlus"/>
+ </button>
+ <button v-if="!isMyNote && appearNote.myReaction != null" class="button _button reacted" @click="undoReact(appearNote)" ref="reactButton">
+ <fa :icon="faMinus"/>
+ </button>
+ <button class="button _button" @click="menu()" ref="menuButton">
+ <fa :icon="faEllipsisH"/>
+ </button>
+ </footer>
+ <div class="deleted" v-if="appearNote.deletedAt != null">{{ $t('deleted') }}</div>
+ </div>
+ </article>
+ <x-sub v-for="note in replies" :key="note.id" :note="note"/>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import { faPlus, faMinus, faRetweet, faReply, faReplyAll, faEllipsisH, faHome, faUnlock, faEnvelope, faThumbtack, faBan } from '@fortawesome/free-solid-svg-icons';
+import { parse } from '../../mfm/parse';
+import { sum, unique } from '../../prelude/array';
+import i18n from '../i18n';
+import XSub from './note.sub.vue';
+import XNoteHeader from './note-header.vue';
+import XNotePreview from './note-preview.vue';
+import XReactionsViewer from './reactions-viewer.vue';
+import XMediaList from './media-list.vue';
+import XCwButton from './cw-button.vue';
+import XPoll from './poll.vue';
+import XUrlPreview from './url-preview.vue';
+import MkNoteMenu from './note-menu.vue';
+import MkReactionPicker from './reaction-picker.vue';
+import MkRenotePicker from './renote-picker.vue';
+import pleaseLogin from '../scripts/please-login';
+
+function focus(el, fn) {
+ const target = fn(el);
+ if (target) {
+ if (target.hasAttribute('tabindex')) {
+ target.focus();
+ } else {
+ focus(target, fn);
+ }
+ }
+}
+
+export default Vue.extend({
+ i18n,
+
+ components: {
+ XSub,
+ XNoteHeader,
+ XNotePreview,
+ XReactionsViewer,
+ XMediaList,
+ XCwButton,
+ XPoll,
+ XUrlPreview,
+ },
+
+ props: {
+ note: {
+ type: Object,
+ required: true
+ },
+ detail: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ pinned: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ },
+
+ data() {
+ return {
+ connection: null,
+ conversation: [],
+ replies: [],
+ showContent: false,
+ hideThisNote: false,
+ openingMenu: false,
+ faPlus, faMinus, faRetweet, faReply, faReplyAll, faEllipsisH, faHome, faUnlock, faEnvelope, faThumbtack, faBan
+ };
+ },
+
+ computed: {
+ keymap(): any {
+ return {
+ 'r': () => this.reply(true),
+ 'e|a|plus': () => this.react(true),
+ 'q': () => this.renote(true),
+ 'f|b': this.favorite,
+ 'delete|ctrl+d': this.del,
+ 'ctrl+q': 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(this.$store.state.settings.reactions[0]),
+ '2': () => this.reactDirectly(this.$store.state.settings.reactions[1]),
+ '3': () => this.reactDirectly(this.$store.state.settings.reactions[2]),
+ '4': () => this.reactDirectly(this.$store.state.settings.reactions[3]),
+ '5': () => this.reactDirectly(this.$store.state.settings.reactions[4]),
+ '6': () => this.reactDirectly(this.$store.state.settings.reactions[5]),
+ '7': () => this.reactDirectly(this.$store.state.settings.reactions[6]),
+ '8': () => this.reactDirectly(this.$store.state.settings.reactions[7]),
+ '9': () => this.reactDirectly(this.$store.state.settings.reactions[8]),
+ '0': () => this.reactDirectly(this.$store.state.settings.reactions[9]),
+ };
+ },
+
+ 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;
+ },
+
+ isMyNote(): boolean {
+ return this.$store.getters.isSignedIn && (this.$store.state.i.id === this.appearNote.userId);
+ },
+
+ reactionsCount(): number {
+ return this.appearNote.reactions
+ ? sum(Object.values(this.appearNote.reactions))
+ : 0;
+ },
+
+ title(): string {
+ return '';
+ },
+
+ urls(): string[] {
+ if (this.appearNote.text) {
+ const ast = parse(this.appearNote.text);
+ // TODO: 再帰的にURL要素がないか調べる
+ const urls = unique(ast
+ .filter(t => ((t.node.type == 'url' || t.node.type == 'link') && t.node.props.url && !t.node.props.silent))
+ .map(t => t.node.props.url));
+
+ // unique without hash
+ // [ http://a/#1, http://a/#2, http://b/#3 ] => [ http://a/#1, http://b/#3 ]
+ const removeHash = x => x.replace(/#[^#]*$/, '');
+
+ return urls.reduce((array, url) => {
+ const removed = removeHash(url);
+ if (!array.map(x => removeHash(x)).includes(removed)) array.push(url);
+ return array;
+ }, []);
+ } else {
+ return null;
+ }
+ }
+ },
+
+ created() {
+ if (this.$store.getters.isSignedIn) {
+ this.connection = this.$root.stream;
+ }
+
+ if (this.detail) {
+ this.$root.api('notes/children', {
+ noteId: this.appearNote.id,
+ limit: 30
+ }).then(replies => {
+ this.replies = replies;
+ });
+
+ if (this.appearNote.replyId) {
+ this.$root.api('notes/conversation', {
+ noteId: this.appearNote.replyId
+ }).then(conversation => {
+ this.conversation = conversation.reverse();
+ });
+ }
+ }
+ },
+
+ mounted() {
+ this.capture(true);
+
+ if (this.$store.getters.isSignedIn) {
+ this.connection.on('_connected_', this.onStreamConnected);
+ }
+ },
+
+ beforeDestroy() {
+ this.decapture(true);
+
+ if (this.$store.getters.isSignedIn) {
+ this.connection.off('_connected_', this.onStreamConnected);
+ }
+ },
+
+ methods: {
+ capture(withHandler = false) {
+ if (this.$store.getters.isSignedIn) {
+ this.connection.send('sn', { id: this.appearNote.id });
+ if (withHandler) this.connection.on('noteUpdated', this.onStreamNoteUpdated);
+ }
+ },
+
+ decapture(withHandler = false) {
+ if (this.$store.getters.isSignedIn) {
+ this.connection.send('un', {
+ id: this.appearNote.id
+ });
+ if (withHandler) this.connection.off('noteUpdated', this.onStreamNoteUpdated);
+ }
+ },
+
+ onStreamConnected() {
+ this.capture();
+ },
+
+ onStreamNoteUpdated(data) {
+ const { type, id, body } = data;
+
+ if (id !== this.appearNote.id) return;
+
+ switch (type) {
+ case 'reacted': {
+ const reaction = body.reaction;
+
+ if (this.appearNote.reactions == null) {
+ Vue.set(this.appearNote, 'reactions', {});
+ }
+
+ if (this.appearNote.reactions[reaction] == null) {
+ Vue.set(this.appearNote.reactions, reaction, 0);
+ }
+
+ // Increment the count
+ this.appearNote.reactions[reaction]++;
+
+ if (body.userId == this.$store.state.i.id) {
+ Vue.set(this.appearNote, 'myReaction', reaction);
+ }
+ break;
+ }
+
+ case 'unreacted': {
+ const reaction = body.reaction;
+
+ if (this.appearNote.reactions == null) {
+ return;
+ }
+
+ if (this.appearNote.reactions[reaction] == null) {
+ return;
+ }
+
+ // Decrement the count
+ if (this.appearNote.reactions[reaction] > 0) this.appearNote.reactions[reaction]--;
+
+ if (body.userId == this.$store.state.i.id) {
+ Vue.set(this.appearNote, 'myReaction', null);
+ }
+ break;
+ }
+
+ case 'pollVoted': {
+ const choice = body.choice;
+ this.appearNote.poll.choices[choice].votes++;
+ if (body.userId == this.$store.state.i.id) {
+ Vue.set(this.appearNote.poll.choices[choice], 'isVoted', true);
+ }
+ break;
+ }
+
+ case 'deleted': {
+ Vue.set(this.appearNote, 'deletedAt', body.deletedAt);
+ Vue.set(this.appearNote, 'renote', null);
+ this.appearNote.text = null;
+ this.appearNote.fileIds = [];
+ this.appearNote.poll = null;
+ this.appearNote.cw = null;
+ break;
+ }
+ }
+ },
+
+ reply(viaKeyboard = false) {
+ pleaseLogin(this.$root);
+ this.$root.post({
+ reply: this.appearNote,
+ animation: !viaKeyboard,
+ }, () => {
+ this.focus();
+ });
+ },
+
+ renote() {
+ pleaseLogin(this.$root);
+ this.blur();
+ this.$root.new(MkRenotePicker, {
+ source: this.$refs.renoteButton,
+ note: this.appearNote,
+ }).$once('closed', this.focus);
+ },
+
+ renoteDirectly() {
+ (this as any).$root.api('notes/create', {
+ renoteId: this.appearNote.id
+ });
+ },
+
+ react(viaKeyboard = false) {
+ pleaseLogin(this.$root);
+ this.blur();
+ const picker = this.$root.new(MkReactionPicker, {
+ source: this.$refs.reactButton,
+ showFocus: viaKeyboard,
+ });
+ picker.$once('chosen', reaction => {
+ this.$root.api('notes/reactions/create', {
+ noteId: this.appearNote.id,
+ reaction: reaction
+ }).then(() => {
+ picker.close();
+ });
+ });
+ picker.$once('closed', this.focus);
+ },
+
+ reactDirectly(reaction) {
+ this.$root.api('notes/reactions/create', {
+ noteId: this.appearNote.id,
+ reaction: reaction
+ });
+ },
+
+ undoReact(note) {
+ const oldReaction = note.myReaction;
+ if (!oldReaction) return;
+ this.$root.api('notes/reactions/delete', {
+ noteId: note.id
+ });
+ },
+
+ favorite() {
+ pleaseLogin(this.$root);
+ this.$root.api('notes/favorites/create', {
+ noteId: this.appearNote.id
+ }).then(() => {
+ this.$root.dialog({
+ type: 'success',
+ iconOnly: true, autoClose: true
+ });
+ });
+ },
+
+ del() {
+ this.$root.dialog({
+ type: 'warning',
+ text: this.$t('noteDeleteConfirm'),
+ showCancelButton: true
+ }).then(({ canceled }) => {
+ if (canceled) return;
+
+ this.$root.api('notes/delete', {
+ noteId: this.appearNote.id
+ });
+ });
+ },
+
+ menu(viaKeyboard = false) {
+ if (this.openingMenu) return;
+ this.openingMenu = true;
+ const w = this.$root.new(MkNoteMenu, {
+ source: this.$refs.menuButton,
+ note: this.appearNote,
+ animation: !viaKeyboard
+ }).$once('closed', () => {
+ this.openingMenu = false;
+ 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>
+
+<style lang="scss" scoped>
+.note {
+ position: relative;
+ transition: box-shadow 0.1s ease;
+
+ &.max-width_500px {
+ font-size: 0.9em;
+ }
+
+ &.max-width_450px {
+ > .renote {
+ padding: 8px 16px 0 16px;
+ }
+
+ > .article {
+ padding: 14px 16px 9px;
+
+ > .avatar {
+ margin: 0 10px 8px 0;
+ width: 50px;
+ height: 50px;
+ }
+ }
+ }
+
+ &.max-width_350px {
+ > .article {
+ > .main {
+ > .footer {
+ > .button {
+ &:not(:last-child) {
+ margin-right: 18px;
+ }
+ }
+ }
+ }
+ }
+ }
+
+ &.max-width_300px {
+ font-size: 0.825em;
+
+ > .article {
+ > .avatar {
+ width: 44px;
+ height: 44px;
+ }
+
+ > .main {
+ > .footer {
+ > .button {
+ &:not(:last-child) {
+ margin-right: 12px;
+ }
+ }
+ }
+ }
+ }
+ }
+
+ &:focus {
+ outline: none;
+ box-shadow: 0 0 0 3px var(--focus);
+ }
+
+ &:hover > .article > .main > .footer > .button {
+ opacity: 1;
+ }
+
+ > *:first-child {
+ border-radius: var(--radius) var(--radius) 0 0;
+ }
+
+ > *:last-child {
+ border-radius: 0 0 var(--radius) var(--radius);
+ }
+
+ > .pinned {
+ padding: 16px 32px 8px 32px;
+ line-height: 24px;
+ font-size: 90%;
+ white-space: pre;
+ color: #d28a3f;
+
+ @media (max-width: 450px) {
+ padding: 8px 16px 0 16px;
+ }
+
+ > [data-icon] {
+ margin-right: 4px;
+ }
+ }
+
+ > .pinned + .article {
+ padding-top: 8px;
+ }
+
+ > .renote {
+ display: flex;
+ align-items: center;
+ padding: 16px 32px 8px 32px;
+ line-height: 28px;
+ white-space: pre;
+ color: var(--renote);
+
+ > .avatar {
+ flex-shrink: 0;
+ display: inline-block;
+ width: 28px;
+ height: 28px;
+ margin: 0 8px 0 0;
+ border-radius: 6px;
+ }
+
+ > [data-icon] {
+ margin-right: 4px;
+ }
+
+ > span {
+ overflow: hidden;
+ flex-shrink: 1;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+
+ > .name {
+ font-weight: bold;
+ }
+ }
+
+ > .info {
+ margin-left: auto;
+ font-size: 0.9em;
+
+ > .mk-time {
+ flex-shrink: 0;
+ }
+
+ > .visibility {
+ margin-left: 8px;
+
+ [data-icon] {
+ margin-right: 0;
+ }
+ }
+ }
+ }
+
+ > .renote + .article {
+ padding-top: 8px;
+ }
+
+ > .article {
+ display: flex;
+ padding: 28px 32px 18px;
+
+ > .avatar {
+ flex-shrink: 0;
+ display: block;
+ //position: sticky;
+ //top: 72px;
+ margin: 0 14px 8px 0;
+ width: 58px;
+ height: 58px;
+ }
+
+ > .main {
+ flex: 1;
+ min-width: 0;
+
+ > .body {
+ > .cw {
+ cursor: default;
+ display: block;
+ margin: 0;
+ padding: 0;
+ overflow-wrap: break-word;
+
+ > .text {
+ margin-right: 8px;
+ }
+ }
+
+ > .content {
+ > .text {
+ overflow-wrap: break-word;
+
+ > .reply {
+ color: var(--accent);
+ margin-right: 0.5em;
+ }
+
+ > .rp {
+ margin-left: 4px;
+ font-style: oblique;
+ color: var(--renote);
+ }
+ }
+
+ > .url-preview {
+ margin-top: 8px;
+ }
+
+ > .mk-poll {
+ font-size: 80%;
+ }
+
+ > .renote {
+ padding: 8px 0;
+
+ > * {
+ padding: 16px;
+ border: dashed 1px var(--renote);
+ border-radius: 8px;
+ }
+ }
+ }
+ }
+
+ > .footer {
+ > .button {
+ margin: 0;
+ padding: 8px;
+ opacity: 0.7;
+
+ &:not(:last-child) {
+ margin-right: 28px;
+ }
+
+ &:hover {
+ color: var(--mkykhqkw);
+ }
+
+ > .count {
+ display: inline;
+ margin: 0 0 0 8px;
+ opacity: 0.7;
+ }
+
+ &.reacted {
+ color: var(--accent);
+ }
+ }
+ }
+
+ > .deleted {
+ opacity: 0.7;
+ }
+ }
+ }
+}
+</style>