wip: refactor(client): migrate paging components to composition api
This commit is contained in:
parent
27778f839a
commit
28193f12ca
@ -10,13 +10,13 @@
|
|||||||
<XCwButton v-model="showContent" :note="note"/>
|
<XCwButton v-model="showContent" :note="note"/>
|
||||||
</p>
|
</p>
|
||||||
<div v-show="note.cw == null || showContent" class="content">
|
<div v-show="note.cw == null || showContent" class="content">
|
||||||
<XSubNote-content class="text" :note="note"/>
|
<MkNoteSubNoteContent class="text" :note="note"/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<template v-if="depth < 5">
|
<template v-if="depth < 5">
|
||||||
<XSub v-for="reply in replies" :key="reply.id" :note="reply" class="reply" :detail="true" :depth="depth + 1"/>
|
<MkNoteSub v-for="reply in replies" :key="reply.id" :note="reply" class="reply" :detail="true" :depth="depth + 1"/>
|
||||||
</template>
|
</template>
|
||||||
<div v-else class="more">
|
<div v-else class="more">
|
||||||
<MkA class="text _link" :to="notePage(note)">{{ $ts.continueThread }} <i class="fas fa-angle-double-right"></i></MkA>
|
<MkA class="text _link" :to="notePage(note)">{{ $ts.continueThread }} <i class="fas fa-angle-double-right"></i></MkA>
|
||||||
@ -24,63 +24,36 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts" setup>
|
||||||
import { defineComponent } from 'vue';
|
import { } from 'vue';
|
||||||
|
import * as misskey from 'misskey-js';
|
||||||
import { notePage } from '@/filters/note';
|
import { notePage } from '@/filters/note';
|
||||||
import XNoteHeader from './note-header.vue';
|
import XNoteHeader from './note-header.vue';
|
||||||
import XSubNoteContent from './sub-note-content.vue';
|
import MkNoteSubNoteContent from './sub-note-content.vue';
|
||||||
import XCwButton from './cw-button.vue';
|
import XCwButton from './cw-button.vue';
|
||||||
import * as os from '@/os';
|
import * as os from '@/os';
|
||||||
|
|
||||||
export default defineComponent({
|
const props = withDefaults(defineProps<{
|
||||||
name: 'XSub',
|
note: misskey.entities.Note;
|
||||||
|
detail?: boolean;
|
||||||
|
|
||||||
components: {
|
// how many notes are in between this one and the note being viewed in detail
|
||||||
XNoteHeader,
|
depth?: number;
|
||||||
XSubNoteContent,
|
}>(), {
|
||||||
XCwButton,
|
depth: 1,
|
||||||
},
|
|
||||||
|
|
||||||
props: {
|
|
||||||
note: {
|
|
||||||
type: Object,
|
|
||||||
required: true
|
|
||||||
},
|
|
||||||
detail: {
|
|
||||||
type: Boolean,
|
|
||||||
required: false,
|
|
||||||
default: false
|
|
||||||
},
|
|
||||||
// how many notes are in between this one and the note being viewed in detail
|
|
||||||
depth: {
|
|
||||||
type: Number,
|
|
||||||
required: false,
|
|
||||||
default: 1
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
showContent: false,
|
|
||||||
replies: [],
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
created() {
|
|
||||||
if (this.detail) {
|
|
||||||
os.api('notes/children', {
|
|
||||||
noteId: this.note.id,
|
|
||||||
limit: 5
|
|
||||||
}).then(replies => {
|
|
||||||
this.replies = replies;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
methods: {
|
|
||||||
notePage,
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let showContent = $ref(false);
|
||||||
|
let replies: misskey.entities.Note[] = $ref([]);
|
||||||
|
|
||||||
|
if (props.detail) {
|
||||||
|
os.api('notes/children', {
|
||||||
|
noteId: props.note.id,
|
||||||
|
limit: 5
|
||||||
|
}).then(res => {
|
||||||
|
replies = res;
|
||||||
|
});
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
@ -8,8 +8,8 @@
|
|||||||
:tabindex="!isDeleted ? '-1' : null"
|
:tabindex="!isDeleted ? '-1' : null"
|
||||||
:class="{ renote: isRenote }"
|
:class="{ renote: isRenote }"
|
||||||
>
|
>
|
||||||
<XSub v-for="note in conversation" :key="note.id" class="reply-to-more" :note="note"/>
|
<MkNoteSub v-for="note in conversation" :key="note.id" class="reply-to-more" :note="note"/>
|
||||||
<XSub v-if="appearNote.reply" :note="appearNote.reply" class="reply-to"/>
|
<MkNoteSub v-if="appearNote.reply" :note="appearNote.reply" class="reply-to"/>
|
||||||
<div v-if="isRenote" class="renote">
|
<div v-if="isRenote" class="renote">
|
||||||
<MkAvatar class="avatar" :user="note.user"/>
|
<MkAvatar class="avatar" :user="note.user"/>
|
||||||
<i class="fas fa-retweet"></i>
|
<i class="fas fa-retweet"></i>
|
||||||
@ -107,7 +107,7 @@
|
|||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
<XSub v-for="note in replies" :key="note.id" :note="note" class="reply" :detail="true"/>
|
<MkNoteSub v-for="note in replies" :key="note.id" :note="note" class="reply" :detail="true"/>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="_panel muted" @click="muted = false">
|
<div v-else class="_panel muted" @click="muted = false">
|
||||||
<I18n :src="$ts.userSaysSomething" tag="small">
|
<I18n :src="$ts.userSaysSomething" tag="small">
|
||||||
@ -120,765 +120,171 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts" setup>
|
||||||
import { defineAsyncComponent, defineComponent, markRaw } from 'vue';
|
import { computed, inject, onMounted, onUnmounted, reactive, ref } from 'vue';
|
||||||
import * as mfm from 'mfm-js';
|
import * as mfm from 'mfm-js';
|
||||||
import { sum } from '@/scripts/array';
|
import * as misskey from 'misskey-js';
|
||||||
import XSub from './note.sub.vue';
|
import MkNoteSub from './MkNoteSub.vue';
|
||||||
import XNoteHeader from './note-header.vue';
|
|
||||||
import XNoteSimple from './note-simple.vue';
|
import XNoteSimple from './note-simple.vue';
|
||||||
import XReactionsViewer from './reactions-viewer.vue';
|
import XReactionsViewer from './reactions-viewer.vue';
|
||||||
import XMediaList from './media-list.vue';
|
import XMediaList from './media-list.vue';
|
||||||
import XCwButton from './cw-button.vue';
|
import XCwButton from './cw-button.vue';
|
||||||
import XPoll from './poll.vue';
|
import XPoll from './poll.vue';
|
||||||
import XRenoteButton from './renote-button.vue';
|
import XRenoteButton from './renote-button.vue';
|
||||||
|
import MkUrlPreview from '@/components/url-preview.vue';
|
||||||
|
import MkInstanceTicker from '@/components/instance-ticker.vue';
|
||||||
import { pleaseLogin } from '@/scripts/please-login';
|
import { pleaseLogin } from '@/scripts/please-login';
|
||||||
import { focusPrev, focusNext } from '@/scripts/focus';
|
|
||||||
import { url } from '@/config';
|
|
||||||
import copyToClipboard from '@/scripts/copy-to-clipboard';
|
|
||||||
import { checkWordMute } from '@/scripts/check-word-mute';
|
import { checkWordMute } from '@/scripts/check-word-mute';
|
||||||
import { userPage } from '@/filters/user';
|
import { userPage } from '@/filters/user';
|
||||||
import { notePage } from '@/filters/note';
|
import { notePage } from '@/filters/note';
|
||||||
import * as os from '@/os';
|
import * as os from '@/os';
|
||||||
import { stream } from '@/stream';
|
import { defaultStore, noteViewInterruptors } from '@/store';
|
||||||
import { noteActions, noteViewInterruptors } from '@/store';
|
|
||||||
import { reactionPicker } from '@/scripts/reaction-picker';
|
import { reactionPicker } from '@/scripts/reaction-picker';
|
||||||
import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm';
|
import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm';
|
||||||
|
import { $i } from '@/account';
|
||||||
// TODO: note.vueとほぼ同じなので共通化したい
|
import { i18n } from '@/i18n';
|
||||||
export default defineComponent({
|
import { getNoteMenu } from '@/scripts/get-note-menu';
|
||||||
components: {
|
import { useNoteCapture } from '@/scripts/use-note-capture';
|
||||||
XSub,
|
|
||||||
XNoteHeader,
|
const props = defineProps<{
|
||||||
XNoteSimple,
|
note: misskey.entities.Note;
|
||||||
XReactionsViewer,
|
pinned?: boolean;
|
||||||
XMediaList,
|
}>();
|
||||||
XCwButton,
|
|
||||||
XPoll,
|
const inChannel = inject('inChannel', null);
|
||||||
XRenoteButton,
|
|
||||||
MkUrlPreview: defineAsyncComponent(() => import('@/components/url-preview.vue')),
|
const isRenote = (
|
||||||
MkInstanceTicker: defineAsyncComponent(() => import('@/components/instance-ticker.vue')),
|
props.note.renote != null &&
|
||||||
},
|
props.note.text == null &&
|
||||||
|
props.note.fileIds.length === 0 &&
|
||||||
inject: {
|
props.note.poll == null
|
||||||
inChannel: {
|
);
|
||||||
default: null
|
|
||||||
},
|
const el = ref<HTMLElement>();
|
||||||
},
|
const menuButton = ref<HTMLElement>();
|
||||||
|
const renoteButton = ref<InstanceType<typeof XRenoteButton>>();
|
||||||
props: {
|
const renoteTime = ref<HTMLElement>();
|
||||||
note: {
|
const reactButton = ref<HTMLElement>();
|
||||||
type: Object,
|
let appearNote = $ref(isRenote ? props.note.renote as misskey.entities.Note : props.note);
|
||||||
required: true
|
const isMyRenote = $i && ($i.id === props.note.userId);
|
||||||
},
|
const showContent = ref(false);
|
||||||
},
|
const isDeleted = ref(false);
|
||||||
|
const muted = ref(checkWordMute(appearNote, $i, defaultStore.state.mutedWords));
|
||||||
emits: ['update:note'],
|
const translation = ref(null);
|
||||||
|
const translating = ref(false);
|
||||||
data() {
|
const urls = appearNote.text ? extractUrlFromMfm(mfm.parse(appearNote.text)) : null;
|
||||||
return {
|
const showTicker = (defaultStore.state.instanceTicker === 'always') || (defaultStore.state.instanceTicker === 'remote' && appearNote.user.instance);
|
||||||
connection: null,
|
const conversation = ref<misskey.entities.Note[]>([]);
|
||||||
conversation: [],
|
const replies = ref<misskey.entities.Note[]>([]);
|
||||||
replies: [],
|
|
||||||
showContent: false,
|
const keymap = {
|
||||||
isDeleted: false,
|
'r': () => reply(true),
|
||||||
muted: false,
|
'e|a|plus': () => react(true),
|
||||||
translation: null,
|
'q': () => renoteButton.value.renote(true),
|
||||||
translating: false,
|
'esc': blur,
|
||||||
notePage,
|
'm|o': () => menu(true),
|
||||||
};
|
's': () => showContent.value != showContent.value,
|
||||||
},
|
};
|
||||||
|
|
||||||
computed: {
|
useNoteCapture({
|
||||||
rs() {
|
appearNote: $$(appearNote),
|
||||||
return this.$store.state.reactions;
|
rootEl: el,
|
||||||
},
|
|
||||||
keymap(): any {
|
|
||||||
return {
|
|
||||||
'r': () => this.reply(true),
|
|
||||||
'e|a|plus': () => this.react(true),
|
|
||||||
'q': () => this.$refs.renoteButton.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.rs[0]),
|
|
||||||
'2': () => this.reactDirectly(this.rs[1]),
|
|
||||||
'3': () => this.reactDirectly(this.rs[2]),
|
|
||||||
'4': () => this.reactDirectly(this.rs[3]),
|
|
||||||
'5': () => this.reactDirectly(this.rs[4]),
|
|
||||||
'6': () => this.reactDirectly(this.rs[5]),
|
|
||||||
'7': () => this.reactDirectly(this.rs[6]),
|
|
||||||
'8': () => this.reactDirectly(this.rs[7]),
|
|
||||||
'9': () => this.reactDirectly(this.rs[8]),
|
|
||||||
'0': () => this.reactDirectly(this.rs[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.$i && (this.$i.id === this.appearNote.userId);
|
|
||||||
},
|
|
||||||
|
|
||||||
isMyRenote(): boolean {
|
|
||||||
return this.$i && (this.$i.id === this.note.userId);
|
|
||||||
},
|
|
||||||
|
|
||||||
reactionsCount(): number {
|
|
||||||
return this.appearNote.reactions
|
|
||||||
? sum(Object.values(this.appearNote.reactions))
|
|
||||||
: 0;
|
|
||||||
},
|
|
||||||
|
|
||||||
urls(): string[] {
|
|
||||||
if (this.appearNote.text) {
|
|
||||||
return extractUrlFromMfm(mfm.parse(this.appearNote.text));
|
|
||||||
} else {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
showTicker() {
|
|
||||||
if (this.$store.state.instanceTicker === 'always') return true;
|
|
||||||
if (this.$store.state.instanceTicker === 'remote' && this.appearNote.user.instance) return true;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async created() {
|
|
||||||
if (this.$i) {
|
|
||||||
this.connection = stream;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.muted = await checkWordMute(this.appearNote, this.$i, this.$store.state.mutedWords);
|
|
||||||
|
|
||||||
// plugin
|
|
||||||
if (noteViewInterruptors.length > 0) {
|
|
||||||
let result = this.note;
|
|
||||||
for (const interruptor of noteViewInterruptors) {
|
|
||||||
result = await interruptor.handler(JSON.parse(JSON.stringify(result)));
|
|
||||||
}
|
|
||||||
this.$emit('update:note', Object.freeze(result));
|
|
||||||
}
|
|
||||||
|
|
||||||
os.api('notes/children', {
|
|
||||||
noteId: this.appearNote.id,
|
|
||||||
limit: 30
|
|
||||||
}).then(replies => {
|
|
||||||
this.replies = replies;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (this.appearNote.replyId) {
|
|
||||||
os.api('notes/conversation', {
|
|
||||||
noteId: this.appearNote.replyId
|
|
||||||
}).then(conversation => {
|
|
||||||
this.conversation = conversation.reverse();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
mounted() {
|
|
||||||
this.capture(true);
|
|
||||||
|
|
||||||
if (this.$i) {
|
|
||||||
this.connection.on('_connected_', this.onStreamConnected);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
beforeUnmount() {
|
|
||||||
this.decapture(true);
|
|
||||||
|
|
||||||
if (this.$i) {
|
|
||||||
this.connection.off('_connected_', this.onStreamConnected);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
methods: {
|
|
||||||
updateAppearNote(v) {
|
|
||||||
this.$emit('update:note', Object.freeze(this.isRenote ? {
|
|
||||||
...this.note,
|
|
||||||
renote: {
|
|
||||||
...this.note.renote,
|
|
||||||
...v
|
|
||||||
}
|
|
||||||
} : {
|
|
||||||
...this.note,
|
|
||||||
...v
|
|
||||||
}));
|
|
||||||
},
|
|
||||||
|
|
||||||
readPromo() {
|
|
||||||
os.api('promo/read', {
|
|
||||||
noteId: this.appearNote.id
|
|
||||||
});
|
|
||||||
this.isDeleted = true;
|
|
||||||
},
|
|
||||||
|
|
||||||
capture(withHandler = false) {
|
|
||||||
if (this.$i) {
|
|
||||||
// TODO: このノートがストリーミング経由で流れてきた場合のみ sr する
|
|
||||||
this.connection.send(document.body.contains(this.$el) ? 'sr' : 's', { id: this.appearNote.id });
|
|
||||||
if (withHandler) this.connection.on('noteUpdated', this.onStreamNoteUpdated);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
decapture(withHandler = false) {
|
|
||||||
if (this.$i) {
|
|
||||||
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;
|
|
||||||
|
|
||||||
// DeepではなくShallowコピーであることに注意。n.reactions[reaction] = hogeとかしないように(親からもらったデータをミューテートすることになるので)
|
|
||||||
let n = {
|
|
||||||
...this.appearNote,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (body.emoji) {
|
|
||||||
const emojis = this.appearNote.emojis || [];
|
|
||||||
if (!emojis.includes(body.emoji)) {
|
|
||||||
n.emojis = [...emojis, body.emoji];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: reactionsプロパティがない場合ってあったっけ? なければ || {} は消せる
|
|
||||||
const currentCount = (this.appearNote.reactions || {})[reaction] || 0;
|
|
||||||
|
|
||||||
// Increment the count
|
|
||||||
n.reactions = {
|
|
||||||
...this.appearNote.reactions,
|
|
||||||
[reaction]: currentCount + 1
|
|
||||||
};
|
|
||||||
|
|
||||||
if (body.userId === this.$i.id) {
|
|
||||||
n.myReaction = reaction;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.updateAppearNote(n);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'unreacted': {
|
|
||||||
const reaction = body.reaction;
|
|
||||||
|
|
||||||
// DeepではなくShallowコピーであることに注意。n.reactions[reaction] = hogeとかしないように(親からもらったデータをミューテートすることになるので)
|
|
||||||
let n = {
|
|
||||||
...this.appearNote,
|
|
||||||
};
|
|
||||||
|
|
||||||
// TODO: reactionsプロパティがない場合ってあったっけ? なければ || {} は消せる
|
|
||||||
const currentCount = (this.appearNote.reactions || {})[reaction] || 0;
|
|
||||||
|
|
||||||
// Decrement the count
|
|
||||||
n.reactions = {
|
|
||||||
...this.appearNote.reactions,
|
|
||||||
[reaction]: Math.max(0, currentCount - 1)
|
|
||||||
};
|
|
||||||
|
|
||||||
if (body.userId === this.$i.id) {
|
|
||||||
n.myReaction = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.updateAppearNote(n);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'pollVoted': {
|
|
||||||
const choice = body.choice;
|
|
||||||
|
|
||||||
// DeepではなくShallowコピーであることに注意。n.reactions[reaction] = hogeとかしないように(親からもらったデータをミューテートすることになるので)
|
|
||||||
let n = {
|
|
||||||
...this.appearNote,
|
|
||||||
};
|
|
||||||
|
|
||||||
const choices = [...this.appearNote.poll.choices];
|
|
||||||
choices[choice] = {
|
|
||||||
...choices[choice],
|
|
||||||
votes: choices[choice].votes + 1,
|
|
||||||
...(body.userId === this.$i.id ? {
|
|
||||||
isVoted: true
|
|
||||||
} : {})
|
|
||||||
};
|
|
||||||
|
|
||||||
n.poll = {
|
|
||||||
...this.appearNote.poll,
|
|
||||||
choices: choices
|
|
||||||
};
|
|
||||||
|
|
||||||
this.updateAppearNote(n);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'deleted': {
|
|
||||||
this.isDeleted = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
reply(viaKeyboard = false) {
|
|
||||||
pleaseLogin();
|
|
||||||
os.post({
|
|
||||||
reply: this.appearNote,
|
|
||||||
animation: !viaKeyboard,
|
|
||||||
}, () => {
|
|
||||||
this.focus();
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
renoteDirectly() {
|
|
||||||
os.apiWithDialog('notes/create', {
|
|
||||||
renoteId: this.appearNote.id
|
|
||||||
}, undefined, (res: any) => {
|
|
||||||
os.alert({
|
|
||||||
type: 'success',
|
|
||||||
text: this.$ts.renoted,
|
|
||||||
});
|
|
||||||
}, (e: Error) => {
|
|
||||||
if (e.id === 'b5c90186-4ab0-49c8-9bba-a1f76c282ba4') {
|
|
||||||
os.alert({
|
|
||||||
type: 'error',
|
|
||||||
text: this.$ts.cantRenote,
|
|
||||||
});
|
|
||||||
} else if (e.id === 'fd4cc33e-2a37-48dd-99cc-9b806eb2031a') {
|
|
||||||
os.alert({
|
|
||||||
type: 'error',
|
|
||||||
text: this.$ts.cantReRenote,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
react(viaKeyboard = false) {
|
|
||||||
pleaseLogin();
|
|
||||||
this.blur();
|
|
||||||
reactionPicker.show(this.$refs.reactButton, reaction => {
|
|
||||||
os.api('notes/reactions/create', {
|
|
||||||
noteId: this.appearNote.id,
|
|
||||||
reaction: reaction
|
|
||||||
});
|
|
||||||
}, () => {
|
|
||||||
this.focus();
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
reactDirectly(reaction) {
|
|
||||||
os.api('notes/reactions/create', {
|
|
||||||
noteId: this.appearNote.id,
|
|
||||||
reaction: reaction
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
undoReact(note) {
|
|
||||||
const oldReaction = note.myReaction;
|
|
||||||
if (!oldReaction) return;
|
|
||||||
os.api('notes/reactions/delete', {
|
|
||||||
noteId: note.id
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
favorite() {
|
|
||||||
pleaseLogin();
|
|
||||||
os.apiWithDialog('notes/favorites/create', {
|
|
||||||
noteId: this.appearNote.id
|
|
||||||
}, undefined, (res: any) => {
|
|
||||||
os.alert({
|
|
||||||
type: 'success',
|
|
||||||
text: this.$ts.favorited,
|
|
||||||
});
|
|
||||||
}, (e: Error) => {
|
|
||||||
if (e.id === 'a402c12b-34dd-41d2-97d8-4d2ffd96a1a6') {
|
|
||||||
os.alert({
|
|
||||||
type: 'error',
|
|
||||||
text: this.$ts.alreadyFavorited,
|
|
||||||
});
|
|
||||||
} else if (e.id === '6dd26674-e060-4816-909a-45ba3f4da458') {
|
|
||||||
os.alert({
|
|
||||||
type: 'error',
|
|
||||||
text: this.$ts.cantFavorite,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
del() {
|
|
||||||
os.confirm({
|
|
||||||
type: 'warning',
|
|
||||||
text: this.$ts.noteDeleteConfirm,
|
|
||||||
}).then(({ canceled }) => {
|
|
||||||
if (canceled) return;
|
|
||||||
|
|
||||||
os.api('notes/delete', {
|
|
||||||
noteId: this.appearNote.id
|
|
||||||
});
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
delEdit() {
|
|
||||||
os.confirm({
|
|
||||||
type: 'warning',
|
|
||||||
text: this.$ts.deleteAndEditConfirm,
|
|
||||||
}).then(({ canceled }) => {
|
|
||||||
if (canceled) return;
|
|
||||||
|
|
||||||
os.api('notes/delete', {
|
|
||||||
noteId: this.appearNote.id
|
|
||||||
});
|
|
||||||
|
|
||||||
os.post({ initialNote: this.appearNote, renote: this.appearNote.renote, reply: this.appearNote.reply, channel: this.appearNote.channel });
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
toggleFavorite(favorite: boolean) {
|
|
||||||
os.apiWithDialog(favorite ? 'notes/favorites/create' : 'notes/favorites/delete', {
|
|
||||||
noteId: this.appearNote.id
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
toggleWatch(watch: boolean) {
|
|
||||||
os.apiWithDialog(watch ? 'notes/watching/create' : 'notes/watching/delete', {
|
|
||||||
noteId: this.appearNote.id
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
toggleThreadMute(mute: boolean) {
|
|
||||||
os.apiWithDialog(mute ? 'notes/thread-muting/create' : 'notes/thread-muting/delete', {
|
|
||||||
noteId: this.appearNote.id
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
getMenu() {
|
|
||||||
let menu;
|
|
||||||
if (this.$i) {
|
|
||||||
const statePromise = os.api('notes/state', {
|
|
||||||
noteId: this.appearNote.id
|
|
||||||
});
|
|
||||||
|
|
||||||
menu = [{
|
|
||||||
icon: 'fas fa-copy',
|
|
||||||
text: this.$ts.copyContent,
|
|
||||||
action: this.copyContent
|
|
||||||
}, {
|
|
||||||
icon: 'fas fa-link',
|
|
||||||
text: this.$ts.copyLink,
|
|
||||||
action: this.copyLink
|
|
||||||
}, (this.appearNote.url || this.appearNote.uri) ? {
|
|
||||||
icon: 'fas fa-external-link-square-alt',
|
|
||||||
text: this.$ts.showOnRemote,
|
|
||||||
action: () => {
|
|
||||||
window.open(this.appearNote.url || this.appearNote.uri, '_blank');
|
|
||||||
}
|
|
||||||
} : undefined,
|
|
||||||
{
|
|
||||||
icon: 'fas fa-share-alt',
|
|
||||||
text: this.$ts.share,
|
|
||||||
action: this.share
|
|
||||||
},
|
|
||||||
this.$instance.translatorAvailable ? {
|
|
||||||
icon: 'fas fa-language',
|
|
||||||
text: this.$ts.translate,
|
|
||||||
action: this.translate
|
|
||||||
} : undefined,
|
|
||||||
null,
|
|
||||||
statePromise.then(state => state.isFavorited ? {
|
|
||||||
icon: 'fas fa-star',
|
|
||||||
text: this.$ts.unfavorite,
|
|
||||||
action: () => this.toggleFavorite(false)
|
|
||||||
} : {
|
|
||||||
icon: 'fas fa-star',
|
|
||||||
text: this.$ts.favorite,
|
|
||||||
action: () => this.toggleFavorite(true)
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
icon: 'fas fa-paperclip',
|
|
||||||
text: this.$ts.clip,
|
|
||||||
action: () => this.clip()
|
|
||||||
},
|
|
||||||
(this.appearNote.userId != this.$i.id) ? statePromise.then(state => state.isWatching ? {
|
|
||||||
icon: 'fas fa-eye-slash',
|
|
||||||
text: this.$ts.unwatch,
|
|
||||||
action: () => this.toggleWatch(false)
|
|
||||||
} : {
|
|
||||||
icon: 'fas fa-eye',
|
|
||||||
text: this.$ts.watch,
|
|
||||||
action: () => this.toggleWatch(true)
|
|
||||||
}) : undefined,
|
|
||||||
statePromise.then(state => state.isMutedThread ? {
|
|
||||||
icon: 'fas fa-comment-slash',
|
|
||||||
text: this.$ts.unmuteThread,
|
|
||||||
action: () => this.toggleThreadMute(false)
|
|
||||||
} : {
|
|
||||||
icon: 'fas fa-comment-slash',
|
|
||||||
text: this.$ts.muteThread,
|
|
||||||
action: () => this.toggleThreadMute(true)
|
|
||||||
}),
|
|
||||||
this.appearNote.userId == this.$i.id ? (this.$i.pinnedNoteIds || []).includes(this.appearNote.id) ? {
|
|
||||||
icon: 'fas fa-thumbtack',
|
|
||||||
text: this.$ts.unpin,
|
|
||||||
action: () => this.togglePin(false)
|
|
||||||
} : {
|
|
||||||
icon: 'fas fa-thumbtack',
|
|
||||||
text: this.$ts.pin,
|
|
||||||
action: () => this.togglePin(true)
|
|
||||||
} : undefined,
|
|
||||||
/*...(this.$i.isModerator || this.$i.isAdmin ? [
|
|
||||||
null,
|
|
||||||
{
|
|
||||||
icon: 'fas fa-bullhorn',
|
|
||||||
text: this.$ts.promote,
|
|
||||||
action: this.promote
|
|
||||||
}]
|
|
||||||
: []
|
|
||||||
),*/
|
|
||||||
...(this.appearNote.userId != this.$i.id ? [
|
|
||||||
null,
|
|
||||||
{
|
|
||||||
icon: 'fas fa-exclamation-circle',
|
|
||||||
text: this.$ts.reportAbuse,
|
|
||||||
action: () => {
|
|
||||||
const u = `${url}/notes/${this.appearNote.id}`;
|
|
||||||
os.popup(import('@/components/abuse-report-window.vue'), {
|
|
||||||
user: this.appearNote.user,
|
|
||||||
initialComment: `Note: ${u}\n-----\n`
|
|
||||||
}, {}, 'closed');
|
|
||||||
}
|
|
||||||
}]
|
|
||||||
: []
|
|
||||||
),
|
|
||||||
...(this.appearNote.userId == this.$i.id || this.$i.isModerator || this.$i.isAdmin ? [
|
|
||||||
null,
|
|
||||||
this.appearNote.userId == this.$i.id ? {
|
|
||||||
icon: 'fas fa-edit',
|
|
||||||
text: this.$ts.deleteAndEdit,
|
|
||||||
action: this.delEdit
|
|
||||||
} : undefined,
|
|
||||||
{
|
|
||||||
icon: 'fas fa-trash-alt',
|
|
||||||
text: this.$ts.delete,
|
|
||||||
danger: true,
|
|
||||||
action: this.del
|
|
||||||
}]
|
|
||||||
: []
|
|
||||||
)]
|
|
||||||
.filter(x => x !== undefined);
|
|
||||||
} else {
|
|
||||||
menu = [{
|
|
||||||
icon: 'fas fa-copy',
|
|
||||||
text: this.$ts.copyContent,
|
|
||||||
action: this.copyContent
|
|
||||||
}, {
|
|
||||||
icon: 'fas fa-link',
|
|
||||||
text: this.$ts.copyLink,
|
|
||||||
action: this.copyLink
|
|
||||||
}, (this.appearNote.url || this.appearNote.uri) ? {
|
|
||||||
icon: 'fas fa-external-link-square-alt',
|
|
||||||
text: this.$ts.showOnRemote,
|
|
||||||
action: () => {
|
|
||||||
window.open(this.appearNote.url || this.appearNote.uri, '_blank');
|
|
||||||
}
|
|
||||||
} : undefined]
|
|
||||||
.filter(x => x !== undefined);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (noteActions.length > 0) {
|
|
||||||
menu = menu.concat([null, ...noteActions.map(action => ({
|
|
||||||
icon: 'fas fa-plug',
|
|
||||||
text: action.title,
|
|
||||||
action: () => {
|
|
||||||
action.handler(this.appearNote);
|
|
||||||
}
|
|
||||||
}))]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return menu;
|
|
||||||
},
|
|
||||||
|
|
||||||
onContextmenu(e) {
|
|
||||||
const isLink = (el: HTMLElement) => {
|
|
||||||
if (el.tagName === 'A') return true;
|
|
||||||
if (el.parentElement) {
|
|
||||||
return isLink(el.parentElement);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
if (isLink(e.target)) return;
|
|
||||||
if (window.getSelection().toString() !== '') return;
|
|
||||||
|
|
||||||
if (this.$store.state.useReactionPickerForContextMenu) {
|
|
||||||
e.preventDefault();
|
|
||||||
this.react();
|
|
||||||
} else {
|
|
||||||
os.contextMenu(this.getMenu(), e).then(this.focus);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
menu(viaKeyboard = false) {
|
|
||||||
os.popupMenu(this.getMenu(), this.$refs.menuButton, {
|
|
||||||
viaKeyboard
|
|
||||||
}).then(this.focus);
|
|
||||||
},
|
|
||||||
|
|
||||||
showRenoteMenu(viaKeyboard = false) {
|
|
||||||
if (!this.isMyRenote) return;
|
|
||||||
os.popupMenu([{
|
|
||||||
text: this.$ts.unrenote,
|
|
||||||
icon: 'fas fa-trash-alt',
|
|
||||||
danger: true,
|
|
||||||
action: () => {
|
|
||||||
os.api('notes/delete', {
|
|
||||||
noteId: this.note.id
|
|
||||||
});
|
|
||||||
this.isDeleted = true;
|
|
||||||
}
|
|
||||||
}], this.$refs.renoteTime, {
|
|
||||||
viaKeyboard: viaKeyboard
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
toggleShowContent() {
|
|
||||||
this.showContent = !this.showContent;
|
|
||||||
},
|
|
||||||
|
|
||||||
copyContent() {
|
|
||||||
copyToClipboard(this.appearNote.text);
|
|
||||||
os.success();
|
|
||||||
},
|
|
||||||
|
|
||||||
copyLink() {
|
|
||||||
copyToClipboard(`${url}/notes/${this.appearNote.id}`);
|
|
||||||
os.success();
|
|
||||||
},
|
|
||||||
|
|
||||||
togglePin(pin: boolean) {
|
|
||||||
os.apiWithDialog(pin ? 'i/pin' : 'i/unpin', {
|
|
||||||
noteId: this.appearNote.id
|
|
||||||
}, undefined, null, e => {
|
|
||||||
if (e.id === '72dab508-c64d-498f-8740-a8eec1ba385a') {
|
|
||||||
os.alert({
|
|
||||||
type: 'error',
|
|
||||||
text: this.$ts.pinLimitExceeded
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
async clip() {
|
|
||||||
const clips = await os.api('clips/list');
|
|
||||||
os.popupMenu([{
|
|
||||||
icon: 'fas fa-plus',
|
|
||||||
text: this.$ts.createNew,
|
|
||||||
action: async () => {
|
|
||||||
const { canceled, result } = await os.form(this.$ts.createNewClip, {
|
|
||||||
name: {
|
|
||||||
type: 'string',
|
|
||||||
label: this.$ts.name
|
|
||||||
},
|
|
||||||
description: {
|
|
||||||
type: 'string',
|
|
||||||
required: false,
|
|
||||||
multiline: true,
|
|
||||||
label: this.$ts.description
|
|
||||||
},
|
|
||||||
isPublic: {
|
|
||||||
type: 'boolean',
|
|
||||||
label: this.$ts.public,
|
|
||||||
default: false
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if (canceled) return;
|
|
||||||
|
|
||||||
const clip = await os.apiWithDialog('clips/create', result);
|
|
||||||
|
|
||||||
os.apiWithDialog('clips/add-note', { clipId: clip.id, noteId: this.appearNote.id });
|
|
||||||
}
|
|
||||||
}, null, ...clips.map(clip => ({
|
|
||||||
text: clip.name,
|
|
||||||
action: () => {
|
|
||||||
os.apiWithDialog('clips/add-note', { clipId: clip.id, noteId: this.appearNote.id });
|
|
||||||
}
|
|
||||||
}))], this.$refs.menuButton, {
|
|
||||||
}).then(this.focus);
|
|
||||||
},
|
|
||||||
|
|
||||||
async promote() {
|
|
||||||
const { canceled, result: days } = await os.inputNumber({
|
|
||||||
title: this.$ts.numberOfDays,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (canceled) return;
|
|
||||||
|
|
||||||
os.apiWithDialog('admin/promo/create', {
|
|
||||||
noteId: this.appearNote.id,
|
|
||||||
expiresAt: Date.now() + (86400000 * days)
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
share() {
|
|
||||||
navigator.share({
|
|
||||||
title: this.$t('noteOf', { user: this.appearNote.user.name }),
|
|
||||||
text: this.appearNote.text,
|
|
||||||
url: `${url}/notes/${this.appearNote.id}`
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
async translate() {
|
|
||||||
if (this.translation != null) return;
|
|
||||||
this.translating = true;
|
|
||||||
const res = await os.api('notes/translate', {
|
|
||||||
noteId: this.appearNote.id,
|
|
||||||
targetLang: localStorage.getItem('lang') || navigator.language,
|
|
||||||
});
|
|
||||||
this.translating = false;
|
|
||||||
this.translation = res;
|
|
||||||
},
|
|
||||||
|
|
||||||
focus() {
|
|
||||||
this.$el.focus();
|
|
||||||
},
|
|
||||||
|
|
||||||
blur() {
|
|
||||||
this.$el.blur();
|
|
||||||
},
|
|
||||||
|
|
||||||
focusBefore() {
|
|
||||||
focusPrev(this.$el);
|
|
||||||
},
|
|
||||||
|
|
||||||
focusAfter() {
|
|
||||||
focusNext(this.$el);
|
|
||||||
},
|
|
||||||
|
|
||||||
userPage
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function reply(viaKeyboard = false): void {
|
||||||
|
pleaseLogin();
|
||||||
|
os.post({
|
||||||
|
reply: appearNote,
|
||||||
|
animation: !viaKeyboard,
|
||||||
|
}, () => {
|
||||||
|
focus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function react(viaKeyboard = false): void {
|
||||||
|
pleaseLogin();
|
||||||
|
blur();
|
||||||
|
reactionPicker.show(reactButton.value, reaction => {
|
||||||
|
os.api('notes/reactions/create', {
|
||||||
|
noteId: appearNote.id,
|
||||||
|
reaction: reaction
|
||||||
|
});
|
||||||
|
}, () => {
|
||||||
|
focus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function undoReact(note): void {
|
||||||
|
const oldReaction = note.myReaction;
|
||||||
|
if (!oldReaction) return;
|
||||||
|
os.api('notes/reactions/delete', {
|
||||||
|
noteId: note.id
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function onContextmenu(e): void {
|
||||||
|
const isLink = (el: HTMLElement) => {
|
||||||
|
if (el.tagName === 'A') return true;
|
||||||
|
if (el.parentElement) {
|
||||||
|
return isLink(el.parentElement);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (isLink(e.target)) return;
|
||||||
|
if (window.getSelection().toString() !== '') return;
|
||||||
|
|
||||||
|
if (defaultStore.state.useReactionPickerForContextMenu) {
|
||||||
|
e.preventDefault();
|
||||||
|
react();
|
||||||
|
} else {
|
||||||
|
os.contextMenu(getNoteMenu({ note: props.note, translating, translation, menuButton }), e).then(focus);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function menu(viaKeyboard = false): void {
|
||||||
|
os.popupMenu(getNoteMenu({ note: props.note, translating, translation, menuButton }), menuButton.value, {
|
||||||
|
viaKeyboard
|
||||||
|
}).then(focus);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showRenoteMenu(viaKeyboard = false): void {
|
||||||
|
if (!isMyRenote) return;
|
||||||
|
os.popupMenu([{
|
||||||
|
text: i18n.locale.unrenote,
|
||||||
|
icon: 'fas fa-trash-alt',
|
||||||
|
danger: true,
|
||||||
|
action: () => {
|
||||||
|
os.api('notes/delete', {
|
||||||
|
noteId: props.note.id
|
||||||
|
});
|
||||||
|
isDeleted.value = true;
|
||||||
|
}
|
||||||
|
}], renoteTime.value, {
|
||||||
|
viaKeyboard: viaKeyboard
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function focus() {
|
||||||
|
el.value.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function blur() {
|
||||||
|
el.value.blur();
|
||||||
|
}
|
||||||
|
|
||||||
|
os.api('notes/children', {
|
||||||
|
noteId: appearNote.id,
|
||||||
|
limit: 30
|
||||||
|
}).then(res => {
|
||||||
|
replies.value = res;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (appearNote.replyId) {
|
||||||
|
os.api('notes/conversation', {
|
||||||
|
noteId: appearNote.replyId
|
||||||
|
}).then(res => {
|
||||||
|
conversation.value = res.reverse();
|
||||||
|
});
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
@ -19,30 +19,16 @@
|
|||||||
</header>
|
</header>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts" setup>
|
||||||
import { defineComponent } from 'vue';
|
import { } from 'vue';
|
||||||
|
import * as misskey from 'misskey-js';
|
||||||
import { notePage } from '@/filters/note';
|
import { notePage } from '@/filters/note';
|
||||||
import { userPage } from '@/filters/user';
|
import { userPage } from '@/filters/user';
|
||||||
import * as os from '@/os';
|
|
||||||
|
|
||||||
export default defineComponent({
|
defineProps<{
|
||||||
props: {
|
note: misskey.entities.Note;
|
||||||
note: {
|
pinned?: boolean;
|
||||||
type: Object,
|
}>();
|
||||||
required: true
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
methods: {
|
|
||||||
notePage,
|
|
||||||
userPage
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
@ -9,7 +9,7 @@
|
|||||||
<XCwButton v-model="showContent" :note="note"/>
|
<XCwButton v-model="showContent" :note="note"/>
|
||||||
</p>
|
</p>
|
||||||
<div v-show="note.cw == null || showContent" class="content">
|
<div v-show="note.cw == null || showContent" class="content">
|
||||||
<XSubNote-content class="text" :note="note"/>
|
<MkNoteSubNoteContent class="text" :note="note"/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -19,14 +19,14 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { defineComponent } from 'vue';
|
import { defineComponent } from 'vue';
|
||||||
import XNoteHeader from './note-header.vue';
|
import XNoteHeader from './note-header.vue';
|
||||||
import XSubNoteContent from './sub-note-content.vue';
|
import MkNoteSubNoteContent from './sub-note-content.vue';
|
||||||
import XCwButton from './cw-button.vue';
|
import XCwButton from './cw-button.vue';
|
||||||
import * as os from '@/os';
|
import * as os from '@/os';
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
components: {
|
components: {
|
||||||
XNoteHeader,
|
XNoteHeader,
|
||||||
XSubNoteContent,
|
MkNoteSubNoteContent,
|
||||||
XCwButton,
|
XCwButton,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@ -2,20 +2,21 @@
|
|||||||
<div
|
<div
|
||||||
v-if="!muted"
|
v-if="!muted"
|
||||||
v-show="!isDeleted"
|
v-show="!isDeleted"
|
||||||
|
ref="el"
|
||||||
v-hotkey="keymap"
|
v-hotkey="keymap"
|
||||||
v-size="{ max: [500, 450, 350, 300] }"
|
v-size="{ max: [500, 450, 350, 300] }"
|
||||||
class="tkcbzcuz"
|
class="tkcbzcuz"
|
||||||
:tabindex="!isDeleted ? '-1' : null"
|
:tabindex="!isDeleted ? '-1' : null"
|
||||||
:class="{ renote: isRenote }"
|
:class="{ renote: isRenote }"
|
||||||
>
|
>
|
||||||
<XSub v-if="appearNote.reply" :note="appearNote.reply" class="reply-to"/>
|
<MkNoteSub v-if="appearNote.reply" :note="appearNote.reply" class="reply-to"/>
|
||||||
<div v-if="pinned" class="info"><i class="fas fa-thumbtack"></i> {{ $ts.pinnedNote }}</div>
|
<div v-if="pinned" class="info"><i class="fas fa-thumbtack"></i> {{ i18n.locale.pinnedNote }}</div>
|
||||||
<div v-if="appearNote._prId_" class="info"><i class="fas fa-bullhorn"></i> {{ $ts.promotion }}<button class="_textButton hide" @click="readPromo()">{{ $ts.hideThisNote }} <i class="fas fa-times"></i></button></div>
|
<div v-if="appearNote._prId_" class="info"><i class="fas fa-bullhorn"></i> {{ i18n.locale.promotion }}<button class="_textButton hide" @click="readPromo()">{{ i18n.locale.hideThisNote }} <i class="fas fa-times"></i></button></div>
|
||||||
<div v-if="appearNote._featuredId_" class="info"><i class="fas fa-bolt"></i> {{ $ts.featured }}</div>
|
<div v-if="appearNote._featuredId_" class="info"><i class="fas fa-bolt"></i> {{ i18n.locale.featured }}</div>
|
||||||
<div v-if="isRenote" class="renote">
|
<div v-if="isRenote" class="renote">
|
||||||
<MkAvatar class="avatar" :user="note.user"/>
|
<MkAvatar class="avatar" :user="note.user"/>
|
||||||
<i class="fas fa-retweet"></i>
|
<i class="fas fa-retweet"></i>
|
||||||
<I18n :src="$ts.renotedBy" tag="span">
|
<I18n :src="i18n.locale.renotedBy" tag="span">
|
||||||
<template #user>
|
<template #user>
|
||||||
<MkA v-user-preview="note.userId" class="name" :to="userPage(note.user)">
|
<MkA v-user-preview="note.userId" class="name" :to="userPage(note.user)">
|
||||||
<MkUserName :user="note.user"/>
|
<MkUserName :user="note.user"/>
|
||||||
@ -47,7 +48,7 @@
|
|||||||
</p>
|
</p>
|
||||||
<div v-show="appearNote.cw == null || showContent" class="content" :class="{ collapsed }">
|
<div v-show="appearNote.cw == null || showContent" class="content" :class="{ collapsed }">
|
||||||
<div class="text">
|
<div class="text">
|
||||||
<span v-if="appearNote.isHidden" style="opacity: 0.5">({{ $ts.private }})</span>
|
<span v-if="appearNote.isHidden" style="opacity: 0.5">({{ i18n.locale.private }})</span>
|
||||||
<MkA v-if="appearNote.replyId" class="reply" :to="`/notes/${appearNote.replyId}`"><i class="fas fa-reply"></i></MkA>
|
<MkA v-if="appearNote.replyId" class="reply" :to="`/notes/${appearNote.replyId}`"><i class="fas fa-reply"></i></MkA>
|
||||||
<Mfm v-if="appearNote.text" :text="appearNote.text" :author="appearNote.user" :i="$i" :custom-emojis="appearNote.emojis"/>
|
<Mfm v-if="appearNote.text" :text="appearNote.text" :author="appearNote.user" :i="$i" :custom-emojis="appearNote.emojis"/>
|
||||||
<a v-if="appearNote.renote != null" class="rp">RN:</a>
|
<a v-if="appearNote.renote != null" class="rp">RN:</a>
|
||||||
@ -66,7 +67,7 @@
|
|||||||
<MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="false" class="url-preview"/>
|
<MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="false" class="url-preview"/>
|
||||||
<div v-if="appearNote.renote" class="renote"><XNoteSimple :note="appearNote.renote"/></div>
|
<div v-if="appearNote.renote" class="renote"><XNoteSimple :note="appearNote.renote"/></div>
|
||||||
<button v-if="collapsed" class="fade _button" @click="collapsed = false">
|
<button v-if="collapsed" class="fade _button" @click="collapsed = false">
|
||||||
<span>{{ $ts.showMore }}</span>
|
<span>{{ i18n.locale.showMore }}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<MkA v-if="appearNote.channel && !inChannel" class="channel" :to="`/channels/${appearNote.channel.id}`"><i class="fas fa-satellite-dish"></i> {{ appearNote.channel.name }}</MkA>
|
<MkA v-if="appearNote.channel && !inChannel" class="channel" :to="`/channels/${appearNote.channel.id}`"><i class="fas fa-satellite-dish"></i> {{ appearNote.channel.name }}</MkA>
|
||||||
@ -93,7 +94,7 @@
|
|||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="muted" @click="muted = false">
|
<div v-else class="muted" @click="muted = false">
|
||||||
<I18n :src="$ts.userSaysSomething" tag="small">
|
<I18n :src="i18n.locale.userSaysSomething" tag="small">
|
||||||
<template #name>
|
<template #name>
|
||||||
<MkA v-user-preview="appearNote.userId" class="name" :to="userPage(appearNote.user)">
|
<MkA v-user-preview="appearNote.userId" class="name" :to="userPage(appearNote.user)">
|
||||||
<MkUserName :user="appearNote.user"/>
|
<MkUserName :user="appearNote.user"/>
|
||||||
@ -103,11 +104,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts" setup>
|
||||||
import { defineAsyncComponent, defineComponent, markRaw } from 'vue';
|
import { computed, inject, onMounted, onUnmounted, reactive, ref } from 'vue';
|
||||||
import * as mfm from 'mfm-js';
|
import * as mfm from 'mfm-js';
|
||||||
import { sum } from '@/scripts/array';
|
import * as misskey from 'misskey-js';
|
||||||
import XSub from './note.sub.vue';
|
import MkNoteSub from './MkNoteSub.vue';
|
||||||
import XNoteHeader from './note-header.vue';
|
import XNoteHeader from './note-header.vue';
|
||||||
import XNoteSimple from './note-simple.vue';
|
import XNoteSimple from './note-simple.vue';
|
||||||
import XReactionsViewer from './reactions-viewer.vue';
|
import XReactionsViewer from './reactions-viewer.vue';
|
||||||
@ -115,745 +116,164 @@ import XMediaList from './media-list.vue';
|
|||||||
import XCwButton from './cw-button.vue';
|
import XCwButton from './cw-button.vue';
|
||||||
import XPoll from './poll.vue';
|
import XPoll from './poll.vue';
|
||||||
import XRenoteButton from './renote-button.vue';
|
import XRenoteButton from './renote-button.vue';
|
||||||
|
import MkUrlPreview from '@/components/url-preview.vue';
|
||||||
|
import MkInstanceTicker from '@/components/instance-ticker.vue';
|
||||||
import { pleaseLogin } from '@/scripts/please-login';
|
import { pleaseLogin } from '@/scripts/please-login';
|
||||||
import { focusPrev, focusNext } from '@/scripts/focus';
|
import { focusPrev, focusNext } from '@/scripts/focus';
|
||||||
import { url } from '@/config';
|
|
||||||
import copyToClipboard from '@/scripts/copy-to-clipboard';
|
|
||||||
import { checkWordMute } from '@/scripts/check-word-mute';
|
import { checkWordMute } from '@/scripts/check-word-mute';
|
||||||
import { userPage } from '@/filters/user';
|
import { userPage } from '@/filters/user';
|
||||||
import * as os from '@/os';
|
import * as os from '@/os';
|
||||||
import { stream } from '@/stream';
|
import { defaultStore, noteViewInterruptors } from '@/store';
|
||||||
import { noteActions, noteViewInterruptors } from '@/store';
|
|
||||||
import { reactionPicker } from '@/scripts/reaction-picker';
|
import { reactionPicker } from '@/scripts/reaction-picker';
|
||||||
import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm';
|
import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm';
|
||||||
|
import { $i } from '@/account';
|
||||||
export default defineComponent({
|
import { i18n } from '@/i18n';
|
||||||
components: {
|
import { getNoteMenu } from '@/scripts/get-note-menu';
|
||||||
XSub,
|
import { useNoteCapture } from '@/scripts/use-note-capture';
|
||||||
XNoteHeader,
|
|
||||||
XNoteSimple,
|
const props = defineProps<{
|
||||||
XReactionsViewer,
|
note: misskey.entities.Note;
|
||||||
XMediaList,
|
pinned?: boolean;
|
||||||
XCwButton,
|
}>();
|
||||||
XPoll,
|
|
||||||
XRenoteButton,
|
const inChannel = inject('inChannel', null);
|
||||||
MkUrlPreview: defineAsyncComponent(() => import('@/components/url-preview.vue')),
|
|
||||||
MkInstanceTicker: defineAsyncComponent(() => import('@/components/instance-ticker.vue')),
|
const isRenote = (
|
||||||
},
|
props.note.renote != null &&
|
||||||
|
props.note.text == null &&
|
||||||
inject: {
|
props.note.fileIds.length === 0 &&
|
||||||
inChannel: {
|
props.note.poll == null
|
||||||
default: null
|
);
|
||||||
},
|
|
||||||
},
|
const el = ref<HTMLElement>();
|
||||||
|
const menuButton = ref<HTMLElement>();
|
||||||
props: {
|
const renoteButton = ref<InstanceType<typeof XRenoteButton>>();
|
||||||
note: {
|
const renoteTime = ref<HTMLElement>();
|
||||||
type: Object,
|
const reactButton = ref<HTMLElement>();
|
||||||
required: true
|
let appearNote = $ref(isRenote ? props.note.renote as misskey.entities.Note : props.note);
|
||||||
},
|
const isMyRenote = $i && ($i.id === props.note.userId);
|
||||||
pinned: {
|
const showContent = ref(false);
|
||||||
type: Boolean,
|
const collapsed = ref(appearNote.cw == null && appearNote.text != null && (
|
||||||
required: false,
|
(appearNote.text.split('\n').length > 9) ||
|
||||||
default: false
|
(appearNote.text.length > 500)
|
||||||
},
|
));
|
||||||
},
|
const isDeleted = ref(false);
|
||||||
|
const muted = ref(checkWordMute(appearNote, $i, defaultStore.state.mutedWords));
|
||||||
emits: ['update:note'],
|
const translation = ref(null);
|
||||||
|
const translating = ref(false);
|
||||||
data() {
|
const urls = appearNote.text ? extractUrlFromMfm(mfm.parse(appearNote.text)) : null;
|
||||||
return {
|
const showTicker = (defaultStore.state.instanceTicker === 'always') || (defaultStore.state.instanceTicker === 'remote' && appearNote.user.instance);
|
||||||
connection: null,
|
|
||||||
replies: [],
|
const keymap = {
|
||||||
showContent: false,
|
'r': () => reply(true),
|
||||||
collapsed: false,
|
'e|a|plus': () => react(true),
|
||||||
isDeleted: false,
|
'q': () => renoteButton.value.renote(true),
|
||||||
muted: false,
|
'up|k|shift+tab': focusBefore,
|
||||||
translation: null,
|
'down|j|tab': focusAfter,
|
||||||
translating: false,
|
'esc': blur,
|
||||||
};
|
'm|o': () => menu(true),
|
||||||
},
|
's': () => showContent.value != showContent.value,
|
||||||
|
};
|
||||||
computed: {
|
|
||||||
rs() {
|
useNoteCapture({
|
||||||
return this.$store.state.reactions;
|
appearNote: $$(appearNote),
|
||||||
},
|
rootEl: el,
|
||||||
keymap(): any {
|
|
||||||
return {
|
|
||||||
'r': () => this.reply(true),
|
|
||||||
'e|a|plus': () => this.react(true),
|
|
||||||
'q': () => this.$refs.renoteButton.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.rs[0]),
|
|
||||||
'2': () => this.reactDirectly(this.rs[1]),
|
|
||||||
'3': () => this.reactDirectly(this.rs[2]),
|
|
||||||
'4': () => this.reactDirectly(this.rs[3]),
|
|
||||||
'5': () => this.reactDirectly(this.rs[4]),
|
|
||||||
'6': () => this.reactDirectly(this.rs[5]),
|
|
||||||
'7': () => this.reactDirectly(this.rs[6]),
|
|
||||||
'8': () => this.reactDirectly(this.rs[7]),
|
|
||||||
'9': () => this.reactDirectly(this.rs[8]),
|
|
||||||
'0': () => this.reactDirectly(this.rs[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.$i && (this.$i.id === this.appearNote.userId);
|
|
||||||
},
|
|
||||||
|
|
||||||
isMyRenote(): boolean {
|
|
||||||
return this.$i && (this.$i.id === this.note.userId);
|
|
||||||
},
|
|
||||||
|
|
||||||
reactionsCount(): number {
|
|
||||||
return this.appearNote.reactions
|
|
||||||
? sum(Object.values(this.appearNote.reactions))
|
|
||||||
: 0;
|
|
||||||
},
|
|
||||||
|
|
||||||
urls(): string[] {
|
|
||||||
if (this.appearNote.text) {
|
|
||||||
return extractUrlFromMfm(mfm.parse(this.appearNote.text));
|
|
||||||
} else {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
showTicker() {
|
|
||||||
if (this.$store.state.instanceTicker === 'always') return true;
|
|
||||||
if (this.$store.state.instanceTicker === 'remote' && this.appearNote.user.instance) return true;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async created() {
|
|
||||||
if (this.$i) {
|
|
||||||
this.connection = stream;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.collapsed = this.appearNote.cw == null && this.appearNote.text && (
|
|
||||||
(this.appearNote.text.split('\n').length > 9) ||
|
|
||||||
(this.appearNote.text.length > 500)
|
|
||||||
);
|
|
||||||
this.muted = await checkWordMute(this.appearNote, this.$i, this.$store.state.mutedWords);
|
|
||||||
|
|
||||||
// plugin
|
|
||||||
if (noteViewInterruptors.length > 0) {
|
|
||||||
let result = this.note;
|
|
||||||
for (const interruptor of noteViewInterruptors) {
|
|
||||||
result = await interruptor.handler(JSON.parse(JSON.stringify(result)));
|
|
||||||
}
|
|
||||||
this.$emit('update:note', Object.freeze(result));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
mounted() {
|
|
||||||
this.capture(true);
|
|
||||||
|
|
||||||
if (this.$i) {
|
|
||||||
this.connection.on('_connected_', this.onStreamConnected);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
beforeUnmount() {
|
|
||||||
this.decapture(true);
|
|
||||||
|
|
||||||
if (this.$i) {
|
|
||||||
this.connection.off('_connected_', this.onStreamConnected);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
methods: {
|
|
||||||
updateAppearNote(v) {
|
|
||||||
this.$emit('update:note', Object.freeze(this.isRenote ? {
|
|
||||||
...this.note,
|
|
||||||
renote: {
|
|
||||||
...this.note.renote,
|
|
||||||
...v
|
|
||||||
}
|
|
||||||
} : {
|
|
||||||
...this.note,
|
|
||||||
...v
|
|
||||||
}));
|
|
||||||
},
|
|
||||||
|
|
||||||
readPromo() {
|
|
||||||
os.api('promo/read', {
|
|
||||||
noteId: this.appearNote.id
|
|
||||||
});
|
|
||||||
this.isDeleted = true;
|
|
||||||
},
|
|
||||||
|
|
||||||
capture(withHandler = false) {
|
|
||||||
if (this.$i) {
|
|
||||||
// TODO: このノートがストリーミング経由で流れてきた場合のみ sr する
|
|
||||||
this.connection.send(document.body.contains(this.$el) ? 'sr' : 's', { id: this.appearNote.id });
|
|
||||||
if (withHandler) this.connection.on('noteUpdated', this.onStreamNoteUpdated);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
decapture(withHandler = false) {
|
|
||||||
if (this.$i) {
|
|
||||||
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;
|
|
||||||
|
|
||||||
// DeepではなくShallowコピーであることに注意。n.reactions[reaction] = hogeとかしないように(親からもらったデータをミューテートすることになるので)
|
|
||||||
let n = {
|
|
||||||
...this.appearNote,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (body.emoji) {
|
|
||||||
const emojis = this.appearNote.emojis || [];
|
|
||||||
if (!emojis.includes(body.emoji)) {
|
|
||||||
n.emojis = [...emojis, body.emoji];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: reactionsプロパティがない場合ってあったっけ? なければ || {} は消せる
|
|
||||||
const currentCount = (this.appearNote.reactions || {})[reaction] || 0;
|
|
||||||
|
|
||||||
// Increment the count
|
|
||||||
n.reactions = {
|
|
||||||
...this.appearNote.reactions,
|
|
||||||
[reaction]: currentCount + 1
|
|
||||||
};
|
|
||||||
|
|
||||||
if (body.userId === this.$i.id) {
|
|
||||||
n.myReaction = reaction;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.updateAppearNote(n);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'unreacted': {
|
|
||||||
const reaction = body.reaction;
|
|
||||||
|
|
||||||
// DeepではなくShallowコピーであることに注意。n.reactions[reaction] = hogeとかしないように(親からもらったデータをミューテートすることになるので)
|
|
||||||
let n = {
|
|
||||||
...this.appearNote,
|
|
||||||
};
|
|
||||||
|
|
||||||
// TODO: reactionsプロパティがない場合ってあったっけ? なければ || {} は消せる
|
|
||||||
const currentCount = (this.appearNote.reactions || {})[reaction] || 0;
|
|
||||||
|
|
||||||
// Decrement the count
|
|
||||||
n.reactions = {
|
|
||||||
...this.appearNote.reactions,
|
|
||||||
[reaction]: Math.max(0, currentCount - 1)
|
|
||||||
};
|
|
||||||
|
|
||||||
if (body.userId === this.$i.id) {
|
|
||||||
n.myReaction = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.updateAppearNote(n);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'pollVoted': {
|
|
||||||
const choice = body.choice;
|
|
||||||
|
|
||||||
// DeepではなくShallowコピーであることに注意。n.reactions[reaction] = hogeとかしないように(親からもらったデータをミューテートすることになるので)
|
|
||||||
let n = {
|
|
||||||
...this.appearNote,
|
|
||||||
};
|
|
||||||
|
|
||||||
const choices = [...this.appearNote.poll.choices];
|
|
||||||
choices[choice] = {
|
|
||||||
...choices[choice],
|
|
||||||
votes: choices[choice].votes + 1,
|
|
||||||
...(body.userId === this.$i.id ? {
|
|
||||||
isVoted: true
|
|
||||||
} : {})
|
|
||||||
};
|
|
||||||
|
|
||||||
n.poll = {
|
|
||||||
...this.appearNote.poll,
|
|
||||||
choices: choices
|
|
||||||
};
|
|
||||||
|
|
||||||
this.updateAppearNote(n);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'deleted': {
|
|
||||||
this.isDeleted = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
reply(viaKeyboard = false) {
|
|
||||||
pleaseLogin();
|
|
||||||
os.post({
|
|
||||||
reply: this.appearNote,
|
|
||||||
animation: !viaKeyboard,
|
|
||||||
}, () => {
|
|
||||||
this.focus();
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
renoteDirectly() {
|
|
||||||
os.apiWithDialog('notes/create', {
|
|
||||||
renoteId: this.appearNote.id
|
|
||||||
}, undefined, (res: any) => {
|
|
||||||
os.alert({
|
|
||||||
type: 'success',
|
|
||||||
text: this.$ts.renoted,
|
|
||||||
});
|
|
||||||
}, (e: Error) => {
|
|
||||||
if (e.id === 'b5c90186-4ab0-49c8-9bba-a1f76c282ba4') {
|
|
||||||
os.alert({
|
|
||||||
type: 'error',
|
|
||||||
text: this.$ts.cantRenote,
|
|
||||||
});
|
|
||||||
} else if (e.id === 'fd4cc33e-2a37-48dd-99cc-9b806eb2031a') {
|
|
||||||
os.alert({
|
|
||||||
type: 'error',
|
|
||||||
text: this.$ts.cantReRenote,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
react(viaKeyboard = false) {
|
|
||||||
pleaseLogin();
|
|
||||||
this.blur();
|
|
||||||
reactionPicker.show(this.$refs.reactButton, reaction => {
|
|
||||||
os.api('notes/reactions/create', {
|
|
||||||
noteId: this.appearNote.id,
|
|
||||||
reaction: reaction
|
|
||||||
});
|
|
||||||
}, () => {
|
|
||||||
this.focus();
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
reactDirectly(reaction) {
|
|
||||||
os.api('notes/reactions/create', {
|
|
||||||
noteId: this.appearNote.id,
|
|
||||||
reaction: reaction
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
undoReact(note) {
|
|
||||||
const oldReaction = note.myReaction;
|
|
||||||
if (!oldReaction) return;
|
|
||||||
os.api('notes/reactions/delete', {
|
|
||||||
noteId: note.id
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
favorite() {
|
|
||||||
pleaseLogin();
|
|
||||||
os.apiWithDialog('notes/favorites/create', {
|
|
||||||
noteId: this.appearNote.id
|
|
||||||
}, undefined, (res: any) => {
|
|
||||||
os.alert({
|
|
||||||
type: 'success',
|
|
||||||
text: this.$ts.favorited,
|
|
||||||
});
|
|
||||||
}, (e: Error) => {
|
|
||||||
if (e.id === 'a402c12b-34dd-41d2-97d8-4d2ffd96a1a6') {
|
|
||||||
os.alert({
|
|
||||||
type: 'error',
|
|
||||||
text: this.$ts.alreadyFavorited,
|
|
||||||
});
|
|
||||||
} else if (e.id === '6dd26674-e060-4816-909a-45ba3f4da458') {
|
|
||||||
os.alert({
|
|
||||||
type: 'error',
|
|
||||||
text: this.$ts.cantFavorite,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
del() {
|
|
||||||
os.confirm({
|
|
||||||
type: 'warning',
|
|
||||||
text: this.$ts.noteDeleteConfirm,
|
|
||||||
}).then(({ canceled }) => {
|
|
||||||
if (canceled) return;
|
|
||||||
|
|
||||||
os.api('notes/delete', {
|
|
||||||
noteId: this.appearNote.id
|
|
||||||
});
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
delEdit() {
|
|
||||||
os.confirm({
|
|
||||||
type: 'warning',
|
|
||||||
text: this.$ts.deleteAndEditConfirm,
|
|
||||||
}).then(({ canceled }) => {
|
|
||||||
if (canceled) return;
|
|
||||||
|
|
||||||
os.api('notes/delete', {
|
|
||||||
noteId: this.appearNote.id
|
|
||||||
});
|
|
||||||
|
|
||||||
os.post({ initialNote: this.appearNote, renote: this.appearNote.renote, reply: this.appearNote.reply, channel: this.appearNote.channel });
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
toggleFavorite(favorite: boolean) {
|
|
||||||
os.apiWithDialog(favorite ? 'notes/favorites/create' : 'notes/favorites/delete', {
|
|
||||||
noteId: this.appearNote.id
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
toggleWatch(watch: boolean) {
|
|
||||||
os.apiWithDialog(watch ? 'notes/watching/create' : 'notes/watching/delete', {
|
|
||||||
noteId: this.appearNote.id
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
toggleThreadMute(mute: boolean) {
|
|
||||||
os.apiWithDialog(mute ? 'notes/thread-muting/create' : 'notes/thread-muting/delete', {
|
|
||||||
noteId: this.appearNote.id
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
getMenu() {
|
|
||||||
let menu;
|
|
||||||
if (this.$i) {
|
|
||||||
const statePromise = os.api('notes/state', {
|
|
||||||
noteId: this.appearNote.id
|
|
||||||
});
|
|
||||||
|
|
||||||
menu = [{
|
|
||||||
icon: 'fas fa-copy',
|
|
||||||
text: this.$ts.copyContent,
|
|
||||||
action: this.copyContent
|
|
||||||
}, {
|
|
||||||
icon: 'fas fa-link',
|
|
||||||
text: this.$ts.copyLink,
|
|
||||||
action: this.copyLink
|
|
||||||
}, (this.appearNote.url || this.appearNote.uri) ? {
|
|
||||||
icon: 'fas fa-external-link-square-alt',
|
|
||||||
text: this.$ts.showOnRemote,
|
|
||||||
action: () => {
|
|
||||||
window.open(this.appearNote.url || this.appearNote.uri, '_blank');
|
|
||||||
}
|
|
||||||
} : undefined,
|
|
||||||
{
|
|
||||||
icon: 'fas fa-share-alt',
|
|
||||||
text: this.$ts.share,
|
|
||||||
action: this.share
|
|
||||||
},
|
|
||||||
this.$instance.translatorAvailable ? {
|
|
||||||
icon: 'fas fa-language',
|
|
||||||
text: this.$ts.translate,
|
|
||||||
action: this.translate
|
|
||||||
} : undefined,
|
|
||||||
null,
|
|
||||||
statePromise.then(state => state.isFavorited ? {
|
|
||||||
icon: 'fas fa-star',
|
|
||||||
text: this.$ts.unfavorite,
|
|
||||||
action: () => this.toggleFavorite(false)
|
|
||||||
} : {
|
|
||||||
icon: 'fas fa-star',
|
|
||||||
text: this.$ts.favorite,
|
|
||||||
action: () => this.toggleFavorite(true)
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
icon: 'fas fa-paperclip',
|
|
||||||
text: this.$ts.clip,
|
|
||||||
action: () => this.clip()
|
|
||||||
},
|
|
||||||
(this.appearNote.userId != this.$i.id) ? statePromise.then(state => state.isWatching ? {
|
|
||||||
icon: 'fas fa-eye-slash',
|
|
||||||
text: this.$ts.unwatch,
|
|
||||||
action: () => this.toggleWatch(false)
|
|
||||||
} : {
|
|
||||||
icon: 'fas fa-eye',
|
|
||||||
text: this.$ts.watch,
|
|
||||||
action: () => this.toggleWatch(true)
|
|
||||||
}) : undefined,
|
|
||||||
statePromise.then(state => state.isMutedThread ? {
|
|
||||||
icon: 'fas fa-comment-slash',
|
|
||||||
text: this.$ts.unmuteThread,
|
|
||||||
action: () => this.toggleThreadMute(false)
|
|
||||||
} : {
|
|
||||||
icon: 'fas fa-comment-slash',
|
|
||||||
text: this.$ts.muteThread,
|
|
||||||
action: () => this.toggleThreadMute(true)
|
|
||||||
}),
|
|
||||||
this.appearNote.userId == this.$i.id ? (this.$i.pinnedNoteIds || []).includes(this.appearNote.id) ? {
|
|
||||||
icon: 'fas fa-thumbtack',
|
|
||||||
text: this.$ts.unpin,
|
|
||||||
action: () => this.togglePin(false)
|
|
||||||
} : {
|
|
||||||
icon: 'fas fa-thumbtack',
|
|
||||||
text: this.$ts.pin,
|
|
||||||
action: () => this.togglePin(true)
|
|
||||||
} : undefined,
|
|
||||||
/*
|
|
||||||
...(this.$i.isModerator || this.$i.isAdmin ? [
|
|
||||||
null,
|
|
||||||
{
|
|
||||||
icon: 'fas fa-bullhorn',
|
|
||||||
text: this.$ts.promote,
|
|
||||||
action: this.promote
|
|
||||||
}]
|
|
||||||
: []
|
|
||||||
),*/
|
|
||||||
...(this.appearNote.userId != this.$i.id ? [
|
|
||||||
null,
|
|
||||||
{
|
|
||||||
icon: 'fas fa-exclamation-circle',
|
|
||||||
text: this.$ts.reportAbuse,
|
|
||||||
action: () => {
|
|
||||||
const u = `${url}/notes/${this.appearNote.id}`;
|
|
||||||
os.popup(import('@/components/abuse-report-window.vue'), {
|
|
||||||
user: this.appearNote.user,
|
|
||||||
initialComment: `Note: ${u}\n-----\n`
|
|
||||||
}, {}, 'closed');
|
|
||||||
}
|
|
||||||
}]
|
|
||||||
: []
|
|
||||||
),
|
|
||||||
...(this.appearNote.userId == this.$i.id || this.$i.isModerator || this.$i.isAdmin ? [
|
|
||||||
null,
|
|
||||||
this.appearNote.userId == this.$i.id ? {
|
|
||||||
icon: 'fas fa-edit',
|
|
||||||
text: this.$ts.deleteAndEdit,
|
|
||||||
action: this.delEdit
|
|
||||||
} : undefined,
|
|
||||||
{
|
|
||||||
icon: 'fas fa-trash-alt',
|
|
||||||
text: this.$ts.delete,
|
|
||||||
danger: true,
|
|
||||||
action: this.del
|
|
||||||
}]
|
|
||||||
: []
|
|
||||||
)]
|
|
||||||
.filter(x => x !== undefined);
|
|
||||||
} else {
|
|
||||||
menu = [{
|
|
||||||
icon: 'fas fa-copy',
|
|
||||||
text: this.$ts.copyContent,
|
|
||||||
action: this.copyContent
|
|
||||||
}, {
|
|
||||||
icon: 'fas fa-link',
|
|
||||||
text: this.$ts.copyLink,
|
|
||||||
action: this.copyLink
|
|
||||||
}, (this.appearNote.url || this.appearNote.uri) ? {
|
|
||||||
icon: 'fas fa-external-link-square-alt',
|
|
||||||
text: this.$ts.showOnRemote,
|
|
||||||
action: () => {
|
|
||||||
window.open(this.appearNote.url || this.appearNote.uri, '_blank');
|
|
||||||
}
|
|
||||||
} : undefined]
|
|
||||||
.filter(x => x !== undefined);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (noteActions.length > 0) {
|
|
||||||
menu = menu.concat([null, ...noteActions.map(action => ({
|
|
||||||
icon: 'fas fa-plug',
|
|
||||||
text: action.title,
|
|
||||||
action: () => {
|
|
||||||
action.handler(this.appearNote);
|
|
||||||
}
|
|
||||||
}))]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return menu;
|
|
||||||
},
|
|
||||||
|
|
||||||
onContextmenu(e) {
|
|
||||||
const isLink = (el: HTMLElement) => {
|
|
||||||
if (el.tagName === 'A') return true;
|
|
||||||
if (el.parentElement) {
|
|
||||||
return isLink(el.parentElement);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
if (isLink(e.target)) return;
|
|
||||||
if (window.getSelection().toString() !== '') return;
|
|
||||||
|
|
||||||
if (this.$store.state.useReactionPickerForContextMenu) {
|
|
||||||
e.preventDefault();
|
|
||||||
this.react();
|
|
||||||
} else {
|
|
||||||
os.contextMenu(this.getMenu(), e).then(this.focus);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
menu(viaKeyboard = false) {
|
|
||||||
os.popupMenu(this.getMenu(), this.$refs.menuButton, {
|
|
||||||
viaKeyboard
|
|
||||||
}).then(this.focus);
|
|
||||||
},
|
|
||||||
|
|
||||||
showRenoteMenu(viaKeyboard = false) {
|
|
||||||
if (!this.isMyRenote) return;
|
|
||||||
os.popupMenu([{
|
|
||||||
text: this.$ts.unrenote,
|
|
||||||
icon: 'fas fa-trash-alt',
|
|
||||||
danger: true,
|
|
||||||
action: () => {
|
|
||||||
os.api('notes/delete', {
|
|
||||||
noteId: this.note.id
|
|
||||||
});
|
|
||||||
this.isDeleted = true;
|
|
||||||
}
|
|
||||||
}], this.$refs.renoteTime, {
|
|
||||||
viaKeyboard: viaKeyboard
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
toggleShowContent() {
|
|
||||||
this.showContent = !this.showContent;
|
|
||||||
},
|
|
||||||
|
|
||||||
copyContent() {
|
|
||||||
copyToClipboard(this.appearNote.text);
|
|
||||||
os.success();
|
|
||||||
},
|
|
||||||
|
|
||||||
copyLink() {
|
|
||||||
copyToClipboard(`${url}/notes/${this.appearNote.id}`);
|
|
||||||
os.success();
|
|
||||||
},
|
|
||||||
|
|
||||||
togglePin(pin: boolean) {
|
|
||||||
os.apiWithDialog(pin ? 'i/pin' : 'i/unpin', {
|
|
||||||
noteId: this.appearNote.id
|
|
||||||
}, undefined, null, e => {
|
|
||||||
if (e.id === '72dab508-c64d-498f-8740-a8eec1ba385a') {
|
|
||||||
os.alert({
|
|
||||||
type: 'error',
|
|
||||||
text: this.$ts.pinLimitExceeded
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
async clip() {
|
|
||||||
const clips = await os.api('clips/list');
|
|
||||||
os.popupMenu([{
|
|
||||||
icon: 'fas fa-plus',
|
|
||||||
text: this.$ts.createNew,
|
|
||||||
action: async () => {
|
|
||||||
const { canceled, result } = await os.form(this.$ts.createNewClip, {
|
|
||||||
name: {
|
|
||||||
type: 'string',
|
|
||||||
label: this.$ts.name
|
|
||||||
},
|
|
||||||
description: {
|
|
||||||
type: 'string',
|
|
||||||
required: false,
|
|
||||||
multiline: true,
|
|
||||||
label: this.$ts.description
|
|
||||||
},
|
|
||||||
isPublic: {
|
|
||||||
type: 'boolean',
|
|
||||||
label: this.$ts.public,
|
|
||||||
default: false
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if (canceled) return;
|
|
||||||
|
|
||||||
const clip = await os.apiWithDialog('clips/create', result);
|
|
||||||
|
|
||||||
os.apiWithDialog('clips/add-note', { clipId: clip.id, noteId: this.appearNote.id });
|
|
||||||
}
|
|
||||||
}, null, ...clips.map(clip => ({
|
|
||||||
text: clip.name,
|
|
||||||
action: () => {
|
|
||||||
os.apiWithDialog('clips/add-note', { clipId: clip.id, noteId: this.appearNote.id });
|
|
||||||
}
|
|
||||||
}))], this.$refs.menuButton, {
|
|
||||||
}).then(this.focus);
|
|
||||||
},
|
|
||||||
|
|
||||||
async promote() {
|
|
||||||
const { canceled, result: days } = await os.inputNumber({
|
|
||||||
title: this.$ts.numberOfDays,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (canceled) return;
|
|
||||||
|
|
||||||
os.apiWithDialog('admin/promo/create', {
|
|
||||||
noteId: this.appearNote.id,
|
|
||||||
expiresAt: Date.now() + (86400000 * days)
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
share() {
|
|
||||||
navigator.share({
|
|
||||||
title: this.$t('noteOf', { user: this.appearNote.user.name }),
|
|
||||||
text: this.appearNote.text,
|
|
||||||
url: `${url}/notes/${this.appearNote.id}`
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
async translate() {
|
|
||||||
if (this.translation != null) return;
|
|
||||||
this.translating = true;
|
|
||||||
const res = await os.api('notes/translate', {
|
|
||||||
noteId: this.appearNote.id,
|
|
||||||
targetLang: localStorage.getItem('lang') || navigator.language,
|
|
||||||
});
|
|
||||||
this.translating = false;
|
|
||||||
this.translation = res;
|
|
||||||
},
|
|
||||||
|
|
||||||
focus() {
|
|
||||||
this.$el.focus();
|
|
||||||
},
|
|
||||||
|
|
||||||
blur() {
|
|
||||||
this.$el.blur();
|
|
||||||
},
|
|
||||||
|
|
||||||
focusBefore() {
|
|
||||||
focusPrev(this.$el);
|
|
||||||
},
|
|
||||||
|
|
||||||
focusAfter() {
|
|
||||||
focusNext(this.$el);
|
|
||||||
},
|
|
||||||
|
|
||||||
userPage
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function reply(viaKeyboard = false): void {
|
||||||
|
pleaseLogin();
|
||||||
|
os.post({
|
||||||
|
reply: appearNote,
|
||||||
|
animation: !viaKeyboard,
|
||||||
|
}, () => {
|
||||||
|
focus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function react(viaKeyboard = false): void {
|
||||||
|
pleaseLogin();
|
||||||
|
blur();
|
||||||
|
reactionPicker.show(reactButton.value, reaction => {
|
||||||
|
os.api('notes/reactions/create', {
|
||||||
|
noteId: appearNote.id,
|
||||||
|
reaction: reaction
|
||||||
|
});
|
||||||
|
}, () => {
|
||||||
|
focus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function undoReact(note): void {
|
||||||
|
const oldReaction = note.myReaction;
|
||||||
|
if (!oldReaction) return;
|
||||||
|
os.api('notes/reactions/delete', {
|
||||||
|
noteId: note.id
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function onContextmenu(e): void {
|
||||||
|
const isLink = (el: HTMLElement) => {
|
||||||
|
if (el.tagName === 'A') return true;
|
||||||
|
if (el.parentElement) {
|
||||||
|
return isLink(el.parentElement);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (isLink(e.target)) return;
|
||||||
|
if (window.getSelection().toString() !== '') return;
|
||||||
|
|
||||||
|
if (defaultStore.state.useReactionPickerForContextMenu) {
|
||||||
|
e.preventDefault();
|
||||||
|
react();
|
||||||
|
} else {
|
||||||
|
os.contextMenu(getNoteMenu({ note: props.note, translating, translation, menuButton }), e).then(focus);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function menu(viaKeyboard = false): void {
|
||||||
|
os.popupMenu(getNoteMenu({ note: props.note, translating, translation, menuButton }), menuButton.value, {
|
||||||
|
viaKeyboard
|
||||||
|
}).then(focus);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showRenoteMenu(viaKeyboard = false): void {
|
||||||
|
if (!isMyRenote) return;
|
||||||
|
os.popupMenu([{
|
||||||
|
text: i18n.locale.unrenote,
|
||||||
|
icon: 'fas fa-trash-alt',
|
||||||
|
danger: true,
|
||||||
|
action: () => {
|
||||||
|
os.api('notes/delete', {
|
||||||
|
noteId: props.note.id
|
||||||
|
});
|
||||||
|
isDeleted.value = true;
|
||||||
|
}
|
||||||
|
}], renoteTime.value, {
|
||||||
|
viaKeyboard: viaKeyboard
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function focus() {
|
||||||
|
el.value.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function blur() {
|
||||||
|
el.value.blur();
|
||||||
|
}
|
||||||
|
|
||||||
|
function focusBefore() {
|
||||||
|
focusPrev(el.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function focusAfter() {
|
||||||
|
focusNext(el.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readPromo() {
|
||||||
|
os.api('promo/read', {
|
||||||
|
noteId: appearNote.id
|
||||||
|
});
|
||||||
|
isDeleted.value = true;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
@ -10,7 +10,7 @@
|
|||||||
<template #default="{ items: notes }">
|
<template #default="{ items: notes }">
|
||||||
<div class="giivymft" :class="{ noGap }">
|
<div class="giivymft" :class="{ noGap }">
|
||||||
<XList ref="notes" v-slot="{ item: note }" :items="notes" :direction="pagination.reversed ? 'up' : 'down'" :reversed="pagination.reversed" :no-gap="noGap" :ad="true" class="notes">
|
<XList ref="notes" v-slot="{ item: note }" :items="notes" :direction="pagination.reversed ? 'up' : 'down'" :reversed="pagination.reversed" :no-gap="noGap" :ad="true" class="notes">
|
||||||
<XNote :key="note._featuredId_ || note._prId_ || note.id" class="qtqtichx" :note="note" @update:note="updated(note, $event)"/>
|
<XNote :key="note._featuredId_ || note._prId_ || note.id" class="qtqtichx" :note="note"/>
|
||||||
</XList>
|
</XList>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@ -31,10 +31,6 @@ const props = defineProps<{
|
|||||||
|
|
||||||
const pagingComponent = ref<InstanceType<typeof MkPagination>>();
|
const pagingComponent = ref<InstanceType<typeof MkPagination>>();
|
||||||
|
|
||||||
const updated = (oldValue, newValue) => {
|
|
||||||
pagingComponent.value?.updateItem(oldValue.id, () => newValue);
|
|
||||||
};
|
|
||||||
|
|
||||||
defineExpose({
|
defineExpose({
|
||||||
prepend: (note) => {
|
prepend: (note) => {
|
||||||
pagingComponent.value?.prepend(note);
|
pagingComponent.value?.prepend(note);
|
||||||
|
@ -9,7 +9,7 @@
|
|||||||
|
|
||||||
<template #default="{ items: notifications }">
|
<template #default="{ items: notifications }">
|
||||||
<XList v-slot="{ item: notification }" class="elsfgstc" :items="notifications" :no-gap="true">
|
<XList v-slot="{ item: notification }" class="elsfgstc" :items="notifications" :no-gap="true">
|
||||||
<XNote v-if="['reply', 'quote', 'mention'].includes(notification.type)" :key="notification.id" :note="notification.note" @update:note="noteUpdated(notification, $event)"/>
|
<XNote v-if="['reply', 'quote', 'mention'].includes(notification.type)" :key="notification.id" :note="notification.note"/>
|
||||||
<XNotification v-else :key="notification.id" :notification="notification" :with-time="true" :full="true" class="_panel notification"/>
|
<XNotification v-else :key="notification.id" :notification="notification" :with-time="true" :full="true" class="_panel notification"/>
|
||||||
</XList>
|
</XList>
|
||||||
</template>
|
</template>
|
||||||
@ -62,13 +62,6 @@ const onNotification = (notification) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const noteUpdated = (item, note) => {
|
|
||||||
pagingComponent.value?.updateItem(item.id, old => ({
|
|
||||||
...old,
|
|
||||||
note: note,
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
const connection = stream.useChannel('main');
|
const connection = stream.useChannel('main');
|
||||||
connection.on('notification', onNotification);
|
connection.on('notification', onNotification);
|
||||||
|
@ -90,7 +90,6 @@ const init = async (): Promise<void> => {
|
|||||||
}).then(res => {
|
}).then(res => {
|
||||||
for (let i = 0; i < res.length; i++) {
|
for (let i = 0; i < res.length; i++) {
|
||||||
const item = res[i];
|
const item = res[i];
|
||||||
markRaw(item);
|
|
||||||
if (props.pagination.reversed) {
|
if (props.pagination.reversed) {
|
||||||
if (i === res.length - 2) item._shouldInsertAd_ = true;
|
if (i === res.length - 2) item._shouldInsertAd_ = true;
|
||||||
} else {
|
} else {
|
||||||
@ -134,7 +133,6 @@ const fetchMore = async (): Promise<void> => {
|
|||||||
}).then(res => {
|
}).then(res => {
|
||||||
for (let i = 0; i < res.length; i++) {
|
for (let i = 0; i < res.length; i++) {
|
||||||
const item = res[i];
|
const item = res[i];
|
||||||
markRaw(item);
|
|
||||||
if (props.pagination.reversed) {
|
if (props.pagination.reversed) {
|
||||||
if (i === res.length - 9) item._shouldInsertAd_ = true;
|
if (i === res.length - 9) item._shouldInsertAd_ = true;
|
||||||
} else {
|
} else {
|
||||||
@ -169,9 +167,6 @@ const fetchMoreAhead = async (): Promise<void> => {
|
|||||||
sinceId: props.pagination.reversed ? items.value[0].id : items.value[items.value.length - 1].id,
|
sinceId: props.pagination.reversed ? items.value[0].id : items.value[items.value.length - 1].id,
|
||||||
}),
|
}),
|
||||||
}).then(res => {
|
}).then(res => {
|
||||||
for (const item of res) {
|
|
||||||
markRaw(item);
|
|
||||||
}
|
|
||||||
if (res.length > SECOND_FETCH_LIMIT) {
|
if (res.length > SECOND_FETCH_LIMIT) {
|
||||||
res.pop();
|
res.pop();
|
||||||
items.value = props.pagination.reversed ? [...res].reverse().concat(items.value) : items.value.concat(res);
|
items.value = props.pagination.reversed ? [...res].reverse().concat(items.value) : items.value.concat(res);
|
||||||
|
@ -10,7 +10,7 @@
|
|||||||
|
|
||||||
<template #default="{ items }">
|
<template #default="{ items }">
|
||||||
<XList v-slot="{ item }" :items="items" :direction="'down'" :no-gap="false" :ad="false">
|
<XList v-slot="{ item }" :items="items" :direction="'down'" :no-gap="false" :ad="false">
|
||||||
<XNote :key="item.id" :note="item.note" :class="$style.note" @update:note="noteUpdated(item, $event)"/>
|
<XNote :key="item.id" :note="item.note" :class="$style.note"/>
|
||||||
</XList>
|
</XList>
|
||||||
</template>
|
</template>
|
||||||
</MkPagination>
|
</MkPagination>
|
||||||
@ -32,13 +32,6 @@ const pagination = {
|
|||||||
|
|
||||||
const pagingComponent = ref<InstanceType<typeof MkPagination>>();
|
const pagingComponent = ref<InstanceType<typeof MkPagination>>();
|
||||||
|
|
||||||
const noteUpdated = (item, note) => {
|
|
||||||
pagingComponent.value?.updateItem(item.id, old => ({
|
|
||||||
...old,
|
|
||||||
note: note,
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
defineExpose({
|
defineExpose({
|
||||||
[symbols.PAGE_INFO]: {
|
[symbols.PAGE_INFO]: {
|
||||||
title: i18n.locale.favorites,
|
title: i18n.locale.favorites,
|
||||||
|
@ -7,7 +7,7 @@
|
|||||||
<MkReactionIcon class="reaction" :reaction="item.type" :custom-emojis="item.note.emojis" :no-style="true"/>
|
<MkReactionIcon class="reaction" :reaction="item.type" :custom-emojis="item.note.emojis" :no-style="true"/>
|
||||||
<MkTime :time="item.createdAt" class="createdAt"/>
|
<MkTime :time="item.createdAt" class="createdAt"/>
|
||||||
</div>
|
</div>
|
||||||
<MkNote :key="item.id" :note="item.note" @update:note="updated(note, $event)"/>
|
<MkNote :key="item.id" :note="item.note"/>
|
||||||
</div>
|
</div>
|
||||||
</MkPagination>
|
</MkPagination>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
export async function checkWordMute(note: Record<string, any>, me: Record<string, any> | null | undefined, mutedWords: string[][]): Promise<boolean> {
|
export function checkWordMute(note: Record<string, any>, me: Record<string, any> | null | undefined, mutedWords: string[][]): boolean {
|
||||||
// 自分自身
|
// 自分自身
|
||||||
if (me && (note.userId === me.id)) return false;
|
if (me && (note.userId === me.id)) return false;
|
||||||
|
|
||||||
|
310
packages/client/src/scripts/get-note-menu.ts
Normal file
310
packages/client/src/scripts/get-note-menu.ts
Normal file
@ -0,0 +1,310 @@
|
|||||||
|
import { Ref } from 'vue';
|
||||||
|
import * as misskey from 'misskey-js';
|
||||||
|
import { $i } from '@/account';
|
||||||
|
import { i18n } from '@/i18n';
|
||||||
|
import { instance } from '@/instance';
|
||||||
|
import * as os from '@/os';
|
||||||
|
import copyToClipboard from '@/scripts/copy-to-clipboard';
|
||||||
|
import { url } from '@/config';
|
||||||
|
import { noteActions } from '@/store';
|
||||||
|
import { pleaseLogin } from './please-login';
|
||||||
|
|
||||||
|
export function getNoteMenu(props: {
|
||||||
|
note: misskey.entities.Note;
|
||||||
|
menuButton: Ref<HTMLElement>;
|
||||||
|
translation: Ref<any>;
|
||||||
|
translating: Ref<boolean>;
|
||||||
|
}) {
|
||||||
|
const isRenote = (
|
||||||
|
props.note.renote != null &&
|
||||||
|
props.note.text == null &&
|
||||||
|
props.note.fileIds.length === 0 &&
|
||||||
|
props.note.poll == null
|
||||||
|
);
|
||||||
|
|
||||||
|
let appearNote = isRenote ? props.note.renote as misskey.entities.Note : props.note;
|
||||||
|
|
||||||
|
function del(): void {
|
||||||
|
os.confirm({
|
||||||
|
type: 'warning',
|
||||||
|
text: i18n.locale.noteDeleteConfirm,
|
||||||
|
}).then(({ canceled }) => {
|
||||||
|
if (canceled) return;
|
||||||
|
|
||||||
|
os.api('notes/delete', {
|
||||||
|
noteId: appearNote.id
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function delEdit(): void {
|
||||||
|
os.confirm({
|
||||||
|
type: 'warning',
|
||||||
|
text: i18n.locale.deleteAndEditConfirm,
|
||||||
|
}).then(({ canceled }) => {
|
||||||
|
if (canceled) return;
|
||||||
|
|
||||||
|
os.api('notes/delete', {
|
||||||
|
noteId: appearNote.id
|
||||||
|
});
|
||||||
|
|
||||||
|
os.post({ initialNote: appearNote, renote: appearNote.renote, reply: appearNote.reply, channel: appearNote.channel });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleFavorite(favorite: boolean): void {
|
||||||
|
os.apiWithDialog(favorite ? 'notes/favorites/create' : 'notes/favorites/delete', {
|
||||||
|
noteId: appearNote.id
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleWatch(watch: boolean): void {
|
||||||
|
os.apiWithDialog(watch ? 'notes/watching/create' : 'notes/watching/delete', {
|
||||||
|
noteId: appearNote.id
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleThreadMute(mute: boolean): void {
|
||||||
|
os.apiWithDialog(mute ? 'notes/thread-muting/create' : 'notes/thread-muting/delete', {
|
||||||
|
noteId: appearNote.id
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyContent(): void {
|
||||||
|
copyToClipboard(appearNote.text);
|
||||||
|
os.success();
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyLink(): void {
|
||||||
|
copyToClipboard(`${url}/notes/${appearNote.id}`);
|
||||||
|
os.success();
|
||||||
|
}
|
||||||
|
|
||||||
|
function togglePin(pin: boolean): void {
|
||||||
|
os.apiWithDialog(pin ? 'i/pin' : 'i/unpin', {
|
||||||
|
noteId: appearNote.id
|
||||||
|
}, undefined, null, e => {
|
||||||
|
if (e.id === '72dab508-c64d-498f-8740-a8eec1ba385a') {
|
||||||
|
os.alert({
|
||||||
|
type: 'error',
|
||||||
|
text: i18n.locale.pinLimitExceeded
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clip(): Promise<void> {
|
||||||
|
const clips = await os.api('clips/list');
|
||||||
|
os.popupMenu([{
|
||||||
|
icon: 'fas fa-plus',
|
||||||
|
text: i18n.locale.createNew,
|
||||||
|
action: async () => {
|
||||||
|
const { canceled, result } = await os.form(i18n.locale.createNewClip, {
|
||||||
|
name: {
|
||||||
|
type: 'string',
|
||||||
|
label: i18n.locale.name
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
type: 'string',
|
||||||
|
required: false,
|
||||||
|
multiline: true,
|
||||||
|
label: i18n.locale.description
|
||||||
|
},
|
||||||
|
isPublic: {
|
||||||
|
type: 'boolean',
|
||||||
|
label: i18n.locale.public,
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (canceled) return;
|
||||||
|
|
||||||
|
const clip = await os.apiWithDialog('clips/create', result);
|
||||||
|
|
||||||
|
os.apiWithDialog('clips/add-note', { clipId: clip.id, noteId: appearNote.id });
|
||||||
|
}
|
||||||
|
}, null, ...clips.map(clip => ({
|
||||||
|
text: clip.name,
|
||||||
|
action: () => {
|
||||||
|
os.apiWithDialog('clips/add-note', { clipId: clip.id, noteId: appearNote.id });
|
||||||
|
}
|
||||||
|
}))], props.menuButton.value, {
|
||||||
|
}).then(focus);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function promote(): Promise<void> {
|
||||||
|
const { canceled, result: days } = await os.inputNumber({
|
||||||
|
title: i18n.locale.numberOfDays,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (canceled) return;
|
||||||
|
|
||||||
|
os.apiWithDialog('admin/promo/create', {
|
||||||
|
noteId: appearNote.id,
|
||||||
|
expiresAt: Date.now() + (86400000 * days),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function share(): void {
|
||||||
|
navigator.share({
|
||||||
|
title: i18n.t('noteOf', { user: appearNote.user.name }),
|
||||||
|
text: appearNote.text,
|
||||||
|
url: `${url}/notes/${appearNote.id}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function translate(): Promise<void> {
|
||||||
|
if (props.translation.value != null) return;
|
||||||
|
props.translating.value = true;
|
||||||
|
const res = await os.api('notes/translate', {
|
||||||
|
noteId: appearNote.id,
|
||||||
|
targetLang: localStorage.getItem('lang') || navigator.language,
|
||||||
|
});
|
||||||
|
props.translating.value = false;
|
||||||
|
props.translation.value = res;
|
||||||
|
}
|
||||||
|
|
||||||
|
let menu;
|
||||||
|
if ($i) {
|
||||||
|
const statePromise = os.api('notes/state', {
|
||||||
|
noteId: appearNote.id
|
||||||
|
});
|
||||||
|
|
||||||
|
menu = [{
|
||||||
|
icon: 'fas fa-copy',
|
||||||
|
text: i18n.locale.copyContent,
|
||||||
|
action: copyContent
|
||||||
|
}, {
|
||||||
|
icon: 'fas fa-link',
|
||||||
|
text: i18n.locale.copyLink,
|
||||||
|
action: copyLink
|
||||||
|
}, (appearNote.url || appearNote.uri) ? {
|
||||||
|
icon: 'fas fa-external-link-square-alt',
|
||||||
|
text: i18n.locale.showOnRemote,
|
||||||
|
action: () => {
|
||||||
|
window.open(appearNote.url || appearNote.uri, '_blank');
|
||||||
|
}
|
||||||
|
} : undefined,
|
||||||
|
{
|
||||||
|
icon: 'fas fa-share-alt',
|
||||||
|
text: i18n.locale.share,
|
||||||
|
action: share
|
||||||
|
},
|
||||||
|
instance.translatorAvailable ? {
|
||||||
|
icon: 'fas fa-language',
|
||||||
|
text: i18n.locale.translate,
|
||||||
|
action: translate
|
||||||
|
} : undefined,
|
||||||
|
null,
|
||||||
|
statePromise.then(state => state.isFavorited ? {
|
||||||
|
icon: 'fas fa-star',
|
||||||
|
text: i18n.locale.unfavorite,
|
||||||
|
action: () => toggleFavorite(false)
|
||||||
|
} : {
|
||||||
|
icon: 'fas fa-star',
|
||||||
|
text: i18n.locale.favorite,
|
||||||
|
action: () => toggleFavorite(true)
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
icon: 'fas fa-paperclip',
|
||||||
|
text: i18n.locale.clip,
|
||||||
|
action: () => clip()
|
||||||
|
},
|
||||||
|
(appearNote.userId != $i.id) ? statePromise.then(state => state.isWatching ? {
|
||||||
|
icon: 'fas fa-eye-slash',
|
||||||
|
text: i18n.locale.unwatch,
|
||||||
|
action: () => toggleWatch(false)
|
||||||
|
} : {
|
||||||
|
icon: 'fas fa-eye',
|
||||||
|
text: i18n.locale.watch,
|
||||||
|
action: () => toggleWatch(true)
|
||||||
|
}) : undefined,
|
||||||
|
statePromise.then(state => state.isMutedThread ? {
|
||||||
|
icon: 'fas fa-comment-slash',
|
||||||
|
text: i18n.locale.unmuteThread,
|
||||||
|
action: () => toggleThreadMute(false)
|
||||||
|
} : {
|
||||||
|
icon: 'fas fa-comment-slash',
|
||||||
|
text: i18n.locale.muteThread,
|
||||||
|
action: () => toggleThreadMute(true)
|
||||||
|
}),
|
||||||
|
appearNote.userId == $i.id ? ($i.pinnedNoteIds || []).includes(appearNote.id) ? {
|
||||||
|
icon: 'fas fa-thumbtack',
|
||||||
|
text: i18n.locale.unpin,
|
||||||
|
action: () => togglePin(false)
|
||||||
|
} : {
|
||||||
|
icon: 'fas fa-thumbtack',
|
||||||
|
text: i18n.locale.pin,
|
||||||
|
action: () => togglePin(true)
|
||||||
|
} : undefined,
|
||||||
|
/*
|
||||||
|
...($i.isModerator || $i.isAdmin ? [
|
||||||
|
null,
|
||||||
|
{
|
||||||
|
icon: 'fas fa-bullhorn',
|
||||||
|
text: i18n.locale.promote,
|
||||||
|
action: promote
|
||||||
|
}]
|
||||||
|
: []
|
||||||
|
),*/
|
||||||
|
...(appearNote.userId != $i.id ? [
|
||||||
|
null,
|
||||||
|
{
|
||||||
|
icon: 'fas fa-exclamation-circle',
|
||||||
|
text: i18n.locale.reportAbuse,
|
||||||
|
action: () => {
|
||||||
|
const u = `${url}/notes/${appearNote.id}`;
|
||||||
|
os.popup(import('@/components/abuse-report-window.vue'), {
|
||||||
|
user: appearNote.user,
|
||||||
|
initialComment: `Note: ${u}\n-----\n`
|
||||||
|
}, {}, 'closed');
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
: []
|
||||||
|
),
|
||||||
|
...(appearNote.userId == $i.id || $i.isModerator || $i.isAdmin ? [
|
||||||
|
null,
|
||||||
|
appearNote.userId == $i.id ? {
|
||||||
|
icon: 'fas fa-edit',
|
||||||
|
text: i18n.locale.deleteAndEdit,
|
||||||
|
action: delEdit
|
||||||
|
} : undefined,
|
||||||
|
{
|
||||||
|
icon: 'fas fa-trash-alt',
|
||||||
|
text: i18n.locale.delete,
|
||||||
|
danger: true,
|
||||||
|
action: del
|
||||||
|
}]
|
||||||
|
: []
|
||||||
|
)]
|
||||||
|
.filter(x => x !== undefined);
|
||||||
|
} else {
|
||||||
|
menu = [{
|
||||||
|
icon: 'fas fa-copy',
|
||||||
|
text: i18n.locale.copyContent,
|
||||||
|
action: copyContent
|
||||||
|
}, {
|
||||||
|
icon: 'fas fa-link',
|
||||||
|
text: i18n.locale.copyLink,
|
||||||
|
action: copyLink
|
||||||
|
}, (appearNote.url || appearNote.uri) ? {
|
||||||
|
icon: 'fas fa-external-link-square-alt',
|
||||||
|
text: i18n.locale.showOnRemote,
|
||||||
|
action: () => {
|
||||||
|
window.open(appearNote.url || appearNote.uri, '_blank');
|
||||||
|
}
|
||||||
|
} : undefined]
|
||||||
|
.filter(x => x !== undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (noteActions.length > 0) {
|
||||||
|
menu = menu.concat([null, ...noteActions.map(action => ({
|
||||||
|
icon: 'fas fa-plug',
|
||||||
|
text: action.title,
|
||||||
|
action: () => {
|
||||||
|
action.handler(appearNote);
|
||||||
|
}
|
||||||
|
}))]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return menu;
|
||||||
|
}
|
123
packages/client/src/scripts/use-note-capture.ts
Normal file
123
packages/client/src/scripts/use-note-capture.ts
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
import { onUnmounted, Ref } from 'vue';
|
||||||
|
import * as misskey from 'misskey-js';
|
||||||
|
import { stream } from '@/stream';
|
||||||
|
import { $i } from '@/account';
|
||||||
|
|
||||||
|
export function useNoteCapture(props: {
|
||||||
|
rootEl: Ref<HTMLElement>;
|
||||||
|
appearNote: Ref<misskey.entities.Note>;
|
||||||
|
}) {
|
||||||
|
const appearNote = props.appearNote;
|
||||||
|
const connection = $i ? stream : null;
|
||||||
|
|
||||||
|
function onStreamNoteUpdated(data): void {
|
||||||
|
const { type, id, body } = data;
|
||||||
|
|
||||||
|
if (id !== appearNote.value.id) return;
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'reacted': {
|
||||||
|
const reaction = body.reaction;
|
||||||
|
|
||||||
|
const updated = JSON.parse(JSON.stringify(appearNote.value));
|
||||||
|
|
||||||
|
if (body.emoji) {
|
||||||
|
const emojis = appearNote.value.emojis || [];
|
||||||
|
if (!emojis.includes(body.emoji)) {
|
||||||
|
updated.emojis = [...emojis, body.emoji];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: reactionsプロパティがない場合ってあったっけ? なければ || {} は消せる
|
||||||
|
const currentCount = (appearNote.value.reactions || {})[reaction] || 0;
|
||||||
|
|
||||||
|
updated.reactions[reaction] = currentCount + 1;
|
||||||
|
|
||||||
|
if ($i && (body.userId === $i.id)) {
|
||||||
|
updated.myReaction = reaction;
|
||||||
|
}
|
||||||
|
|
||||||
|
appearNote.value = updated;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'unreacted': {
|
||||||
|
const reaction = body.reaction;
|
||||||
|
|
||||||
|
const updated = JSON.parse(JSON.stringify(appearNote.value));
|
||||||
|
|
||||||
|
// TODO: reactionsプロパティがない場合ってあったっけ? なければ || {} は消せる
|
||||||
|
const currentCount = (appearNote.value.reactions || {})[reaction] || 0;
|
||||||
|
|
||||||
|
updated.reactions[reaction] = Math.max(0, currentCount - 1);
|
||||||
|
|
||||||
|
if ($i && (body.userId === $i.id)) {
|
||||||
|
updated.myReaction = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
appearNote.value = updated;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'pollVoted': {
|
||||||
|
const choice = body.choice;
|
||||||
|
|
||||||
|
const updated = JSON.parse(JSON.stringify(appearNote.value));
|
||||||
|
|
||||||
|
const choices = [...appearNote.value.poll.choices];
|
||||||
|
choices[choice] = {
|
||||||
|
...choices[choice],
|
||||||
|
votes: choices[choice].votes + 1,
|
||||||
|
...($i && (body.userId === $i.id) ? {
|
||||||
|
isVoted: true
|
||||||
|
} : {})
|
||||||
|
};
|
||||||
|
|
||||||
|
updated.poll.choices = choices;
|
||||||
|
|
||||||
|
appearNote.value = updated;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'deleted': {
|
||||||
|
const updated = JSON.parse(JSON.stringify(appearNote.value));
|
||||||
|
updated.value = true;
|
||||||
|
appearNote.value = updated;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function capture(withHandler = false): void {
|
||||||
|
if (connection) {
|
||||||
|
// TODO: このノートがストリーミング経由で流れてきた場合のみ sr する
|
||||||
|
connection.send(document.body.contains(props.rootEl.value) ? 'sr' : 's', { id: appearNote.value.id });
|
||||||
|
if (withHandler) connection.on('noteUpdated', onStreamNoteUpdated);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function decapture(withHandler = false): void {
|
||||||
|
if (connection) {
|
||||||
|
connection.send('un', {
|
||||||
|
id: appearNote.value.id,
|
||||||
|
});
|
||||||
|
if (withHandler) connection.off('noteUpdated', onStreamNoteUpdated);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onStreamConnected() {
|
||||||
|
capture(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
capture(true);
|
||||||
|
if (connection) {
|
||||||
|
connection.on('_connected_', onStreamConnected);
|
||||||
|
}
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
decapture(true);
|
||||||
|
if (connection) {
|
||||||
|
connection.off('_connected_', onStreamConnected);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
@ -160,7 +160,7 @@ export const defaultStore = markRaw(new Storage('base', {
|
|||||||
},
|
},
|
||||||
useReactionPickerForContextMenu: {
|
useReactionPickerForContextMenu: {
|
||||||
where: 'device',
|
where: 'device',
|
||||||
default: true
|
default: false
|
||||||
},
|
},
|
||||||
showGapBetweenNotesInTimeline: {
|
showGapBetweenNotesInTimeline: {
|
||||||
where: 'device',
|
where: 'device',
|
||||||
|
Loading…
Reference in New Issue
Block a user