summaryrefslogtreecommitdiff
path: root/packages/client/src/components
diff options
context:
space:
mode:
authortamaina <tamaina@hotmail.co.jp>2022-01-16 18:01:23 +0900
committertamaina <tamaina@hotmail.co.jp>2022-01-16 18:01:23 +0900
commit04bafc5aeef5dc5db41679ee959ceb300ceb6187 (patch)
tree1bfcc2cc8e0a6305fa98567db01d1216579082da /packages/client/src/components
parentMerge branch 'develop' into pizzax-indexeddb (diff)
parentwip: refactor(client): migrate components to composition api (diff)
downloadmisskey-04bafc5aeef5dc5db41679ee959ceb300ceb6187.tar.gz
misskey-04bafc5aeef5dc5db41679ee959ceb300ceb6187.tar.bz2
misskey-04bafc5aeef5dc5db41679ee959ceb300ceb6187.zip
Merge branch 'develop' into pizzax-indexeddb
Diffstat (limited to 'packages/client/src/components')
-rw-r--r--packages/client/src/components/MkNoteSub.vue (renamed from packages/client/src/components/note.sub.vue)75
-rw-r--r--packages/client/src/components/analog-clock.vue2
-rw-r--r--packages/client/src/components/captcha.vue2
-rw-r--r--packages/client/src/components/form/input.vue4
-rw-r--r--packages/client/src/components/form/select.vue4
-rw-r--r--packages/client/src/components/global/a.vue202
-rw-r--r--packages/client/src/components/global/ad.vue6
-rw-r--r--packages/client/src/components/global/avatar.vue94
-rw-r--r--packages/client/src/components/global/loading.vue30
-rw-r--r--packages/client/src/components/global/misskey-flavored-markdown.vue22
-rw-r--r--packages/client/src/components/global/sticky-container.vue2
-rw-r--r--packages/client/src/components/global/time.vue112
-rw-r--r--packages/client/src/components/global/user-name.vue21
-rw-r--r--packages/client/src/components/image-viewer.vue34
-rw-r--r--packages/client/src/components/img-with-blurhash.vue86
-rw-r--r--packages/client/src/components/instance-ticker.vue41
-rw-r--r--packages/client/src/components/link.vue84
-rw-r--r--packages/client/src/components/media-banner.vue46
-rw-r--r--packages/client/src/components/mini-chart.vue4
-rw-r--r--packages/client/src/components/note-detailed.vue862
-rw-r--r--packages/client/src/components/note-header.vue28
-rw-r--r--packages/client/src/components/note-preview.vue18
-rw-r--r--packages/client/src/components/note-simple.vue34
-rw-r--r--packages/client/src/components/note.vue868
-rw-r--r--packages/client/src/components/notes.vue6
-rw-r--r--packages/client/src/components/notification-toast.vue2
-rw-r--r--packages/client/src/components/notifications.vue9
-rw-r--r--packages/client/src/components/post-form.vue1048
-rw-r--r--packages/client/src/components/reaction-icon.vue28
-rw-r--r--packages/client/src/components/reaction-tooltip.vue35
-rw-r--r--packages/client/src/components/reactions-viewer.details.vue45
-rw-r--r--packages/client/src/components/reactions-viewer.vue34
-rw-r--r--packages/client/src/components/renote.details.vue34
-rw-r--r--packages/client/src/components/ripple.vue2
-rw-r--r--packages/client/src/components/signin-dialog.vue42
-rw-r--r--packages/client/src/components/signup-dialog.vue46
-rw-r--r--packages/client/src/components/sub-note-content.vue38
-rw-r--r--packages/client/src/components/toast.vue2
-rw-r--r--packages/client/src/components/ui/button.vue6
-rw-r--r--packages/client/src/components/ui/modal.vue2
-rw-r--r--packages/client/src/components/ui/pagination.vue5
-rw-r--r--packages/client/src/components/url-preview.vue156
-rw-r--r--packages/client/src/components/user-online-indicator.vue31
-rw-r--r--packages/client/src/components/visibility-picker.vue81
-rw-r--r--packages/client/src/components/waiting-dialog.vue57
45 files changed, 1379 insertions, 3011 deletions
diff --git a/packages/client/src/components/note.sub.vue b/packages/client/src/components/MkNoteSub.vue
index de4218e535..30c27e6235 100644
--- a/packages/client/src/components/note.sub.vue
+++ b/packages/client/src/components/MkNoteSub.vue
@@ -10,13 +10,13 @@
<XCwButton v-model="showContent" :note="note"/>
</p>
<div v-show="note.cw == null || showContent" class="content">
- <XSubNote-content class="text" :note="note"/>
+ <MkNoteSubNoteContent class="text" :note="note"/>
</div>
</div>
</div>
</div>
<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>
<div v-else class="more">
<MkA class="text _link" :to="notePage(note)">{{ $ts.continueThread }} <i class="fas fa-angle-double-right"></i></MkA>
@@ -24,63 +24,36 @@
</div>
</template>
-<script lang="ts">
-import { defineComponent } from 'vue';
+<script lang="ts" setup>
+import { } from 'vue';
+import * as misskey from 'misskey-js';
import { notePage } from '@/filters/note';
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 * as os from '@/os';
-export default defineComponent({
- name: 'XSub',
+const props = withDefaults(defineProps<{
+ note: misskey.entities.Note;
+ detail?: boolean;
- components: {
- XNoteHeader,
- XSubNoteContent,
- XCwButton,
- },
-
- 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: [],
- };
- },
+ // how many notes are in between this one and the note being viewed in detail
+ depth?: number;
+}>(), {
+ depth: 1,
+});
- created() {
- if (this.detail) {
- os.api('notes/children', {
- noteId: this.note.id,
- limit: 5
- }).then(replies => {
- this.replies = replies;
- });
- }
- },
+let showContent = $ref(false);
+let replies: misskey.entities.Note[] = $ref([]);
- methods: {
- notePage,
- }
-});
+if (props.detail) {
+ os.api('notes/children', {
+ noteId: props.note.id,
+ limit: 5
+ }).then(res => {
+ replies = res;
+ });
+}
</script>
<style lang="scss" scoped>
diff --git a/packages/client/src/components/analog-clock.vue b/packages/client/src/components/analog-clock.vue
index 9ca511b6e9..59b8e97304 100644
--- a/packages/client/src/components/analog-clock.vue
+++ b/packages/client/src/components/analog-clock.vue
@@ -90,7 +90,7 @@ onMounted(() => {
const update = () => {
if (enabled.value) {
tick();
- setTimeout(update, 1000);
+ window.setTimeout(update, 1000);
}
};
update();
diff --git a/packages/client/src/components/captcha.vue b/packages/client/src/components/captcha.vue
index 2a4181255f..7fe499dc86 100644
--- a/packages/client/src/components/captcha.vue
+++ b/packages/client/src/components/captcha.vue
@@ -90,7 +90,7 @@ function requestRender() {
'error-callback': callback,
});
} else {
- setTimeout(requestRender, 1);
+ window.setTimeout(requestRender, 1);
}
}
diff --git a/packages/client/src/components/form/input.vue b/packages/client/src/components/form/input.vue
index 3533f4f27b..7165671af3 100644
--- a/packages/client/src/components/form/input.vue
+++ b/packages/client/src/components/form/input.vue
@@ -167,7 +167,7 @@ export default defineComponent({
// このコンポーネントが作成された時、非表示状態である場合がある
// 非表示状態だと要素の幅などは0になってしまうので、定期的に計算する
- const clock = setInterval(() => {
+ const clock = window.setInterval(() => {
if (prefixEl.value) {
if (prefixEl.value.offsetWidth) {
inputEl.value.style.paddingLeft = prefixEl.value.offsetWidth + 'px';
@@ -181,7 +181,7 @@ export default defineComponent({
}, 100);
onUnmounted(() => {
- clearInterval(clock);
+ window.clearInterval(clock);
});
});
});
diff --git a/packages/client/src/components/form/select.vue b/packages/client/src/components/form/select.vue
index afc53ca9c8..87196027a8 100644
--- a/packages/client/src/components/form/select.vue
+++ b/packages/client/src/components/form/select.vue
@@ -117,7 +117,7 @@ export default defineComponent({
// このコンポーネントが作成された時、非表示状態である場合がある
// 非表示状態だと要素の幅などは0になってしまうので、定期的に計算する
- const clock = setInterval(() => {
+ const clock = window.setInterval(() => {
if (prefixEl.value) {
if (prefixEl.value.offsetWidth) {
inputEl.value.style.paddingLeft = prefixEl.value.offsetWidth + 'px';
@@ -131,7 +131,7 @@ export default defineComponent({
}, 100);
onUnmounted(() => {
- clearInterval(clock);
+ window.clearInterval(clock);
});
});
});
diff --git a/packages/client/src/components/global/a.vue b/packages/client/src/components/global/a.vue
index 77ee7525a4..cf7385ca22 100644
--- a/packages/client/src/components/global/a.vue
+++ b/packages/client/src/components/global/a.vue
@@ -4,130 +4,114 @@
</a>
</template>
-<script lang="ts">
-import { defineComponent } from 'vue';
+<script lang="ts" setup>
+import { inject } from 'vue';
import * as os from '@/os';
import copyToClipboard from '@/scripts/copy-to-clipboard';
import { router } from '@/router';
import { url } from '@/config';
-import { popout } from '@/scripts/popout';
-import { ColdDeviceStorage } from '@/store';
+import { popout as popout_ } from '@/scripts/popout';
+import { i18n } from '@/i18n';
+import { defaultStore } from '@/store';
-export default defineComponent({
- inject: {
- navHook: {
- default: null
- },
- sideViewHook: {
- default: null
- }
- },
+const props = withDefaults(defineProps<{
+ to: string;
+ activeClass?: null | string;
+ behavior?: null | 'window' | 'browser' | 'modalWindow';
+}>(), {
+ activeClass: null,
+ behavior: null,
+});
- props: {
- to: {
- type: String,
- required: true,
- },
- activeClass: {
- type: String,
- required: false,
- },
- behavior: {
- type: String,
- required: false,
- },
- },
+const navHook = inject('navHook', null);
+const sideViewHook = inject('sideViewHook', null);
- computed: {
- active() {
- if (this.activeClass == null) return false;
- const resolved = router.resolve(this.to);
- if (resolved.path == this.$route.path) return true;
- if (resolved.name == null) return false;
- if (this.$route.name == null) return false;
- return resolved.name == this.$route.name;
- }
- },
+const active = $computed(() => {
+ if (props.activeClass == null) return false;
+ const resolved = router.resolve(props.to);
+ if (resolved.path === router.currentRoute.value.path) return true;
+ if (resolved.name == null) return false;
+ if (router.currentRoute.value.name == null) return false;
+ return resolved.name === router.currentRoute.value.name;
+});
- methods: {
- onContextmenu(e) {
- if (window.getSelection().toString() !== '') return;
- os.contextMenu([{
- type: 'label',
- text: this.to,
- }, {
- icon: 'fas fa-window-maximize',
- text: this.$ts.openInWindow,
- action: () => {
- os.pageWindow(this.to);
- }
- }, this.sideViewHook ? {
- icon: 'fas fa-columns',
- text: this.$ts.openInSideView,
- action: () => {
- this.sideViewHook(this.to);
- }
- } : undefined, {
- icon: 'fas fa-expand-alt',
- text: this.$ts.showInPage,
- action: () => {
- this.$router.push(this.to);
- }
- }, null, {
- icon: 'fas fa-external-link-alt',
- text: this.$ts.openInNewTab,
- action: () => {
- window.open(this.to, '_blank');
- }
- }, {
- icon: 'fas fa-link',
- text: this.$ts.copyLink,
- action: () => {
- copyToClipboard(`${url}${this.to}`);
- }
- }], e);
- },
+function onContextmenu(ev) {
+ const selection = window.getSelection();
+ if (selection && selection.toString() !== '') return;
+ os.contextMenu([{
+ type: 'label',
+ text: props.to,
+ }, {
+ icon: 'fas fa-window-maximize',
+ text: i18n.locale.openInWindow,
+ action: () => {
+ os.pageWindow(props.to);
+ }
+ }, sideViewHook ? {
+ icon: 'fas fa-columns',
+ text: i18n.locale.openInSideView,
+ action: () => {
+ sideViewHook(props.to);
+ }
+ } : undefined, {
+ icon: 'fas fa-expand-alt',
+ text: i18n.locale.showInPage,
+ action: () => {
+ router.push(props.to);
+ }
+ }, null, {
+ icon: 'fas fa-external-link-alt',
+ text: i18n.locale.openInNewTab,
+ action: () => {
+ window.open(props.to, '_blank');
+ }
+ }, {
+ icon: 'fas fa-link',
+ text: i18n.locale.copyLink,
+ action: () => {
+ copyToClipboard(`${url}${props.to}`);
+ }
+ }], ev);
+}
- window() {
- os.pageWindow(this.to);
- },
+function openWindow() {
+ os.pageWindow(props.to);
+}
- modalWindow() {
- os.modalPageWindow(this.to);
- },
+function modalWindow() {
+ os.modalPageWindow(props.to);
+}
- popout() {
- popout(this.to);
- },
+function popout() {
+ popout_(props.to);
+}
- nav() {
- if (this.behavior === 'browser') {
- location.href = this.to;
- return;
- }
+function nav() {
+ if (props.behavior === 'browser') {
+ location.href = props.to;
+ return;
+ }
- if (this.behavior) {
- if (this.behavior === 'window') {
- return this.window();
- } else if (this.behavior === 'modalWindow') {
- return this.modalWindow();
- }
- }
+ if (props.behavior) {
+ if (props.behavior === 'window') {
+ return openWindow();
+ } else if (props.behavior === 'modalWindow') {
+ return modalWindow();
+ }
+ }
- if (this.navHook) {
- this.navHook(this.to);
- } else {
- if (this.$store.state.defaultSideView && this.sideViewHook && this.to !== '/') {
- return this.sideViewHook(this.to);
- }
+ if (navHook) {
+ navHook(props.to);
+ } else {
+ if (defaultStore.state.defaultSideView && sideViewHook && props.to !== '/') {
+ return sideViewHook(props.to);
+ }
- if (this.$router.currentRoute.value.path === this.to) {
- window.scroll({ top: 0, behavior: 'smooth' });
- } else {
- this.$router.push(this.to);
- }
- }
+ if (router.currentRoute.value.path === props.to) {
+ window.scroll({ top: 0, behavior: 'smooth' });
+ } else {
+ router.push(props.to);
}
}
-});
+}
</script>
diff --git a/packages/client/src/components/global/ad.vue b/packages/client/src/components/global/ad.vue
index 49046b00a7..180dabb2a2 100644
--- a/packages/client/src/components/global/ad.vue
+++ b/packages/client/src/components/global/ad.vue
@@ -20,7 +20,7 @@
<script lang="ts">
import { defineComponent, ref } from 'vue';
-import { Instance, instance } from '@/instance';
+import { instance } from '@/instance';
import { host } from '@/config';
import MkButton from '@/components/ui/button.vue';
import { defaultStore } from '@/store';
@@ -48,9 +48,9 @@ export default defineComponent({
showMenu.value = !showMenu.value;
};
- const choseAd = (): Instance['ads'][number] | null => {
+ const choseAd = (): (typeof instance)['ads'][number] | null => {
if (props.specify) {
- return props.specify as Instance['ads'][number];
+ return props.specify as (typeof instance)['ads'][number];
}
const allAds = instance.ads.map(ad => defaultStore.state.mutedAds.includes(ad.id) ? {
diff --git a/packages/client/src/components/global/avatar.vue b/packages/client/src/components/global/avatar.vue
index 300e5e079f..9e8979fe56 100644
--- a/packages/client/src/components/global/avatar.vue
+++ b/packages/client/src/components/global/avatar.vue
@@ -1,74 +1,54 @@
<template>
-<span v-if="disableLink" v-user-preview="disablePreview ? undefined : user.id" class="eiwwqkts _noSelect" :class="{ cat, square: $store.state.squareAvatars }" :title="acct(user)" @click="onClick">
+<span v-if="disableLink" v-user-preview="disablePreview ? undefined : user.id" class="eiwwqkts _noSelect" :class="{ cat: user.isCat, square: $store.state.squareAvatars }" :style="{ color }" :title="acct(user)" @click="onClick">
<img class="inner" :src="url" decoding="async"/>
<MkUserOnlineIndicator v-if="showIndicator" class="indicator" :user="user"/>
</span>
-<MkA v-else v-user-preview="disablePreview ? undefined : user.id" class="eiwwqkts _noSelect" :class="{ cat, square: $store.state.squareAvatars }" :to="userPage(user)" :title="acct(user)" :target="target">
+<MkA v-else v-user-preview="disablePreview ? undefined : user.id" class="eiwwqkts _noSelect" :class="{ cat: user.isCat, square: $store.state.squareAvatars }" :style="{ color }" :to="userPage(user)" :title="acct(user)" :target="target">
<img class="inner" :src="url" decoding="async"/>
<MkUserOnlineIndicator v-if="showIndicator" class="indicator" :user="user"/>
</MkA>
</template>
-<script lang="ts">
-import { defineComponent } from 'vue';
+<script lang="ts" setup>
+import { onMounted, watch } from 'vue';
+import * as misskey from 'misskey-js';
import { getStaticImageUrl } from '@/scripts/get-static-image-url';
import { extractAvgColorFromBlurhash } from '@/scripts/extract-avg-color-from-blurhash';
import { acct, userPage } from '@/filters/user';
import MkUserOnlineIndicator from '@/components/user-online-indicator.vue';
+import { defaultStore } from '@/store';
-export default defineComponent({
- components: {
- MkUserOnlineIndicator
- },
- props: {
- user: {
- type: Object,
- required: true
- },
- target: {
- required: false,
- default: null
- },
- disableLink: {
- required: false,
- default: false
- },
- disablePreview: {
- required: false,
- default: false
- },
- showIndicator: {
- required: false,
- default: false
- }
- },
- emits: ['click'],
- computed: {
- cat(): boolean {
- return this.user.isCat;
- },
- url(): string {
- return this.$store.state.disableShowingAnimatedImages
- ? getStaticImageUrl(this.user.avatarUrl)
- : this.user.avatarUrl;
- },
- },
- watch: {
- 'user.avatarBlurhash'() {
- if (this.$el == null) return;
- this.$el.style.color = extractAvgColorFromBlurhash(this.user.avatarBlurhash);
- }
- },
- mounted() {
- this.$el.style.color = extractAvgColorFromBlurhash(this.user.avatarBlurhash);
- },
- methods: {
- onClick(e) {
- this.$emit('click', e);
- },
- acct,
- userPage
- }
+const props = withDefaults(defineProps<{
+ user: misskey.entities.User;
+ target?: string | null;
+ disableLink?: boolean;
+ disablePreview?: boolean;
+ showIndicator?: boolean;
+}>(), {
+ target: null,
+ disableLink: false,
+ disablePreview: false,
+ showIndicator: false,
+});
+
+const emit = defineEmits<{
+ (e: 'click', ev: MouseEvent): void;
+}>();
+
+const url = defaultStore.state.disableShowingAnimatedImages
+ ? getStaticImageUrl(props.user.avatarUrl)
+ : props.user.avatarUrl;
+
+function onClick(ev: MouseEvent) {
+ emit('click', ev);
+}
+
+let color = $ref();
+
+watch(() => props.user.avatarBlurhash, () => {
+ color = extractAvgColorFromBlurhash(props.user.avatarBlurhash);
+}, {
+ immediate: true,
});
</script>
diff --git a/packages/client/src/components/global/loading.vue b/packages/client/src/components/global/loading.vue
index 7bde53c12e..43ea1395ed 100644
--- a/packages/client/src/components/global/loading.vue
+++ b/packages/client/src/components/global/loading.vue
@@ -4,27 +4,17 @@
</div>
</template>
-<script lang="ts">
-import { defineComponent } from 'vue';
+<script lang="ts" setup>
+import { } from 'vue';
-export default defineComponent({
- props: {
- inline: {
- type: Boolean,
- required: false,
- default: false
- },
- colored: {
- type: Boolean,
- required: false,
- default: true
- },
- mini: {
- type: Boolean,
- required: false,
- default: false
- },
- }
+const props = withDefaults(defineProps<{
+ inline?: boolean;
+ colored?: boolean;
+ mini?: boolean;
+}>(), {
+ inline: false,
+ colored: true,
+ mini: false,
});
</script>
diff --git a/packages/client/src/components/global/misskey-flavored-markdown.vue b/packages/client/src/components/global/misskey-flavored-markdown.vue
index ab20404909..243d8614ba 100644
--- a/packages/client/src/components/global/misskey-flavored-markdown.vue
+++ b/packages/client/src/components/global/misskey-flavored-markdown.vue
@@ -1,15 +1,23 @@
<template>
-<mfm-core v-bind="$attrs" class="havbbuyv" :class="{ nowrap: $attrs['nowrap'] }"/>
+<MfmCore :text="text" :plain="plain" :nowrap="nowrap" :author="author" :customEmojis="customEmojis" :isNote="isNote" class="havbbuyv" :class="{ nowrap }"/>
</template>
-<script lang="ts">
-import { defineComponent } from 'vue';
+<script lang="ts" setup>
+import { } from 'vue';
import MfmCore from '@/components/mfm';
-export default defineComponent({
- components: {
- MfmCore
- }
+const props = withDefaults(defineProps<{
+ text: string;
+ plain?: boolean;
+ nowrap?: boolean;
+ author?: any;
+ customEmojis?: any;
+ isNote?: boolean;
+}>(), {
+ plain: false,
+ nowrap: false,
+ author: null,
+ isNote: true,
});
</script>
diff --git a/packages/client/src/components/global/sticky-container.vue b/packages/client/src/components/global/sticky-container.vue
index 859b2c1d73..89d397f082 100644
--- a/packages/client/src/components/global/sticky-container.vue
+++ b/packages/client/src/components/global/sticky-container.vue
@@ -45,7 +45,7 @@ export default defineComponent({
calc();
const observer = new MutationObserver(() => {
- setTimeout(() => {
+ window.setTimeout(() => {
calc();
}, 100);
});
diff --git a/packages/client/src/components/global/time.vue b/packages/client/src/components/global/time.vue
index 6a330a2307..d2788264c5 100644
--- a/packages/client/src/components/global/time.vue
+++ b/packages/client/src/components/global/time.vue
@@ -1,73 +1,57 @@
<template>
<time :title="absolute">
- <template v-if="mode == 'relative'">{{ relative }}</template>
- <template v-else-if="mode == 'absolute'">{{ absolute }}</template>
- <template v-else-if="mode == 'detail'">{{ absolute }} ({{ relative }})</template>
+ <template v-if="mode === 'relative'">{{ relative }}</template>
+ <template v-else-if="mode === 'absolute'">{{ absolute }}</template>
+ <template v-else-if="mode === 'detail'">{{ absolute }} ({{ relative }})</template>
</time>
</template>
-<script lang="ts">
-import { defineComponent } from 'vue';
+<script lang="ts" setup>
+import { onUnmounted } from 'vue';
+import { i18n } from '@/i18n';
-export default defineComponent({
- props: {
- time: {
- type: [Date, String],
- required: true
- },
- mode: {
- type: String,
- default: 'relative'
- }
- },
- data() {
- return {
- tickId: null,
- now: new Date()
- };
- },
- computed: {
- _time(): Date {
- return typeof this.time == 'string' ? new Date(this.time) : this.time;
- },
- absolute(): string {
- return this._time.toLocaleString();
- },
- relative(): string {
- const time = this._time;
- const ago = (this.now.getTime() - time.getTime()) / 1000/*ms*/;
- return (
- ago >= 31536000 ? this.$t('_ago.yearsAgo', { n: (~~(ago / 31536000)).toString() }) :
- ago >= 2592000 ? this.$t('_ago.monthsAgo', { n: (~~(ago / 2592000)).toString() }) :
- ago >= 604800 ? this.$t('_ago.weeksAgo', { n: (~~(ago / 604800)).toString() }) :
- ago >= 86400 ? this.$t('_ago.daysAgo', { n: (~~(ago / 86400)).toString() }) :
- ago >= 3600 ? this.$t('_ago.hoursAgo', { n: (~~(ago / 3600)).toString() }) :
- ago >= 60 ? this.$t('_ago.minutesAgo', { n: (~~(ago / 60)).toString() }) :
- ago >= 10 ? this.$t('_ago.secondsAgo', { n: (~~(ago % 60)).toString() }) :
- ago >= -1 ? this.$ts._ago.justNow :
- ago < -1 ? this.$ts._ago.future :
- this.$ts._ago.unknown);
- }
- },
- created() {
- if (this.mode == 'relative' || this.mode == 'detail') {
- this.tickId = window.requestAnimationFrame(this.tick);
- }
- },
- unmounted() {
- if (this.mode === 'relative' || this.mode === 'detail') {
- window.clearTimeout(this.tickId);
- }
- },
- methods: {
- tick() {
- // TODO: パフォーマンス向上のため、このコンポーネントが画面内に表示されている場合のみ更新する
- this.now = new Date();
+const props = withDefaults(defineProps<{
+ time: Date | string;
+ mode?: 'relative' | 'absolute' | 'detail';
+}>(), {
+ mode: 'relative',
+});
+
+const _time = typeof props.time == 'string' ? new Date(props.time) : props.time;
+const absolute = _time.toLocaleString();
- this.tickId = setTimeout(() => {
- window.requestAnimationFrame(this.tick);
- }, 10000);
- }
- }
+let now = $ref(new Date());
+const relative = $computed(() => {
+ const ago = (now.getTime() - _time.getTime()) / 1000/*ms*/;
+ return (
+ ago >= 31536000 ? i18n.t('_ago.yearsAgo', { n: (~~(ago / 31536000)).toString() }) :
+ ago >= 2592000 ? i18n.t('_ago.monthsAgo', { n: (~~(ago / 2592000)).toString() }) :
+ ago >= 604800 ? i18n.t('_ago.weeksAgo', { n: (~~(ago / 604800)).toString() }) :
+ ago >= 86400 ? i18n.t('_ago.daysAgo', { n: (~~(ago / 86400)).toString() }) :
+ ago >= 3600 ? i18n.t('_ago.hoursAgo', { n: (~~(ago / 3600)).toString() }) :
+ ago >= 60 ? i18n.t('_ago.minutesAgo', { n: (~~(ago / 60)).toString() }) :
+ ago >= 10 ? i18n.t('_ago.secondsAgo', { n: (~~(ago % 60)).toString() }) :
+ ago >= -1 ? i18n.locale._ago.justNow :
+ ago < -1 ? i18n.locale._ago.future :
+ i18n.locale._ago.unknown);
});
+
+function tick() {
+ // TODO: パフォーマンス向上のため、このコンポーネントが画面内に表示されている場合のみ更新する
+ now = new Date();
+
+ tickId = window.setTimeout(() => {
+ window.requestAnimationFrame(tick);
+ }, 10000);
+}
+
+let tickId: number;
+
+if (props.mode === 'relative' || props.mode === 'detail') {
+ tickId = window.requestAnimationFrame(tick);
+
+ onUnmounted(() => {
+ window.clearTimeout(tickId);
+ });
+}
</script>
diff --git a/packages/client/src/components/global/user-name.vue b/packages/client/src/components/global/user-name.vue
index bc93a8ea30..090de3df30 100644
--- a/packages/client/src/components/global/user-name.vue
+++ b/packages/client/src/components/global/user-name.vue
@@ -2,19 +2,14 @@
<Mfm :text="user.name || user.username" :plain="true" :nowrap="nowrap" :custom-emojis="user.emojis"/>
</template>
-<script lang="ts">
-import { defineComponent } from 'vue';
+<script lang="ts" setup>
+import { } from 'vue';
+import * as misskey from 'misskey-js';
-export default defineComponent({
- props: {
- user: {
- type: Object,
- required: true
- },
- nowrap: {
- type: Boolean,
- default: true
- },
- }
+const props = withDefaults(defineProps<{
+ user: misskey.entities.User;
+ nowrap?: boolean;
+}>(), {
+ nowrap: true,
});
</script>
diff --git a/packages/client/src/components/image-viewer.vue b/packages/client/src/components/image-viewer.vue
index 8584b91a61..c39076df16 100644
--- a/packages/client/src/components/image-viewer.vue
+++ b/packages/client/src/components/image-viewer.vue
@@ -1,8 +1,8 @@
<template>
-<MkModal ref="modal" :z-priority="'middle'" @click="$refs.modal.close()" @closed="$emit('closed')">
+<MkModal ref="modal" :z-priority="'middle'" @click="modal.close()" @closed="emit('closed')">
<div class="xubzgfga">
<header>{{ image.name }}</header>
- <img :src="image.url" :alt="image.comment" :title="image.comment" @click="$refs.modal.close()"/>
+ <img :src="image.url" :alt="image.comment" :title="image.comment" @click="modal.close()"/>
<footer>
<span>{{ image.type }}</span>
<span>{{ bytes(image.size) }}</span>
@@ -12,31 +12,23 @@
</MkModal>
</template>
-<script lang="ts">
-import { defineComponent } from 'vue';
+<script lang="ts" setup>
+import { } from 'vue';
+import * as misskey from 'misskey-js';
import bytes from '@/filters/bytes';
import number from '@/filters/number';
import MkModal from '@/components/ui/modal.vue';
-export default defineComponent({
- components: {
- MkModal,
- },
-
- props: {
- image: {
- type: Object,
- required: true
- },
- },
+const props = withDefaults(defineProps<{
+ image: misskey.entities.DriveFile;
+}>(), {
+});
- emits: ['closed'],
+const emit = defineEmits<{
+ (e: 'closed'): void;
+}>();
- methods: {
- bytes,
- number,
- }
-});
+const modal = $ref<InstanceType<typeof MkModal>>();
</script>
<style lang="scss" scoped>
diff --git a/packages/client/src/components/img-with-blurhash.vue b/packages/client/src/components/img-with-blurhash.vue
index a000c699b6..06ad764403 100644
--- a/packages/client/src/components/img-with-blurhash.vue
+++ b/packages/client/src/components/img-with-blurhash.vue
@@ -5,67 +5,43 @@
</div>
</template>
-<script lang="ts">
-import { defineComponent } from 'vue';
+<script lang="ts" setup>
+import { onMounted } from 'vue';
import { decode } from 'blurhash';
-export default defineComponent({
- props: {
- src: {
- type: String,
- required: false,
- default: null
- },
- hash: {
- type: String,
- required: true
- },
- alt: {
- type: String,
- required: false,
- default: '',
- },
- title: {
- type: String,
- required: false,
- default: null,
- },
- size: {
- type: Number,
- required: false,
- default: 64
- },
- cover: {
- type: Boolean,
- required: false,
- default: true,
- }
- },
+const props = withDefaults(defineProps<{
+ src?: string | null;
+ hash: string;
+ alt?: string;
+ title?: string | null;
+ size?: number;
+ cover?: boolean;
+}>(), {
+ src: null,
+ alt: '',
+ title: null,
+ size: 64,
+ cover: true,
+});
- data() {
- return {
- loaded: false,
- };
- },
+const canvas = $ref<HTMLCanvasElement>();
+let loaded = $ref(false);
- mounted() {
- this.draw();
- },
+function draw() {
+ if (props.hash == null) return;
+ const pixels = decode(props.hash, props.size, props.size);
+ const ctx = canvas.getContext('2d');
+ const imageData = ctx!.createImageData(props.size, props.size);
+ imageData.data.set(pixels);
+ ctx!.putImageData(imageData, 0, 0);
+}
- methods: {
- draw() {
- if (this.hash == null) return;
- const pixels = decode(this.hash, this.size, this.size);
- const ctx = (this.$refs.canvas as HTMLCanvasElement).getContext('2d');
- const imageData = ctx!.createImageData(this.size, this.size);
- imageData.data.set(pixels);
- ctx!.putImageData(imageData, 0, 0);
- },
+function onLoad() {
+ loaded = true;
+}
- onLoad() {
- this.loaded = true;
- }
- }
+onMounted(() => {
+ draw();
});
</script>
diff --git a/packages/client/src/components/instance-ticker.vue b/packages/client/src/components/instance-ticker.vue
index 1ce5a1c2c1..77fd8bb344 100644
--- a/packages/client/src/components/instance-ticker.vue
+++ b/packages/client/src/components/instance-ticker.vue
@@ -1,41 +1,22 @@
<template>
<div class="hpaizdrt" :style="bg">
- <img v-if="info.faviconUrl" class="icon" :src="info.faviconUrl"/>
- <span class="name">{{ info.name }}</span>
+ <img v-if="instance.faviconUrl" class="icon" :src="instance.faviconUrl"/>
+ <span class="name">{{ instance.name }}</span>
</div>
</template>
-<script lang="ts">
-import { defineComponent } from 'vue';
-import { instanceName } from '@/config';
+<script lang="ts" setup>
+import { } from 'vue';
-export default defineComponent({
- props: {
- instance: {
- type: Object,
- required: false
- },
- },
+const props = defineProps<{
+ instance: any; // TODO
+}>();
- data() {
- return {
- info: this.instance || {
- faviconUrl: '/favicon.ico',
- name: instanceName,
- themeColor: (document.querySelector('meta[name="theme-color-orig"]') as HTMLMetaElement)?.content
- }
- }
- },
+const themeColor = props.instance.themeColor || '#777777';
- computed: {
- bg(): any {
- const themeColor = this.info.themeColor || '#777777';
- return {
- background: `linear-gradient(90deg, ${themeColor}, ${themeColor + '00'})`
- };
- }
- }
-});
+const bg = {
+ background: `linear-gradient(90deg, ${themeColor}, ${themeColor + '00'})`
+};
</script>
<style lang="scss" scoped>
diff --git a/packages/client/src/components/link.vue b/packages/client/src/components/link.vue
index 8b8cde6510..317c931cec 100644
--- a/packages/client/src/components/link.vue
+++ b/packages/client/src/components/link.vue
@@ -1,82 +1,36 @@
<template>
-<component :is="self ? 'MkA' : 'a'" class="xlcxczvw _link" :[attr]="self ? url.substr(local.length) : url" :rel="rel" :target="target"
+<component :is="self ? 'MkA' : 'a'" ref="el" class="xlcxczvw _link" :[attr]="self ? url.substr(local.length) : url" :rel="rel" :target="target"
:title="url"
- @mouseover="onMouseover"
- @mouseleave="onMouseleave"
>
<slot></slot>
<i v-if="target === '_blank'" class="fas fa-external-link-square-alt icon"></i>
</component>
</template>
-<script lang="ts">
-import { defineComponent } from 'vue';
+<script lang="ts" setup>
+import { } from 'vue';
import { url as local } from '@/config';
-import { isTouchUsing } from '@/scripts/touch';
+import { useTooltip } from '@/scripts/use-tooltip';
import * as os from '@/os';
-export default defineComponent({
- props: {
- url: {
- type: String,
- required: true,
- },
- rel: {
- type: String,
- required: false,
- }
- },
- data() {
- const self = this.url.startsWith(local);
- return {
- local,
- self: self,
- attr: self ? 'to' : 'href',
- target: self ? null : '_blank',
- showTimer: null,
- hideTimer: null,
- checkTimer: null,
- close: null,
- };
- },
- methods: {
- async showPreview() {
- if (!document.body.contains(this.$el)) return;
- if (this.close) return;
+const props = withDefaults(defineProps<{
+ url: string;
+ rel?: null | string;
+}>(), {
+});
- const { dispose } = await os.popup(import('@/components/url-preview-popup.vue'), {
- url: this.url,
- source: this.$el
- });
+const self = props.url.startsWith(local);
+const attr = self ? 'to' : 'href';
+const target = self ? null : '_blank';
- this.close = () => {
- dispose();
- };
+const el = $ref();
- this.checkTimer = setInterval(() => {
- if (!document.body.contains(this.$el)) this.closePreview();
- }, 1000);
- },
- closePreview() {
- if (this.close) {
- clearInterval(this.checkTimer);
- this.close();
- this.close = null;
- }
- },
- onMouseover() {
- if (isTouchUsing) return;
- clearTimeout(this.showTimer);
- clearTimeout(this.hideTimer);
- this.showTimer = setTimeout(this.showPreview, 500);
- },
- onMouseleave() {
- if (isTouchUsing) return;
- clearTimeout(this.showTimer);
- clearTimeout(this.hideTimer);
- this.hideTimer = setTimeout(this.closePreview, 500);
- }
- }
+useTooltip($$(el), (showing) => {
+ os.popup(import('@/components/url-preview-popup.vue'), {
+ showing,
+ url: props.url,
+ source: el,
+ }, {}, 'closed');
});
</script>
diff --git a/packages/client/src/components/media-banner.vue b/packages/client/src/components/media-banner.vue
index 9dbfe3d0c6..5093f11e97 100644
--- a/packages/client/src/components/media-banner.vue
+++ b/packages/client/src/components/media-banner.vue
@@ -6,7 +6,7 @@
<span>{{ $ts.clickToShow }}</span>
</div>
<div v-else-if="media.type.startsWith('audio') && media.type !== 'audio/midi'" class="audio">
- <audio ref="audio"
+ <audio ref="audioEl"
class="audio"
:src="media.url"
:title="media.name"
@@ -25,34 +25,26 @@
</div>
</template>
-<script lang="ts">
-import { defineComponent } from 'vue';
-import * as os from '@/os';
+<script lang="ts" setup>
+import { onMounted } from 'vue';
+import * as misskey from 'misskey-js';
import { ColdDeviceStorage } from '@/store';
-export default defineComponent({
- props: {
- media: {
- type: Object,
- required: true
- }
- },
- data() {
- return {
- hide: true,
- };
- },
- mounted() {
- const audioTag = this.$refs.audio as HTMLAudioElement;
- if (audioTag) audioTag.volume = ColdDeviceStorage.get('mediaVolume');
- },
- methods: {
- volumechange() {
- const audioTag = this.$refs.audio as HTMLAudioElement;
- ColdDeviceStorage.set('mediaVolume', audioTag.volume);
- },
- },
-})
+const props = withDefaults(defineProps<{
+ media: misskey.entities.DriveFile;
+}>(), {
+});
+
+const audioEl = $ref<HTMLAudioElement | null>();
+let hide = $ref(true);
+
+function volumechange() {
+ if (audioEl) ColdDeviceStorage.set('mediaVolume', audioEl.volume);
+}
+
+onMounted(() => {
+ if (audioEl) audioEl.volume = ColdDeviceStorage.get('mediaVolume');
+});
</script>
<style lang="scss" scoped>
diff --git a/packages/client/src/components/mini-chart.vue b/packages/client/src/components/mini-chart.vue
index 2eb9ae8cbe..8c74eae876 100644
--- a/packages/client/src/components/mini-chart.vue
+++ b/packages/client/src/components/mini-chart.vue
@@ -63,10 +63,10 @@ export default defineComponent({
this.draw();
// Vueが何故かWatchを発動させない場合があるので
- this.clock = setInterval(this.draw, 1000);
+ this.clock = window.setInterval(this.draw, 1000);
},
beforeUnmount() {
- clearInterval(this.clock);
+ window.clearInterval(this.clock);
},
methods: {
draw() {
diff --git a/packages/client/src/components/note-detailed.vue b/packages/client/src/components/note-detailed.vue
index a5cb2f0426..07e9920f65 100644
--- a/packages/client/src/components/note-detailed.vue
+++ b/packages/client/src/components/note-detailed.vue
@@ -8,8 +8,8 @@
:tabindex="!isDeleted ? '-1' : null"
:class="{ renote: isRenote }"
>
- <XSub 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-for="note in conversation" :key="note.id" class="reply-to-more" :note="note"/>
+ <MkNoteSub v-if="appearNote.reply" :note="appearNote.reply" class="reply-to"/>
<div v-if="isRenote" class="renote">
<MkAvatar class="avatar" :user="note.user"/>
<i class="fas fa-retweet"></i>
@@ -107,7 +107,7 @@
</footer>
</div>
</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 v-else class="_panel muted" @click="muted = false">
<I18n :src="$ts.userSaysSomething" tag="small">
@@ -120,765 +120,171 @@
</div>
</template>
-<script lang="ts">
-import { defineAsyncComponent, defineComponent, markRaw } from 'vue';
+<script lang="ts" setup>
+import { computed, inject, onMounted, onUnmounted, reactive, ref } from 'vue';
import * as mfm from 'mfm-js';
-import { sum } from '@/scripts/array';
-import XSub from './note.sub.vue';
-import XNoteHeader from './note-header.vue';
+import * as misskey from 'misskey-js';
+import MkNoteSub from './MkNoteSub.vue';
import XNoteSimple from './note-simple.vue';
import XReactionsViewer from './reactions-viewer.vue';
import XMediaList from './media-list.vue';
import XCwButton from './cw-button.vue';
import XPoll from './poll.vue';
import 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 { focusPrev, focusNext } from '@/scripts/focus';
-import { url } from '@/config';
-import copyToClipboard from '@/scripts/copy-to-clipboard';
import { checkWordMute } from '@/scripts/check-word-mute';
import { userPage } from '@/filters/user';
import { notePage } from '@/filters/note';
import * as os from '@/os';
-import { stream } from '@/stream';
-import { noteActions, noteViewInterruptors } from '@/store';
+import { defaultStore, noteViewInterruptors } from '@/store';
import { reactionPicker } from '@/scripts/reaction-picker';
import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm';
+import { $i } from '@/account';
+import { i18n } from '@/i18n';
+import { getNoteMenu } from '@/scripts/get-note-menu';
+import { useNoteCapture } from '@/scripts/use-note-capture';
-// TODO: note.vueとほぼ同じなので共通化したい
-export default defineComponent({
- components: {
- XSub,
- XNoteHeader,
- XNoteSimple,
- XReactionsViewer,
- XMediaList,
- XCwButton,
- XPoll,
- XRenoteButton,
- MkUrlPreview: defineAsyncComponent(() => import('@/components/url-preview.vue')),
- MkInstanceTicker: defineAsyncComponent(() => import('@/components/instance-ticker.vue')),
- },
+const props = defineProps<{
+ note: misskey.entities.Note;
+ pinned?: boolean;
+}>();
- inject: {
- inChannel: {
- default: null
- },
- },
+const inChannel = inject('inChannel', null);
- props: {
- note: {
- type: Object,
- required: true
- },
- },
+const isRenote = (
+ props.note.renote != null &&
+ props.note.text == null &&
+ props.note.fileIds.length === 0 &&
+ props.note.poll == null
+);
- emits: ['update:note'],
+const el = ref<HTMLElement>();
+const menuButton = ref<HTMLElement>();
+const renoteButton = ref<InstanceType<typeof XRenoteButton>>();
+const renoteTime = ref<HTMLElement>();
+const reactButton = ref<HTMLElement>();
+let appearNote = $ref(isRenote ? props.note.renote as misskey.entities.Note : props.note);
+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));
+const translation = ref(null);
+const translating = ref(false);
+const urls = appearNote.text ? extractUrlFromMfm(mfm.parse(appearNote.text)) : null;
+const showTicker = (defaultStore.state.instanceTicker === 'always') || (defaultStore.state.instanceTicker === 'remote' && appearNote.user.instance);
+const conversation = ref<misskey.entities.Note[]>([]);
+const replies = ref<misskey.entities.Note[]>([]);
- data() {
- return {
- connection: null,
- conversation: [],
- replies: [],
- showContent: false,
- isDeleted: false,
- muted: false,
- translation: null,
- translating: false,
- notePage,
- };
- },
+const keymap = {
+ 'r': () => reply(true),
+ 'e|a|plus': () => react(true),
+ 'q': () => renoteButton.value.renote(true),
+ 'esc': blur,
+ 'm|o': () => menu(true),
+ 's': () => showContent.value != showContent.value,
+};
- computed: {
- rs() {
- return this.$store.state.reactions;
- },
- 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);
+useNoteCapture({
+ appearNote: $$(appearNote),
+ rootEl: el,
+});
- // 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));
- }
+function reply(viaKeyboard = false): void {
+ pleaseLogin();
+ os.post({
+ reply: appearNote,
+ animation: !viaKeyboard,
+ }, () => {
+ focus();
+ });
+}
- os.api('notes/children', {
- noteId: this.appearNote.id,
- limit: 30
- }).then(replies => {
- this.replies = replies;
+function react(viaKeyboard = false): void {
+ pleaseLogin();
+ blur();
+ reactionPicker.show(reactButton.value, reaction => {
+ os.api('notes/reactions/create', {
+ noteId: appearNote.id,
+ reaction: reaction
});
+ }, () => {
+ focus();
+ });
+}
- 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);
+function undoReact(note): void {
+ const oldReaction = note.myReaction;
+ if (!oldReaction) return;
+ os.api('notes/reactions/delete', {
+ noteId: note.id
+ });
+}
- if (this.$i) {
- this.connection.off('_connected_', this.onStreamConnected);
+function onContextmenu(e): void {
+ const isLink = (el: HTMLElement) => {
+ if (el.tagName === 'A') return true;
+ if (el.parentElement) {
+ return isLink(el.parentElement);
}
- },
-
- 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;
+ };
+ if (isLink(e.target)) return;
+ if (window.getSelection().toString() !== '') 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)
- });
- },
+ if (defaultStore.state.useReactionPickerForContextMenu) {
+ e.preventDefault();
+ react();
+ } else {
+ os.contextMenu(getNoteMenu({ note: props.note, translating, translation, menuButton }), e).then(focus);
+ }
+}
- share() {
- navigator.share({
- title: this.$t('noteOf', { user: this.appearNote.user.name }),
- text: this.appearNote.text,
- url: `${url}/notes/${this.appearNote.id}`
- });
- },
+function menu(viaKeyboard = false): void {
+ os.popupMenu(getNoteMenu({ note: props.note, translating, translation, menuButton }), menuButton.value, {
+ viaKeyboard
+ }).then(focus);
+}
- 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,
+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
});
- this.translating = false;
- this.translation = res;
- },
-
- focus() {
- this.$el.focus();
- },
-
- blur() {
- this.$el.blur();
- },
+ isDeleted.value = true;
+ }
+ }], renoteTime.value, {
+ viaKeyboard: viaKeyboard
+ });
+}
- focusBefore() {
- focusPrev(this.$el);
- },
+function focus() {
+ el.value.focus();
+}
- focusAfter() {
- focusNext(this.$el);
- },
+function blur() {
+ el.value.blur();
+}
- userPage
- }
+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>
<style lang="scss" scoped>
diff --git a/packages/client/src/components/note-header.vue b/packages/client/src/components/note-header.vue
index 26e725c6b8..56a3a37e75 100644
--- a/packages/client/src/components/note-header.vue
+++ b/packages/client/src/components/note-header.vue
@@ -19,30 +19,16 @@
</header>
</template>
-<script lang="ts">
-import { defineComponent } from 'vue';
+<script lang="ts" setup>
+import { } from 'vue';
+import * as misskey from 'misskey-js';
import { notePage } from '@/filters/note';
import { userPage } from '@/filters/user';
-import * as os from '@/os';
-export default defineComponent({
- props: {
- note: {
- type: Object,
- required: true
- },
- },
-
- data() {
- return {
- };
- },
-
- methods: {
- notePage,
- userPage
- }
-});
+defineProps<{
+ note: misskey.entities.Note;
+ pinned?: boolean;
+}>();
</script>
<style lang="scss" scoped>
diff --git a/packages/client/src/components/note-preview.vue b/packages/client/src/components/note-preview.vue
index bdcb8d5eed..a78b499654 100644
--- a/packages/client/src/components/note-preview.vue
+++ b/packages/client/src/components/note-preview.vue
@@ -14,20 +14,12 @@
</div>
</template>
-<script lang="ts">
-import { defineComponent } from 'vue';
+<script lang="ts" setup>
+import { } from 'vue';
-export default defineComponent({
- components: {
- },
-
- props: {
- text: {
- type: String,
- required: true
- }
- },
-});
+const props = defineProps<{
+ text: string;
+}>();
</script>
<style lang="scss" scoped>
diff --git a/packages/client/src/components/note-simple.vue b/packages/client/src/components/note-simple.vue
index 135f06602d..c6907787b5 100644
--- a/packages/client/src/components/note-simple.vue
+++ b/packages/client/src/components/note-simple.vue
@@ -9,40 +9,26 @@
<XCwButton v-model="showContent" :note="note"/>
</p>
<div v-show="note.cw == null || showContent" class="content">
- <XSubNote-content class="text" :note="note"/>
+ <MkNoteSubNoteContent class="text" :note="note"/>
</div>
</div>
</div>
</div>
</template>
-<script lang="ts">
-import { defineComponent } from 'vue';
+<script lang="ts" setup>
+import { } from 'vue';
+import * as misskey from 'misskey-js';
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 * as os from '@/os';
-export default defineComponent({
- components: {
- XNoteHeader,
- XSubNoteContent,
- XCwButton,
- },
+const props = defineProps<{
+ note: misskey.entities.Note;
+ pinned?: boolean;
+}>();
- props: {
- note: {
- type: Object,
- required: true
- }
- },
-
- data() {
- return {
- showContent: false
- };
- }
-});
+const showContent = $ref(false);
</script>
<style lang="scss" scoped>
diff --git a/packages/client/src/components/note.vue b/packages/client/src/components/note.vue
index 3cf924928a..b309afe051 100644
--- a/packages/client/src/components/note.vue
+++ b/packages/client/src/components/note.vue
@@ -2,20 +2,21 @@
<div
v-if="!muted"
v-show="!isDeleted"
+ ref="el"
v-hotkey="keymap"
v-size="{ max: [500, 450, 350, 300] }"
class="tkcbzcuz"
:tabindex="!isDeleted ? '-1' : null"
:class="{ renote: isRenote }"
>
- <XSub 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="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._featuredId_" class="info"><i class="fas fa-bolt"></i> {{ $ts.featured }}</div>
+ <MkNoteSub v-if="appearNote.reply" :note="appearNote.reply" class="reply-to"/>
+ <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> {{ 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> {{ i18n.locale.featured }}</div>
<div v-if="isRenote" class="renote">
<MkAvatar class="avatar" :user="note.user"/>
<i class="fas fa-retweet"></i>
- <I18n :src="$ts.renotedBy" tag="span">
+ <I18n :src="i18n.locale.renotedBy" tag="span">
<template #user>
<MkA v-user-preview="note.userId" class="name" :to="userPage(note.user)">
<MkUserName :user="note.user"/>
@@ -47,7 +48,7 @@
</p>
<div v-show="appearNote.cw == null || showContent" class="content" :class="{ collapsed }">
<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>
<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>
@@ -66,7 +67,7 @@
<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>
<button v-if="collapsed" class="fade _button" @click="collapsed = false">
- <span>{{ $ts.showMore }}</span>
+ <span>{{ i18n.locale.showMore }}</span>
</button>
</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>
@@ -93,7 +94,7 @@
</article>
</div>
<div v-else class="muted" @click="muted = false">
- <I18n :src="$ts.userSaysSomething" tag="small">
+ <I18n :src="i18n.locale.userSaysSomething" tag="small">
<template #name>
<MkA v-user-preview="appearNote.userId" class="name" :to="userPage(appearNote.user)">
<MkUserName :user="appearNote.user"/>
@@ -103,11 +104,11 @@
</div>
</template>
-<script lang="ts">
-import { defineAsyncComponent, defineComponent, markRaw } from 'vue';
+<script lang="ts" setup>
+import { computed, inject, onMounted, onUnmounted, reactive, ref } from 'vue';
import * as mfm from 'mfm-js';
-import { sum } from '@/scripts/array';
-import XSub from './note.sub.vue';
+import * as misskey from 'misskey-js';
+import MkNoteSub from './MkNoteSub.vue';
import XNoteHeader from './note-header.vue';
import XNoteSimple from './note-simple.vue';
import XReactionsViewer from './reactions-viewer.vue';
@@ -115,745 +116,164 @@ import XMediaList from './media-list.vue';
import XCwButton from './cw-button.vue';
import XPoll from './poll.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 { focusPrev, focusNext } from '@/scripts/focus';
-import { url } from '@/config';
-import copyToClipboard from '@/scripts/copy-to-clipboard';
import { checkWordMute } from '@/scripts/check-word-mute';
import { userPage } from '@/filters/user';
import * as os from '@/os';
-import { stream } from '@/stream';
-import { noteActions, noteViewInterruptors } from '@/store';
+import { defaultStore, noteViewInterruptors } from '@/store';
import { reactionPicker } from '@/scripts/reaction-picker';
import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm';
+import { $i } from '@/account';
+import { i18n } from '@/i18n';
+import { getNoteMenu } from '@/scripts/get-note-menu';
+import { useNoteCapture } from '@/scripts/use-note-capture';
-export default defineComponent({
- components: {
- XSub,
- XNoteHeader,
- XNoteSimple,
- XReactionsViewer,
- XMediaList,
- XCwButton,
- XPoll,
- XRenoteButton,
- MkUrlPreview: defineAsyncComponent(() => import('@/components/url-preview.vue')),
- MkInstanceTicker: defineAsyncComponent(() => import('@/components/instance-ticker.vue')),
- },
+const props = defineProps<{
+ note: misskey.entities.Note;
+ pinned?: boolean;
+}>();
- inject: {
- inChannel: {
- default: null
- },
- },
+const inChannel = inject('inChannel', null);
- props: {
- note: {
- type: Object,
- required: true
- },
- pinned: {
- type: Boolean,
- required: false,
- default: false
- },
- },
+const isRenote = (
+ props.note.renote != null &&
+ props.note.text == null &&
+ props.note.fileIds.length === 0 &&
+ props.note.poll == null
+);
- emits: ['update:note'],
+const el = ref<HTMLElement>();
+const menuButton = ref<HTMLElement>();
+const renoteButton = ref<InstanceType<typeof XRenoteButton>>();
+const renoteTime = ref<HTMLElement>();
+const reactButton = ref<HTMLElement>();
+let appearNote = $ref(isRenote ? props.note.renote as misskey.entities.Note : props.note);
+const isMyRenote = $i && ($i.id === props.note.userId);
+const showContent = ref(false);
+const collapsed = ref(appearNote.cw == null && appearNote.text != null && (
+ (appearNote.text.split('\n').length > 9) ||
+ (appearNote.text.length > 500)
+));
+const isDeleted = ref(false);
+const muted = ref(checkWordMute(appearNote, $i, defaultStore.state.mutedWords));
+const translation = ref(null);
+const translating = ref(false);
+const urls = appearNote.text ? extractUrlFromMfm(mfm.parse(appearNote.text)) : null;
+const showTicker = (defaultStore.state.instanceTicker === 'always') || (defaultStore.state.instanceTicker === 'remote' && appearNote.user.instance);
- data() {
- return {
- connection: null,
- replies: [],
- showContent: false,
- collapsed: false,
- isDeleted: false,
- muted: false,
- translation: null,
- translating: false,
- };
- },
+const keymap = {
+ 'r': () => reply(true),
+ 'e|a|plus': () => react(true),
+ 'q': () => renoteButton.value.renote(true),
+ 'up|k|shift+tab': focusBefore,
+ 'down|j|tab': focusAfter,
+ 'esc': blur,
+ 'm|o': () => menu(true),
+ 's': () => showContent.value != showContent.value,
+};
- computed: {
- rs() {
- return this.$store.state.reactions;
- },
- 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));
- }
- },
+useNoteCapture({
+ appearNote: $$(appearNote),
+ rootEl: el,
+});
- mounted() {
- this.capture(true);
+function reply(viaKeyboard = false): void {
+ pleaseLogin();
+ os.post({
+ reply: appearNote,
+ animation: !viaKeyboard,
+ }, () => {
+ focus();
+ });
+}
- if (this.$i) {
- this.connection.on('_connected_', this.onStreamConnected);
- }
- },
+function react(viaKeyboard = false): void {
+ pleaseLogin();
+ blur();
+ reactionPicker.show(reactButton.value, reaction => {
+ os.api('notes/reactions/create', {
+ noteId: appearNote.id,
+ reaction: reaction
+ });
+ }, () => {
+ focus();
+ });
+}
- beforeUnmount() {
- this.decapture(true);
+function undoReact(note): void {
+ const oldReaction = note.myReaction;
+ if (!oldReaction) return;
+ os.api('notes/reactions/delete', {
+ noteId: note.id
+ });
+}
- if (this.$i) {
- this.connection.off('_connected_', this.onStreamConnected);
+function onContextmenu(e): void {
+ const isLink = (el: HTMLElement) => {
+ if (el.tagName === 'A') return true;
+ if (el.parentElement) {
+ return isLink(el.parentElement);
}
- },
-
- 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);
- }
- },
+ };
+ if (isLink(e.target)) return;
+ if (window.getSelection().toString() !== '') return;
- 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)
- });
- },
+ if (defaultStore.state.useReactionPickerForContextMenu) {
+ e.preventDefault();
+ react();
+ } else {
+ os.contextMenu(getNoteMenu({ note: props.note, translating, translation, menuButton }), e).then(focus);
+ }
+}
- share() {
- navigator.share({
- title: this.$t('noteOf', { user: this.appearNote.user.name }),
- text: this.appearNote.text,
- url: `${url}/notes/${this.appearNote.id}`
- });
- },
+function menu(viaKeyboard = false): void {
+ os.popupMenu(getNoteMenu({ note: props.note, translating, translation, menuButton }), menuButton.value, {
+ viaKeyboard
+ }).then(focus);
+}
- 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,
+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
});
- this.translating = false;
- this.translation = res;
- },
+ isDeleted.value = true;
+ }
+ }], renoteTime.value, {
+ viaKeyboard: viaKeyboard
+ });
+}
- focus() {
- this.$el.focus();
- },
+function focus() {
+ el.value.focus();
+}
- blur() {
- this.$el.blur();
- },
+function blur() {
+ el.value.blur();
+}
- focusBefore() {
- focusPrev(this.$el);
- },
+function focusBefore() {
+ focusPrev(el.value);
+}
- focusAfter() {
- focusNext(this.$el);
- },
+function focusAfter() {
+ focusNext(el.value);
+}
- userPage
- }
-});
+function readPromo() {
+ os.api('promo/read', {
+ noteId: appearNote.id
+ });
+ isDeleted.value = true;
+}
</script>
<style lang="scss" scoped>
diff --git a/packages/client/src/components/notes.vue b/packages/client/src/components/notes.vue
index d6107216e2..aec478ac95 100644
--- a/packages/client/src/components/notes.vue
+++ b/packages/client/src/components/notes.vue
@@ -10,7 +10,7 @@
<template #default="{ items: notes }">
<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">
- <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>
</div>
</template>
@@ -31,10 +31,6 @@ const props = defineProps<{
const pagingComponent = ref<InstanceType<typeof MkPagination>>();
-const updated = (oldValue, newValue) => {
- pagingComponent.value?.updateItem(oldValue.id, () => newValue);
-};
-
defineExpose({
prepend: (note) => {
pagingComponent.value?.prepend(note);
diff --git a/packages/client/src/components/notification-toast.vue b/packages/client/src/components/notification-toast.vue
index 5449409ccc..fbd8467a6e 100644
--- a/packages/client/src/components/notification-toast.vue
+++ b/packages/client/src/components/notification-toast.vue
@@ -29,7 +29,7 @@ export default defineComponent({
};
},
mounted() {
- setTimeout(() => {
+ window.setTimeout(() => {
this.showing = false;
}, 6000);
}
diff --git a/packages/client/src/components/notifications.vue b/packages/client/src/components/notifications.vue
index 31511fb515..5a77b5487e 100644
--- a/packages/client/src/components/notifications.vue
+++ b/packages/client/src/components/notifications.vue
@@ -9,7 +9,7 @@
<template #default="{ items: notifications }">
<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"/>
</XList>
</template>
@@ -62,13 +62,6 @@ const onNotification = (notification) => {
}
};
-const noteUpdated = (item, note) => {
- pagingComponent.value?.updateItem(item.id, old => ({
- ...old,
- note: note,
- }));
-};
-
onMounted(() => {
const connection = stream.useChannel('main');
connection.on('notification', onNotification);
diff --git a/packages/client/src/components/post-form.vue b/packages/client/src/components/post-form.vue
index 24f35da2e9..3fcb1d906b 100644
--- a/packages/client/src/components/post-form.vue
+++ b/packages/client/src/components/post-form.vue
@@ -9,7 +9,7 @@
<header>
<button v-if="!fixed" class="cancel _button" @click="cancel"><i class="fas fa-times"></i></button>
<div>
- <span class="text-count" :class="{ over: textLength > max }">{{ max - textLength }}</span>
+ <span class="text-count" :class="{ over: textLength > maxTextLength }">{{ maxTextLength - textLength }}</span>
<span v-if="localOnly" class="local-only"><i class="fas fa-biohazard"></i></span>
<button ref="visibilityButton" v-tooltip="$ts.visibility" class="_button visibility" :disabled="channel != null" @click="setVisibility">
<span v-if="visibility === 'public'"><i class="fas fa-globe"></i></span>
@@ -36,9 +36,9 @@
</div>
</div>
<MkInfo v-if="hasNotSpecifiedMentions" warn class="hasNotSpecifiedMentions">{{ $ts.notSpecifiedMentionWarning }} - <button class="_textButton" @click="addMissingMention()">{{ $ts.add }}</button></MkInfo>
- <input v-show="useCw" ref="cw" v-model="cw" class="cw" :placeholder="$ts.annotation" @keydown="onKeydown">
- <textarea ref="text" v-model="text" class="text" :class="{ withCw: useCw }" :disabled="posting" :placeholder="placeholder" data-cy-post-form-text @keydown="onKeydown" @paste="onPaste" @compositionupdate="onCompositionUpdate" @compositionend="onCompositionEnd"/>
- <input v-show="withHashtags" ref="hashtags" v-model="hashtags" class="hashtags" :placeholder="$ts.hashtags" list="hashtags">
+ <input v-show="useCw" ref="cwInputEl" v-model="cw" class="cw" :placeholder="$ts.annotation" @keydown="onKeydown">
+ <textarea ref="textareaEl" v-model="text" class="text" :class="{ withCw: useCw }" :disabled="posting" :placeholder="placeholder" data-cy-post-form-text @keydown="onKeydown" @paste="onPaste" @compositionupdate="onCompositionUpdate" @compositionend="onCompositionEnd"/>
+ <input v-show="withHashtags" ref="hashtagsInputEl" v-model="hashtags" class="hashtags" :placeholder="$ts.hashtags" list="hashtags">
<XPostFormAttaches class="attaches" :files="files" @updated="updateFiles" @detach="detachFile" @changeSensitive="updateFileSensitive" @changeName="updateFileName"/>
<XPollEditor v-if="poll" :poll="poll" @destroyed="poll = null" @updated="onPollUpdate"/>
<XNotePreview v-if="showPreview" class="preview" :text="text"/>
@@ -58,667 +58,603 @@
</div>
</template>
-<script lang="ts">
-import { defineComponent, defineAsyncComponent } from 'vue';
+<script lang="ts" setup>
+import { inject, watch, nextTick, onMounted } from 'vue';
+import * as mfm from 'mfm-js';
+import * as misskey from 'misskey-js';
import insertTextAtCursor from 'insert-text-at-cursor';
import { length } from 'stringz';
import { toASCII } from 'punycode/';
import XNoteSimple from './note-simple.vue';
import XNotePreview from './note-preview.vue';
-import * as mfm from 'mfm-js';
+import XPostFormAttaches from './post-form-attaches.vue';
+import XPollEditor from './poll-editor.vue';
import { host, url } from '@/config';
import { erase, unique } from '@/scripts/array';
import { extractMentions } from '@/scripts/extract-mentions';
import * as Acct from 'misskey-js/built/acct';
import { formatTimeString } from '@/scripts/format-time-string';
import { Autocomplete } from '@/scripts/autocomplete';
-import { noteVisibilities } from 'misskey-js';
import * as os from '@/os';
import { stream } from '@/stream';
import { selectFiles } from '@/scripts/select-file';
import { defaultStore, notePostInterruptors, postFormActions } from '@/store';
import { throttle } from 'throttle-debounce';
import MkInfo from '@/components/ui/info.vue';
+import { i18n } from '@/i18n';
+import { instance } from '@/instance';
+import { $i } from '@/account';
-export default defineComponent({
- components: {
- XNoteSimple,
- XNotePreview,
- XPostFormAttaches: defineAsyncComponent(() => import('./post-form-attaches.vue')),
- XPollEditor: defineAsyncComponent(() => import('./poll-editor.vue')),
- MkInfo,
- },
+const modal = inject('modal');
- inject: ['modal'],
+const props = withDefaults(defineProps<{
+ reply?: misskey.entities.Note;
+ renote?: misskey.entities.Note;
+ channel?: any; // TODO
+ mention?: misskey.entities.User;
+ specified?: misskey.entities.User;
+ initialText?: string;
+ initialVisibility?: typeof misskey.noteVisibilities;
+ initialFiles?: misskey.entities.DriveFile[];
+ initialLocalOnly?: boolean;
+ initialVisibleUsers?: misskey.entities.User[];
+ initialNote?: misskey.entities.Note;
+ share?: boolean;
+ fixed?: boolean;
+ autofocus?: boolean;
+}>(), {
+ initialVisibleUsers: [],
+ autofocus: true,
+});
- props: {
- reply: {
- type: Object,
- required: false
- },
- renote: {
- type: Object,
- required: false
- },
- channel: {
- type: Object,
- required: false
- },
- mention: {
- type: Object,
- required: false
- },
- specified: {
- type: Object,
- required: false
- },
- initialText: {
- type: String,
- required: false
- },
- initialVisibility: {
- type: String,
- required: false
- },
- initialFiles: {
- type: Array,
- required: false
- },
- initialLocalOnly: {
- type: Boolean,
- required: false
- },
- initialVisibleUsers: {
- type: Array,
- required: false,
- default: () => []
- },
- initialNote: {
- type: Object,
- required: false
- },
- share: {
- type: Boolean,
- required: false,
- default: false
- },
- fixed: {
- type: Boolean,
- required: false,
- default: false
- },
- autofocus: {
- type: Boolean,
- required: false,
- default: true
- },
- },
+const emit = defineEmits<{
+ (e: 'posted'): void;
+ (e: 'cancel'): void;
+ (e: 'esc'): void;
+}>();
- emits: ['posted', 'cancel', 'esc'],
+const textareaEl = $ref<HTMLTextAreaElement | null>(null);
+const cwInputEl = $ref<HTMLInputElement | null>(null);
+const hashtagsInputEl = $ref<HTMLInputElement | null>(null);
+const visibilityButton = $ref<HTMLElement | null>(null);
- data() {
- return {
- posting: false,
- text: '',
- files: [],
- poll: null,
- useCw: false,
- showPreview: false,
- cw: null,
- localOnly: this.$store.state.rememberNoteVisibility ? this.$store.state.localOnly : this.$store.state.defaultNoteLocalOnly,
- visibility: (this.$store.state.rememberNoteVisibility ? this.$store.state.visibility : this.$store.state.defaultNoteVisibility) as typeof noteVisibilities[number],
- visibleUsers: [],
- autocomplete: null,
- draghover: false,
- quoteId: null,
- hasNotSpecifiedMentions: false,
- recentHashtags: JSON.parse(localStorage.getItem('hashtags') || '[]'),
- imeText: '',
- typing: throttle(3000, () => {
- if (this.channel) {
- stream.send('typingOnChannel', { channel: this.channel.id });
- }
- }),
- postFormActions,
- };
- },
+let posting = $ref(false);
+let text = $ref(props.initialText ?? '');
+let files = $ref(props.initialFiles ?? []);
+let poll = $ref<{
+ choices: string[];
+ multiple: boolean;
+ expiresAt: string;
+ expiredAfter: string;
+} | null>(null);
+let useCw = $ref(false);
+let showPreview = $ref(false);
+let cw = $ref<string | null>(null);
+let localOnly = $ref<boolean>(props.initialLocalOnly ?? defaultStore.state.rememberNoteVisibility ? defaultStore.state.localOnly : defaultStore.state.defaultNoteLocalOnly);
+let visibility = $ref(props.initialVisibility ?? (defaultStore.state.rememberNoteVisibility ? defaultStore.state.visibility : defaultStore.state.defaultNoteVisibility) as typeof misskey.noteVisibilities[number]);
+let visibleUsers = $ref(props.initialVisibleUsers ?? []);
+let autocomplete = $ref(null);
+let draghover = $ref(false);
+let quoteId = $ref(null);
+let hasNotSpecifiedMentions = $ref(false);
+let recentHashtags = $ref(JSON.parse(localStorage.getItem('hashtags') || '[]'));
+let imeText = $ref('');
- computed: {
- draftKey(): string {
- let key = this.channel ? `channel:${this.channel.id}` : '';
+const typing = throttle(3000, () => {
+ if (props.channel) {
+ stream.send('typingOnChannel', { channel: props.channel.id });
+ }
+});
- if (this.renote) {
- key += `renote:${this.renote.id}`;
- } else if (this.reply) {
- key += `reply:${this.reply.id}`;
- } else {
- key += 'note';
- }
+const draftKey = $computed((): string => {
+ let key = props.channel ? `channel:${props.channel.id}` : '';
- return key;
- },
+ if (props.renote) {
+ key += `renote:${props.renote.id}`;
+ } else if (props.reply) {
+ key += `reply:${props.reply.id}`;
+ } else {
+ key += 'note';
+ }
- placeholder(): string {
- if (this.renote) {
- return this.$ts._postForm.quotePlaceholder;
- } else if (this.reply) {
- return this.$ts._postForm.replyPlaceholder;
- } else if (this.channel) {
- return this.$ts._postForm.channelPlaceholder;
- } else {
- const xs = [
- this.$ts._postForm._placeholders.a,
- this.$ts._postForm._placeholders.b,
- this.$ts._postForm._placeholders.c,
- this.$ts._postForm._placeholders.d,
- this.$ts._postForm._placeholders.e,
- this.$ts._postForm._placeholders.f
- ];
- return xs[Math.floor(Math.random() * xs.length)];
- }
- },
+ return key;
+});
- submitText(): string {
- return this.renote
- ? this.$ts.quote
- : this.reply
- ? this.$ts.reply
- : this.$ts.note;
- },
+const placeholder = $computed((): string => {
+ if (props.renote) {
+ return i18n.locale._postForm.quotePlaceholder;
+ } else if (props.reply) {
+ return i18n.locale._postForm.replyPlaceholder;
+ } else if (props.channel) {
+ return i18n.locale._postForm.channelPlaceholder;
+ } else {
+ const xs = [
+ i18n.locale._postForm._placeholders.a,
+ i18n.locale._postForm._placeholders.b,
+ i18n.locale._postForm._placeholders.c,
+ i18n.locale._postForm._placeholders.d,
+ i18n.locale._postForm._placeholders.e,
+ i18n.locale._postForm._placeholders.f
+ ];
+ return xs[Math.floor(Math.random() * xs.length)];
+ }
+});
- textLength(): number {
- return length((this.text + this.imeText).trim());
- },
+const submitText = $computed((): string => {
+ return props.renote
+ ? i18n.locale.quote
+ : props.reply
+ ? i18n.locale.reply
+ : i18n.locale.note;
+});
- canPost(): boolean {
- return !this.posting &&
- (1 <= this.textLength || 1 <= this.files.length || !!this.poll || !!this.renote) &&
- (this.textLength <= this.max) &&
- (!this.poll || this.poll.choices.length >= 2);
- },
+const textLength = $computed((): number => {
+ return length((text + imeText).trim());
+});
- max(): number {
- return this.$instance ? this.$instance.maxNoteTextLength : 1000;
- },
+const maxTextLength = $computed((): number => {
+ return instance ? instance.maxNoteTextLength : 1000;
+});
- withHashtags: defaultStore.makeGetterSetter('postFormWithHashtags'),
- hashtags: defaultStore.makeGetterSetter('postFormHashtags'),
- },
+const canPost = $computed((): boolean => {
+ return !posting &&
+ (1 <= textLength || 1 <= files.length || !!poll || !!props.renote) &&
+ (textLength <= maxTextLength) &&
+ (!poll || poll.choices.length >= 2);
+});
- watch: {
- text() {
- this.checkMissingMention();
- },
- visibleUsers: {
- handler() {
- this.checkMissingMention();
- },
- deep: true
- }
- },
+const withHashtags = $computed(defaultStore.makeGetterSetter('postFormWithHashtags'));
+const hashtags = $computed(defaultStore.makeGetterSetter('postFormHashtags'));
- mounted() {
- if (this.initialText) {
- this.text = this.initialText;
- }
+watch($$(text), () => {
+ checkMissingMention();
+});
- if (this.initialVisibility) {
- this.visibility = this.initialVisibility;
- }
+watch($$(visibleUsers), () => {
+ checkMissingMention();
+}, {
+ deep: true,
+});
- if (this.initialFiles) {
- this.files = this.initialFiles;
- }
+if (props.mention) {
+ text = props.mention.host ? `@${props.mention.username}@${toASCII(props.mention.host)}` : `@${props.mention.username}`;
+ text += ' ';
+}
- if (typeof this.initialLocalOnly === 'boolean') {
- this.localOnly = this.initialLocalOnly;
- }
+if (props.reply && (props.reply.user.username != $i.username || (props.reply.user.host != null && props.reply.user.host != host))) {
+ text = `@${props.reply.user.username}${props.reply.user.host != null ? '@' + toASCII(props.reply.user.host) : ''} `;
+}
- if (this.initialVisibleUsers) {
- this.visibleUsers = this.initialVisibleUsers;
- }
+if (props.reply && props.reply.text != null) {
+ const ast = mfm.parse(props.reply.text);
+ const otherHost = props.reply.user.host;
- if (this.mention) {
- this.text = this.mention.host ? `@${this.mention.username}@${toASCII(this.mention.host)}` : `@${this.mention.username}`;
- this.text += ' ';
- }
+ for (const x of extractMentions(ast)) {
+ const mention = x.host ?
+ `@${x.username}@${toASCII(x.host)}` :
+ (otherHost == null || otherHost == host) ?
+ `@${x.username}` :
+ `@${x.username}@${toASCII(otherHost)}`;
- if (this.reply && (this.reply.user.username != this.$i.username || (this.reply.user.host != null && this.reply.user.host != host))) {
- this.text = `@${this.reply.user.username}${this.reply.user.host != null ? '@' + toASCII(this.reply.user.host) : ''} `;
- }
+ // 自分は除外
+ if ($i.username == x.username && x.host == null) continue;
+ if ($i.username == x.username && x.host == host) continue;
- if (this.reply && this.reply.text != null) {
- const ast = mfm.parse(this.reply.text);
- const otherHost = this.reply.user.host;
+ // 重複は除外
+ if (text.indexOf(`${mention} `) != -1) continue;
- for (const x of extractMentions(ast)) {
- const mention = x.host ?
- `@${x.username}@${toASCII(x.host)}` :
- (otherHost == null || otherHost == host) ?
- `@${x.username}` :
- `@${x.username}@${toASCII(otherHost)}`;
+ text += `${mention} `;
+ }
+}
- // 自分は除外
- if (this.$i.username == x.username && x.host == null) continue;
- if (this.$i.username == x.username && x.host == host) continue;
+if (props.channel) {
+ visibility = 'public';
+ localOnly = true; // TODO: チャンネルが連合するようになった折には消す
+}
- // 重複は除外
- if (this.text.indexOf(`${mention} `) != -1) continue;
+// 公開以外へのリプライ時は元の公開範囲を引き継ぐ
+if (props.reply && ['home', 'followers', 'specified'].includes(props.reply.visibility)) {
+ visibility = props.reply.visibility;
+ if (props.reply.visibility === 'specified') {
+ os.api('users/show', {
+ userIds: props.reply.visibleUserIds.filter(uid => uid !== $i.id && uid !== props.reply.userId)
+ }).then(users => {
+ visibleUsers.push(...users);
+ });
- this.text += `${mention} `;
- }
+ if (props.reply.userId !== $i.id) {
+ os.api('users/show', { userId: props.reply.userId }).then(user => {
+ visibleUsers.push(user);
+ });
}
+ }
+}
- if (this.channel) {
- this.visibility = 'public';
- this.localOnly = true; // TODO: チャンネルが連合するようになった折には消す
- }
+if (props.specified) {
+ visibility = 'specified';
+ visibleUsers.push(props.specified);
+}
- // 公開以外へのリプライ時は元の公開範囲を引き継ぐ
- if (this.reply && ['home', 'followers', 'specified'].includes(this.reply.visibility)) {
- this.visibility = this.reply.visibility;
- if (this.reply.visibility === 'specified') {
- os.api('users/show', {
- userIds: this.reply.visibleUserIds.filter(uid => uid !== this.$i.id && uid !== this.reply.userId)
- }).then(users => {
- this.visibleUsers.push(...users);
- });
+// keep cw when reply
+if (defaultStore.state.keepCw && props.reply && props.reply.cw) {
+ useCw = true;
+ cw = props.reply.cw;
+}
- if (this.reply.userId !== this.$i.id) {
- os.api('users/show', { userId: this.reply.userId }).then(user => {
- this.visibleUsers.push(user);
- });
- }
- }
- }
+function watchForDraft() {
+ watch($$(text), () => saveDraft());
+ watch($$(useCw), () => saveDraft());
+ watch($$(cw), () => saveDraft());
+ watch($$(poll), () => saveDraft());
+ watch($$(files), () => saveDraft(), { deep: true });
+ watch($$(visibility), () => saveDraft());
+ watch($$(localOnly), () => saveDraft());
+}
- if (this.specified) {
- this.visibility = 'specified';
- this.visibleUsers.push(this.specified);
- }
+function checkMissingMention() {
+ if (visibility === 'specified') {
+ const ast = mfm.parse(text);
- // keep cw when reply
- if (this.$store.state.keepCw && this.reply && this.reply.cw) {
- this.useCw = true;
- this.cw = this.reply.cw;
+ for (const x of extractMentions(ast)) {
+ if (!visibleUsers.some(u => (u.username === x.username) && (u.host == x.host))) {
+ hasNotSpecifiedMentions = true;
+ return;
+ }
}
+ hasNotSpecifiedMentions = false;
+ }
+}
- if (this.autofocus) {
- this.focus();
+function addMissingMention() {
+ const ast = mfm.parse(text);
- this.$nextTick(() => {
- this.focus();
+ for (const x of extractMentions(ast)) {
+ if (!visibleUsers.some(u => (u.username === x.username) && (u.host == x.host))) {
+ os.api('users/show', { username: x.username, host: x.host }).then(user => {
+ visibleUsers.push(user);
});
}
+ }
+}
- // TODO: detach when unmount
- new Autocomplete(this.$refs.text, this, { model: 'text' });
- new Autocomplete(this.$refs.cw, this, { model: 'cw' });
- new Autocomplete(this.$refs.hashtags, this, { model: 'hashtags' });
+function togglePoll() {
+ if (poll) {
+ poll = null;
+ } else {
+ poll = {
+ choices: ['', ''],
+ multiple: false,
+ expiresAt: null,
+ expiredAfter: null,
+ };
+ }
+}
- this.$nextTick(() => {
- // 書きかけの投稿を復元
- if (!this.share && !this.mention && !this.specified) {
- const draft = JSON.parse(localStorage.getItem('drafts') || '{}')[this.draftKey];
- if (draft) {
- this.text = draft.data.text;
- this.useCw = draft.data.useCw;
- this.cw = draft.data.cw;
- this.visibility = draft.data.visibility;
- this.localOnly = draft.data.localOnly;
- this.files = (draft.data.files || []).filter(e => e);
- if (draft.data.poll) {
- this.poll = draft.data.poll;
- }
- }
- }
+function addTag(tag: string) {
+ insertTextAtCursor(textareaEl, ` #${tag} `);
+}
- // 削除して編集
- if (this.initialNote) {
- const init = this.initialNote;
- this.text = init.text ? init.text : '';
- this.files = init.files;
- this.cw = init.cw;
- this.useCw = init.cw != null;
- if (init.poll) {
- this.poll = {
- choices: init.poll.choices.map(x => x.text),
- multiple: init.poll.multiple,
- expiresAt: init.poll.expiresAt,
- expiredAfter: init.poll.expiredAfter,
- };
- }
- this.visibility = init.visibility;
- this.localOnly = init.localOnly;
- this.quoteId = init.renote ? init.renote.id : null;
- }
+function focus() {
+ textareaEl.focus();
+}
- this.$nextTick(() => this.watch());
- });
- },
+function chooseFileFrom(ev) {
+ selectFiles(ev.currentTarget || ev.target, i18n.locale.attachFile).then(files => {
+ for (const file of files) {
+ files.push(file);
+ }
+ });
+}
- methods: {
- watch() {
- this.$watch('text', () => this.saveDraft());
- this.$watch('useCw', () => this.saveDraft());
- this.$watch('cw', () => this.saveDraft());
- this.$watch('poll', () => this.saveDraft());
- this.$watch('files', () => this.saveDraft(), { deep: true });
- this.$watch('visibility', () => this.saveDraft());
- this.$watch('localOnly', () => this.saveDraft());
- },
+function detachFile(id) {
+ files = files.filter(x => x.id != id);
+}
- checkMissingMention() {
- if (this.visibility === 'specified') {
- const ast = mfm.parse(this.text);
+function updateFiles(files) {
+ files = files;
+}
- for (const x of extractMentions(ast)) {
- if (!this.visibleUsers.some(u => (u.username === x.username) && (u.host == x.host))) {
- this.hasNotSpecifiedMentions = true;
- return;
- }
- }
- this.hasNotSpecifiedMentions = false;
- }
- },
+function updateFileSensitive(file, sensitive) {
+ files[files.findIndex(x => x.id === file.id)].isSensitive = sensitive;
+}
- addMissingMention() {
- const ast = mfm.parse(this.text);
+function updateFileName(file, name) {
+ files[files.findIndex(x => x.id === file.id)].name = name;
+}
- for (const x of extractMentions(ast)) {
- if (!this.visibleUsers.some(u => (u.username === x.username) && (u.host == x.host))) {
- os.api('users/show', { username: x.username, host: x.host }).then(user => {
- this.visibleUsers.push(user);
- });
- }
- }
- },
+function upload(file: File, name?: string) {
+ os.upload(file, defaultStore.state.uploadFolder, name).then(res => {
+ files.push(res);
+ });
+}
- togglePoll() {
- if (this.poll) {
- this.poll = null;
- } else {
- this.poll = {
- choices: ['', ''],
- multiple: false,
- expiresAt: null,
- expiredAfter: null,
- };
+function onPollUpdate(poll) {
+ poll = poll;
+ saveDraft();
+}
+
+function setVisibility() {
+ if (props.channel) {
+ // TODO: information dialog
+ return;
+ }
+
+ os.popup(import('./visibility-picker.vue'), {
+ currentVisibility: visibility,
+ currentLocalOnly: localOnly,
+ src: visibilityButton,
+ }, {
+ changeVisibility: v => {
+ visibility = v;
+ if (defaultStore.state.rememberNoteVisibility) {
+ defaultStore.set('visibility', visibility);
}
},
+ changeLocalOnly: v => {
+ localOnly = v;
+ if (defaultStore.state.rememberNoteVisibility) {
+ defaultStore.set('localOnly', localOnly);
+ }
+ }
+ }, 'closed');
+}
- addTag(tag: string) {
- insertTextAtCursor(this.$refs.text, ` #${tag} `);
- },
+function addVisibleUser() {
+ os.selectUser().then(user => {
+ visibleUsers.push(user);
+ });
+}
- focus() {
- (this.$refs.text as any).focus();
- },
+function removeVisibleUser(user) {
+ visibleUsers = erase(user, visibleUsers);
+}
- chooseFileFrom(ev) {
- selectFiles(ev.currentTarget || ev.target, this.$ts.attachFile).then(files => {
- for (const file of files) {
- this.files.push(file);
- }
- });
- },
+function clear() {
+ text = '';
+ files = [];
+ poll = null;
+ quoteId = null;
+}
- detachFile(id) {
- this.files = this.files.filter(x => x.id != id);
- },
+function onKeydown(e: KeyboardEvent) {
+ if ((e.which === 10 || e.which === 13) && (e.ctrlKey || e.metaKey) && canPost) post();
+ if (e.which === 27) emit('esc');
+ typing();
+}
- updateFiles(files) {
- this.files = files;
- },
+function onCompositionUpdate(e: CompositionEvent) {
+ imeText = e.data;
+ typing();
+}
- updateFileSensitive(file, sensitive) {
- this.files[this.files.findIndex(x => x.id === file.id)].isSensitive = sensitive;
- },
+function onCompositionEnd(e: CompositionEvent) {
+ imeText = '';
+}
- updateFileName(file, name) {
- this.files[this.files.findIndex(x => x.id === file.id)].name = name;
- },
+async function onPaste(e: ClipboardEvent) {
+ for (const { item, i } of Array.from(e.clipboardData.items).map((item, i) => ({item, i}))) {
+ if (item.kind == 'file') {
+ const file = item.getAsFile();
+ const lio = file.name.lastIndexOf('.');
+ const ext = lio >= 0 ? file.name.slice(lio) : '';
+ const formatted = `${formatTimeString(new Date(file.lastModified), defaultStore.state.pastedFileName).replace(/{{number}}/g, `${i + 1}`)}${ext}`;
+ upload(file, formatted);
+ }
+ }
- upload(file: File, name?: string) {
- os.upload(file, this.$store.state.uploadFolder, name).then(res => {
- this.files.push(res);
- });
- },
+ const paste = e.clipboardData.getData('text');
- onPollUpdate(poll) {
- this.poll = poll;
- this.saveDraft();
- },
+ if (!props.renote && !quoteId && paste.startsWith(url + '/notes/')) {
+ e.preventDefault();
- setVisibility() {
- if (this.channel) {
- // TODO: information dialog
+ os.confirm({
+ type: 'info',
+ text: i18n.locale.quoteQuestion,
+ }).then(({ canceled }) => {
+ if (canceled) {
+ insertTextAtCursor(textareaEl, paste);
return;
}
- os.popup(import('./visibility-picker.vue'), {
- currentVisibility: this.visibility,
- currentLocalOnly: this.localOnly,
- src: this.$refs.visibilityButton
- }, {
- changeVisibility: visibility => {
- this.visibility = visibility;
- if (this.$store.state.rememberNoteVisibility) {
- this.$store.set('visibility', visibility);
- }
- },
- changeLocalOnly: localOnly => {
- this.localOnly = localOnly;
- if (this.$store.state.rememberNoteVisibility) {
- this.$store.set('localOnly', localOnly);
- }
- }
- }, 'closed');
- },
-
- addVisibleUser() {
- os.selectUser().then(user => {
- this.visibleUsers.push(user);
- });
- },
+ quoteId = paste.substr(url.length).match(/^\/notes\/(.+?)\/?$/)[1];
+ });
+ }
+}
- removeVisibleUser(user) {
- this.visibleUsers = erase(user, this.visibleUsers);
- },
+function onDragover(e) {
+ if (!e.dataTransfer.items[0]) return;
+ const isFile = e.dataTransfer.items[0].kind == 'file';
+ const isDriveFile = e.dataTransfer.types[0] == _DATA_TRANSFER_DRIVE_FILE_;
+ if (isFile || isDriveFile) {
+ e.preventDefault();
+ draghover = true;
+ e.dataTransfer.dropEffect = e.dataTransfer.effectAllowed == 'all' ? 'copy' : 'move';
+ }
+}
- clear() {
- this.text = '';
- this.files = [];
- this.poll = null;
- this.quoteId = null;
- },
+function onDragenter(e) {
+ draghover = true;
+}
- onKeydown(e: KeyboardEvent) {
- if ((e.which === 10 || e.which === 13) && (e.ctrlKey || e.metaKey) && this.canPost) this.post();
- if (e.which === 27) this.$emit('esc');
- this.typing();
- },
+function onDragleave(e) {
+ draghover = false;
+}
- onCompositionUpdate(e: CompositionEvent) {
- this.imeText = e.data;
- this.typing();
- },
+function onDrop(e): void {
+ draghover = false;
- onCompositionEnd(e: CompositionEvent) {
- this.imeText = '';
- },
+ // ファイルだったら
+ if (e.dataTransfer.files.length > 0) {
+ e.preventDefault();
+ for (const x of Array.from(e.dataTransfer.files)) upload(x);
+ return;
+ }
- async onPaste(e: ClipboardEvent) {
- for (const { item, i } of Array.from(e.clipboardData.items).map((item, i) => ({item, i}))) {
- if (item.kind == 'file') {
- const file = item.getAsFile();
- const lio = file.name.lastIndexOf('.');
- const ext = lio >= 0 ? file.name.slice(lio) : '';
- const formatted = `${formatTimeString(new Date(file.lastModified), this.$store.state.pastedFileName).replace(/{{number}}/g, `${i + 1}`)}${ext}`;
- this.upload(file, formatted);
- }
- }
+ //#region ドライブのファイル
+ const driveFile = e.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_);
+ if (driveFile != null && driveFile != '') {
+ const file = JSON.parse(driveFile);
+ files.push(file);
+ e.preventDefault();
+ }
+ //#endregion
+}
- const paste = e.clipboardData.getData('text');
+function saveDraft() {
+ const data = JSON.parse(localStorage.getItem('drafts') || '{}');
- if (!this.renote && !this.quoteId && paste.startsWith(url + '/notes/')) {
- e.preventDefault();
+ data[draftKey] = {
+ updatedAt: new Date(),
+ data: {
+ text: text,
+ useCw: useCw,
+ cw: cw,
+ visibility: visibility,
+ localOnly: localOnly,
+ files: files,
+ poll: poll
+ }
+ };
- os.confirm({
- type: 'info',
- text: this.$ts.quoteQuestion,
- }).then(({ canceled }) => {
- if (canceled) {
- insertTextAtCursor(this.$refs.text, paste);
- return;
- }
+ localStorage.setItem('drafts', JSON.stringify(data));
+}
- this.quoteId = paste.substr(url.length).match(/^\/notes\/(.+?)\/?$/)[1];
- });
- }
- },
+function deleteDraft() {
+ const data = JSON.parse(localStorage.getItem('drafts') || '{}');
- onDragover(e) {
- if (!e.dataTransfer.items[0]) return;
- const isFile = e.dataTransfer.items[0].kind == 'file';
- const isDriveFile = e.dataTransfer.types[0] == _DATA_TRANSFER_DRIVE_FILE_;
- if (isFile || isDriveFile) {
- e.preventDefault();
- this.draghover = true;
- e.dataTransfer.dropEffect = e.dataTransfer.effectAllowed == 'all' ? 'copy' : 'move';
- }
- },
+ delete data[draftKey];
- onDragenter(e) {
- this.draghover = true;
- },
+ localStorage.setItem('drafts', JSON.stringify(data));
+}
- onDragleave(e) {
- this.draghover = false;
- },
+async function post() {
+ let data = {
+ text: text == '' ? undefined : text,
+ fileIds: files.length > 0 ? files.map(f => f.id) : undefined,
+ replyId: props.reply ? props.reply.id : undefined,
+ renoteId: props.renote ? props.renote.id : quoteId ? quoteId : undefined,
+ channelId: props.channel ? props.channel.id : undefined,
+ poll: poll,
+ cw: useCw ? cw || '' : undefined,
+ localOnly: localOnly,
+ visibility: visibility,
+ visibleUserIds: visibility == 'specified' ? visibleUsers.map(u => u.id) : undefined,
+ };
- onDrop(e): void {
- this.draghover = false;
+ if (withHashtags && hashtags && hashtags.trim() !== '') {
+ const hashtags = hashtags.trim().split(' ').map(x => x.startsWith('#') ? x : '#' + x).join(' ');
+ data.text = data.text ? `${data.text} ${hashtags}` : hashtags;
+ }
- // ファイルだったら
- if (e.dataTransfer.files.length > 0) {
- e.preventDefault();
- for (const x of Array.from(e.dataTransfer.files)) this.upload(x);
- return;
- }
+ // plugin
+ if (notePostInterruptors.length > 0) {
+ for (const interruptor of notePostInterruptors) {
+ data = await interruptor.handler(JSON.parse(JSON.stringify(data)));
+ }
+ }
- //#region ドライブのファイル
- const driveFile = e.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_);
- if (driveFile != null && driveFile != '') {
- const file = JSON.parse(driveFile);
- this.files.push(file);
- e.preventDefault();
+ posting = true;
+ os.api('notes/create', data).then(() => {
+ clear();
+ nextTick(() => {
+ deleteDraft();
+ emit('posted');
+ if (data.text && data.text != '') {
+ const hashtags = mfm.parse(data.text).filter(x => x.type === 'hashtag').map(x => x.props.hashtag);
+ const history = JSON.parse(localStorage.getItem('hashtags') || '[]') as string[];
+ localStorage.setItem('hashtags', JSON.stringify(unique(hashtags.concat(history))));
}
- //#endregion
- },
-
- saveDraft() {
- const data = JSON.parse(localStorage.getItem('drafts') || '{}');
+ posting = false;
+ });
+ }).catch(err => {
+ posting = false;
+ os.alert({
+ type: 'error',
+ text: err.message + '\n' + (err as any).id,
+ });
+ });
+}
- data[this.draftKey] = {
- updatedAt: new Date(),
- data: {
- text: this.text,
- useCw: this.useCw,
- cw: this.cw,
- visibility: this.visibility,
- localOnly: this.localOnly,
- files: this.files,
- poll: this.poll
- }
- };
+function cancel() {
+ emit('cancel');
+}
- localStorage.setItem('drafts', JSON.stringify(data));
- },
+function insertMention() {
+ os.selectUser().then(user => {
+ insertTextAtCursor(textareaEl, '@' + Acct.toString(user) + ' ');
+ });
+}
- deleteDraft() {
- const data = JSON.parse(localStorage.getItem('drafts') || '{}');
+async function insertEmoji(ev) {
+ os.openEmojiPicker(ev.currentTarget || ev.target, {}, textareaEl);
+}
- delete data[this.draftKey];
+function showActions(ev) {
+ os.popupMenu(postFormActions.map(action => ({
+ text: action.title,
+ action: () => {
+ action.handler({
+ text: text
+ }, (key, value) => {
+ if (key === 'text') { text = value; }
+ });
+ }
+ })), ev.currentTarget || ev.target);
+}
- localStorage.setItem('drafts', JSON.stringify(data));
- },
+onMounted(() => {
+ if (props.autofocus) {
+ focus();
- async post() {
- let data = {
- text: this.text == '' ? undefined : this.text,
- fileIds: this.files.length > 0 ? this.files.map(f => f.id) : undefined,
- replyId: this.reply ? this.reply.id : undefined,
- renoteId: this.renote ? this.renote.id : this.quoteId ? this.quoteId : undefined,
- channelId: this.channel ? this.channel.id : undefined,
- poll: this.poll,
- cw: this.useCw ? this.cw || '' : undefined,
- localOnly: this.localOnly,
- visibility: this.visibility,
- visibleUserIds: this.visibility == 'specified' ? this.visibleUsers.map(u => u.id) : undefined,
- };
+ nextTick(() => {
+ focus();
+ });
+ }
- if (this.withHashtags && this.hashtags && this.hashtags.trim() !== '') {
- const hashtags = this.hashtags.trim().split(' ').map(x => x.startsWith('#') ? x : '#' + x).join(' ');
- data.text = data.text ? `${data.text} ${hashtags}` : hashtags;
- }
+ // TODO: detach when unmount
+ new Autocomplete(textareaEl, $$(text));
+ new Autocomplete(cwInputEl, $$(cw));
+ new Autocomplete(hashtagsInputEl, $$(hashtags));
- // plugin
- if (notePostInterruptors.length > 0) {
- for (const interruptor of notePostInterruptors) {
- data = await interruptor.handler(JSON.parse(JSON.stringify(data)));
+ nextTick(() => {
+ // 書きかけの投稿を復元
+ if (!props.share && !props.mention && !props.specified) {
+ const draft = JSON.parse(localStorage.getItem('drafts') || '{}')[draftKey];
+ if (draft) {
+ text = draft.data.text;
+ useCw = draft.data.useCw;
+ cw = draft.data.cw;
+ visibility = draft.data.visibility;
+ localOnly = draft.data.localOnly;
+ files = (draft.data.files || []).filter(e => e);
+ if (draft.data.poll) {
+ poll = draft.data.poll;
}
}
+ }
- this.posting = true;
- os.api('notes/create', data).then(() => {
- this.clear();
- this.$nextTick(() => {
- this.deleteDraft();
- this.$emit('posted');
- if (data.text && data.text != '') {
- const hashtags = mfm.parse(data.text).filter(x => x.type === 'hashtag').map(x => x.props.hashtag);
- const history = JSON.parse(localStorage.getItem('hashtags') || '[]') as string[];
- localStorage.setItem('hashtags', JSON.stringify(unique(hashtags.concat(history))));
- }
- this.posting = false;
- });
- }).catch(err => {
- this.posting = false;
- os.alert({
- type: 'error',
- text: err.message + '\n' + (err as any).id,
- });
- });
- },
-
- cancel() {
- this.$emit('cancel');
- },
-
- insertMention() {
- os.selectUser().then(user => {
- insertTextAtCursor(this.$refs.text, '@' + Acct.toString(user) + ' ');
- });
- },
-
- async insertEmoji(ev) {
- os.openEmojiPicker(ev.currentTarget || ev.target, {}, this.$refs.text);
- },
-
- showActions(ev) {
- os.popupMenu(postFormActions.map(action => ({
- text: action.title,
- action: () => {
- action.handler({
- text: this.text
- }, (key, value) => {
- if (key === 'text') { this.text = value; }
- });
- }
- })), ev.currentTarget || ev.target);
+ // 削除して編集
+ if (props.initialNote) {
+ const init = props.initialNote;
+ text = init.text ? init.text : '';
+ files = init.files;
+ cw = init.cw;
+ useCw = init.cw != null;
+ if (init.poll) {
+ poll = {
+ choices: init.poll.choices.map(x => x.text),
+ multiple: init.poll.multiple,
+ expiresAt: init.poll.expiresAt,
+ expiredAfter: init.poll.expiredAfter,
+ };
+ }
+ visibility = init.visibility;
+ localOnly = init.localOnly;
+ quoteId = init.renote ? init.renote.id : null;
}
- }
+
+ nextTick(() => watchForDraft());
+ });
});
</script>
diff --git a/packages/client/src/components/reaction-icon.vue b/packages/client/src/components/reaction-icon.vue
index c0ec955e32..5638c9a816 100644
--- a/packages/client/src/components/reaction-icon.vue
+++ b/packages/client/src/components/reaction-icon.vue
@@ -1,25 +1,13 @@
<template>
-<MkEmoji :emoji="reaction" :custom-emojis="customEmojis" :is-reaction="true" :normal="true" :no-style="noStyle"/>
+<MkEmoji :emoji="reaction" :custom-emojis="customEmojis || []" :is-reaction="true" :normal="true" :no-style="noStyle"/>
</template>
-<script lang="ts">
-import { defineComponent } from 'vue';
+<script lang="ts" setup>
+import { } from 'vue';
-export default defineComponent({
- props: {
- reaction: {
- type: String,
- required: true
- },
- customEmojis: {
- required: false,
- default: () => []
- },
- noStyle: {
- type: Boolean,
- required: false,
- default: false
- },
- },
-});
+const props = defineProps<{
+ reaction: string;
+ customEmojis?: any[]; // TODO
+ noStyle?: boolean;
+}>();
</script>
diff --git a/packages/client/src/components/reaction-tooltip.vue b/packages/client/src/components/reaction-tooltip.vue
index dda8e7c6d7..1b2a024e21 100644
--- a/packages/client/src/components/reaction-tooltip.vue
+++ b/packages/client/src/components/reaction-tooltip.vue
@@ -1,5 +1,5 @@
<template>
-<MkTooltip ref="tooltip" :source="source" :max-width="340" @closed="$emit('closed')">
+<MkTooltip ref="tooltip" :source="source" :max-width="340" @closed="emit('closed')">
<div class="beeadbfb">
<XReactionIcon :reaction="reaction" :custom-emojis="emojis" class="icon" :no-style="true"/>
<div class="name">{{ reaction.replace('@.', '') }}</div>
@@ -7,31 +7,20 @@
</MkTooltip>
</template>
-<script lang="ts">
-import { defineComponent } from 'vue';
+<script lang="ts" setup>
+import { } from 'vue';
import MkTooltip from './ui/tooltip.vue';
import XReactionIcon from './reaction-icon.vue';
-export default defineComponent({
- components: {
- MkTooltip,
- XReactionIcon,
- },
- props: {
- reaction: {
- type: String,
- required: true,
- },
- emojis: {
- type: Array,
- required: true,
- },
- source: {
- required: true,
- }
- },
- emits: ['closed'],
-})
+const props = defineProps<{
+ reaction: string;
+ emojis: any[]; // TODO
+ source: any; // TODO
+}>();
+
+const emit = defineEmits<{
+ (e: 'closed'): void;
+}>();
</script>
<style lang="scss" scoped>
diff --git a/packages/client/src/components/reactions-viewer.details.vue b/packages/client/src/components/reactions-viewer.details.vue
index d6374517a2..8cec8dfa2f 100644
--- a/packages/client/src/components/reactions-viewer.details.vue
+++ b/packages/client/src/components/reactions-viewer.details.vue
@@ -1,5 +1,5 @@
<template>
-<MkTooltip ref="tooltip" :source="source" :max-width="340" @closed="$emit('closed')">
+<MkTooltip ref="tooltip" :source="source" :max-width="340" @closed="emit('closed')">
<div class="bqxuuuey">
<div class="reaction">
<XReactionIcon :reaction="reaction" :custom-emojis="emojis" class="icon" :no-style="true"/>
@@ -16,39 +16,22 @@
</MkTooltip>
</template>
-<script lang="ts">
-import { defineComponent } from 'vue';
+<script lang="ts" setup>
+import { } from 'vue';
import MkTooltip from './ui/tooltip.vue';
import XReactionIcon from './reaction-icon.vue';
-export default defineComponent({
- components: {
- MkTooltip,
- XReactionIcon
- },
- props: {
- reaction: {
- type: String,
- required: true,
- },
- users: {
- type: Array,
- required: true,
- },
- count: {
- type: Number,
- required: true,
- },
- emojis: {
- type: Array,
- required: true,
- },
- source: {
- required: true,
- }
- },
- emits: ['closed'],
-})
+const props = defineProps<{
+ reaction: string;
+ users: any[]; // TODO
+ count: number;
+ emojis: any[]; // TODO
+ source: any; // TODO
+}>();
+
+const emit = defineEmits<{
+ (e: 'closed'): void;
+}>();
</script>
<style lang="scss" scoped>
diff --git a/packages/client/src/components/reactions-viewer.vue b/packages/client/src/components/reactions-viewer.vue
index 59fcbb7129..a9bf51f65f 100644
--- a/packages/client/src/components/reactions-viewer.vue
+++ b/packages/client/src/components/reactions-viewer.vue
@@ -4,31 +4,19 @@
</div>
</template>
-<script lang="ts">
-import { defineComponent } from 'vue';
+<script lang="ts" setup>
+import { computed } from 'vue';
+import * as misskey from 'misskey-js';
+import { $i } from '@/account';
import XReaction from './reactions-viewer.reaction.vue';
-export default defineComponent({
- components: {
- XReaction
- },
- props: {
- note: {
- type: Object,
- required: true
- },
- },
- data() {
- return {
- initialReactions: new Set(Object.keys(this.note.reactions))
- };
- },
- computed: {
- isMe(): boolean {
- return this.$i && this.$i.id === this.note.userId;
- },
- },
-});
+const props = defineProps<{
+ note: misskey.entities.Note;
+}>();
+
+const initialReactions = new Set(Object.keys(props.note.reactions));
+
+const isMe = computed(() => $i && $i.id === props.note.userId);
</script>
<style lang="scss" scoped>
diff --git a/packages/client/src/components/renote.details.vue b/packages/client/src/components/renote.details.vue
index e3ef15c753..cdbc71bdce 100644
--- a/packages/client/src/components/renote.details.vue
+++ b/packages/client/src/components/renote.details.vue
@@ -1,5 +1,5 @@
<template>
-<MkTooltip ref="tooltip" :source="source" :max-width="250" @closed="$emit('closed')">
+<MkTooltip ref="tooltip" :source="source" :max-width="250" @closed="emit('closed')">
<div class="beaffaef">
<div v-for="u in users" :key="u.id" class="user">
<MkAvatar class="avatar" :user="u"/>
@@ -10,29 +10,19 @@
</MkTooltip>
</template>
-<script lang="ts">
-import { defineComponent } from 'vue';
+<script lang="ts" setup>
+import { } from 'vue';
import MkTooltip from './ui/tooltip.vue';
-export default defineComponent({
- components: {
- MkTooltip,
- },
- props: {
- users: {
- type: Array,
- required: true,
- },
- count: {
- type: Number,
- required: true,
- },
- source: {
- required: true,
- }
- },
- emits: ['closed'],
-})
+const props = defineProps<{
+ users: any[]; // TODO
+ count: number;
+ source: any; // TODO
+}>();
+
+const emit = defineEmits<{
+ (e: 'closed'): void;
+}>();
</script>
<style lang="scss" scoped>
diff --git a/packages/client/src/components/ripple.vue b/packages/client/src/components/ripple.vue
index 272eacbc6e..401e78e304 100644
--- a/packages/client/src/components/ripple.vue
+++ b/packages/client/src/components/ripple.vue
@@ -94,7 +94,7 @@ export default defineComponent({
}
onMounted(() => {
- setTimeout(() => {
+ window.setTimeout(() => {
context.emit('end');
}, 1100);
});
diff --git a/packages/client/src/components/signin-dialog.vue b/packages/client/src/components/signin-dialog.vue
index 2edd10f539..5c2048e7b0 100644
--- a/packages/client/src/components/signin-dialog.vue
+++ b/packages/client/src/components/signin-dialog.vue
@@ -2,8 +2,8 @@
<XModalWindow ref="dialog"
:width="370"
:height="400"
- @close="$refs.dialog.close()"
- @closed="$emit('closed')"
+ @close="dialog.close()"
+ @closed="emit('closed')"
>
<template #header>{{ $ts.login }}</template>
@@ -11,32 +11,26 @@
</XModalWindow>
</template>
-<script lang="ts">
-import { defineComponent } from 'vue';
+<script lang="ts" setup>
+import { } from 'vue';
import XModalWindow from '@/components/ui/modal-window.vue';
import MkSignin from './signin.vue';
-export default defineComponent({
- components: {
- MkSignin,
- XModalWindow,
- },
+const props = withDefaults(defineProps<{
+ autoSet?: boolean;
+}>(), {
+ autoSet: false,
+});
- props: {
- autoSet: {
- type: Boolean,
- required: false,
- default: false,
- }
- },
+const emit = defineEmits<{
+ (e: 'done'): void;
+ (e: 'closed'): void;
+}>();
- emits: ['done', 'closed'],
+const dialog = $ref<InstanceType<typeof XModalWindow>>();
- methods: {
- onLogin(res) {
- this.$emit('done', res);
- this.$refs.dialog.close();
- }
- }
-});
+function onLogin(res) {
+ emit('done', res);
+ dialog.close();
+}
</script>
diff --git a/packages/client/src/components/signup-dialog.vue b/packages/client/src/components/signup-dialog.vue
index 30fe3bf7d3..bda2495ba7 100644
--- a/packages/client/src/components/signup-dialog.vue
+++ b/packages/client/src/components/signup-dialog.vue
@@ -2,7 +2,7 @@
<XModalWindow ref="dialog"
:width="366"
:height="500"
- @close="$refs.dialog.close()"
+ @close="dialog.close()"
@closed="$emit('closed')"
>
<template #header>{{ $ts.signup }}</template>
@@ -15,36 +15,30 @@
</XModalWindow>
</template>
-<script lang="ts">
-import { defineComponent } from 'vue';
+<script lang="ts" setup>
+import { } from 'vue';
import XModalWindow from '@/components/ui/modal-window.vue';
import XSignup from './signup.vue';
-export default defineComponent({
- components: {
- XSignup,
- XModalWindow,
- },
+const props = withDefaults(defineProps<{
+ autoSet?: boolean;
+}>(), {
+ autoSet: false,
+});
- props: {
- autoSet: {
- type: Boolean,
- required: false,
- default: false,
- }
- },
+const emit = defineEmits<{
+ (e: 'done'): void;
+ (e: 'closed'): void;
+}>();
- emits: ['done', 'closed'],
+const dialog = $ref<InstanceType<typeof XModalWindow>>();
- methods: {
- onSignup(res) {
- this.$emit('done', res);
- this.$refs.dialog.close();
- },
+function onSignup(res) {
+ emit('done', res);
+ dialog.close();
+}
- onSignupEmailPending() {
- this.$refs.dialog.close();
- }
- }
-});
+function onSignupEmailPending() {
+ dialog.close();
+}
</script>
diff --git a/packages/client/src/components/sub-note-content.vue b/packages/client/src/components/sub-note-content.vue
index efa202ce2f..d6a37d07be 100644
--- a/packages/client/src/components/sub-note-content.vue
+++ b/packages/client/src/components/sub-note-content.vue
@@ -21,35 +21,21 @@
</div>
</template>
-<script lang="ts">
-import { defineComponent } from 'vue';
+<script lang="ts" setup>
+import { } from 'vue';
import XPoll from './poll.vue';
import XMediaList from './media-list.vue';
-import * as os from '@/os';
+import * as misskey from 'misskey-js';
-export default defineComponent({
- components: {
- XPoll,
- XMediaList,
- },
- props: {
- note: {
- type: Object,
- required: true
- }
- },
- data() {
- return {
- collapsed: false,
- };
- },
- created() {
- this.collapsed = this.note.cw == null && this.note.text && (
- (this.note.text.split('\n').length > 9) ||
- (this.note.text.length > 500)
- );
- }
-});
+const props = defineProps<{
+ note: misskey.entities.Note;
+}>();
+
+const collapsed = $ref(
+ props.note.cw == null && props.note.text != null && (
+ (props.note.text.split('\n').length > 9) ||
+ (props.note.text.length > 500)
+ ));
</script>
<style lang="scss" scoped>
diff --git a/packages/client/src/components/toast.vue b/packages/client/src/components/toast.vue
index 869182d8e1..031aa45633 100644
--- a/packages/client/src/components/toast.vue
+++ b/packages/client/src/components/toast.vue
@@ -26,7 +26,7 @@ const showing = ref(true);
const zIndex = os.claimZIndex('high');
onMounted(() => {
- setTimeout(() => {
+ window.setTimeout(() => {
showing.value = false;
}, 4000);
});
diff --git a/packages/client/src/components/ui/button.vue b/packages/client/src/components/ui/button.vue
index 804a2e2720..c7b6c8ba96 100644
--- a/packages/client/src/components/ui/button.vue
+++ b/packages/client/src/components/ui/button.vue
@@ -117,14 +117,14 @@ export default defineComponent({
const scale = calcCircleScale(e.target.clientWidth, e.target.clientHeight, circleCenterX, circleCenterY);
- setTimeout(() => {
+ window.setTimeout(() => {
ripple.style.transform = 'scale(' + (scale / 2) + ')';
}, 1);
- setTimeout(() => {
+ window.setTimeout(() => {
ripple.style.transition = 'all 1s ease';
ripple.style.opacity = '0';
}, 1000);
- setTimeout(() => {
+ window.setTimeout(() => {
if (this.$refs.ripples) this.$refs.ripples.removeChild(ripple);
}, 2000);
}
diff --git a/packages/client/src/components/ui/modal.vue b/packages/client/src/components/ui/modal.vue
index 3e2e59b27c..c691c8c6d0 100644
--- a/packages/client/src/components/ui/modal.vue
+++ b/packages/client/src/components/ui/modal.vue
@@ -211,7 +211,7 @@ export default defineComponent({
contentClicking = true;
window.addEventListener('mouseup', e => {
// click イベントより先に mouseup イベントが発生するかもしれないのでちょっと待つ
- setTimeout(() => {
+ window.setTimeout(() => {
contentClicking = false;
}, 100);
}, { passive: true, once: true });
diff --git a/packages/client/src/components/ui/pagination.vue b/packages/client/src/components/ui/pagination.vue
index d4451e27cb..571ef71eab 100644
--- a/packages/client/src/components/ui/pagination.vue
+++ b/packages/client/src/components/ui/pagination.vue
@@ -90,7 +90,6 @@ const init = async (): Promise<void> => {
}).then(res => {
for (let i = 0; i < res.length; i++) {
const item = res[i];
- markRaw(item);
if (props.pagination.reversed) {
if (i === res.length - 2) item._shouldInsertAd_ = true;
} else {
@@ -134,7 +133,6 @@ const fetchMore = async (): Promise<void> => {
}).then(res => {
for (let i = 0; i < res.length; i++) {
const item = res[i];
- markRaw(item);
if (props.pagination.reversed) {
if (i === res.length - 9) item._shouldInsertAd_ = true;
} 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,
}),
}).then(res => {
- for (const item of res) {
- markRaw(item);
- }
if (res.length > SECOND_FETCH_LIMIT) {
res.pop();
items.value = props.pagination.reversed ? [...res].reverse().concat(items.value) : items.value.concat(res);
diff --git a/packages/client/src/components/url-preview.vue b/packages/client/src/components/url-preview.vue
index dff74800ed..bf3b358797 100644
--- a/packages/client/src/components/url-preview.vue
+++ b/packages/client/src/components/url-preview.vue
@@ -4,7 +4,7 @@
<iframe :src="player.url + (player.url.match(/\?/) ? '&autoplay=1&auto_play=1' : '?autoplay=1&auto_play=1')" :width="player.width || '100%'" :heigth="player.height || 250" frameborder="0" allow="autoplay; encrypted-media" allowfullscreen />
</div>
<div v-else-if="tweetId && tweetExpanded" ref="twitter" class="twitter">
- <iframe ref="tweet" scrolling="no" frameborder="no" :style="{ position: 'relative', left: `${tweetLeft}px`, width: `${tweetLeft < 0 ? 'auto' : '100%'}`, height: `${tweetHeight}px` }" :src="`https://platform.twitter.com/embed/index.html?embedId=${embedId}&amp;hideCard=false&amp;hideThread=false&amp;lang=en&amp;theme=${$store.state.darkMode ? 'dark' : 'light'}&amp;id=${tweetId}`"></iframe>
+ <iframe ref="tweet" scrolling="no" frameborder="no" :style="{ position: 'relative', width: '100%', height: `${tweetHeight}px` }" :src="`https://platform.twitter.com/embed/index.html?embedId=${embedId}&amp;hideCard=false&amp;hideThread=false&amp;lang=en&amp;theme=${$store.state.darkMode ? 'dark' : 'light'}&amp;id=${tweetId}`"></iframe>
</div>
<div v-else v-size="{ max: [400, 350] }" class="mk-url-preview">
<transition name="zoom" mode="out-in">
@@ -32,110 +32,80 @@
</div>
</template>
-<script lang="ts">
-import { defineComponent } from 'vue';
+<script lang="ts" setup>
+import { onMounted, onUnmounted } from 'vue';
import { url as local, lang } from '@/config';
-import * as os from '@/os';
-export default defineComponent({
- props: {
- url: {
- type: String,
- require: true
- },
-
- detail: {
- type: Boolean,
- required: false,
- default: false
- },
-
- compact: {
- type: Boolean,
- required: false,
- default: false
- },
- },
-
- data() {
- const self = this.url.startsWith(local);
- return {
- local,
- fetching: true,
- title: null,
- description: null,
- thumbnail: null,
- icon: null,
- sitename: null,
- player: {
- url: null,
- width: null,
- height: null
- },
- tweetId: null,
- tweetExpanded: this.detail,
- embedId: `embed${Math.random().toString().replace(/\D/,'')}`,
- tweetHeight: 150,
- tweetLeft: 0,
- playerEnabled: false,
- self: self,
- attr: self ? 'to' : 'href',
- target: self ? null : '_blank',
- };
- },
+const props = withDefaults(defineProps<{
+ url: string;
+ detail?: boolean;
+ compact?: boolean;
+}>(), {
+ detail: false,
+ compact: false,
+});
- created() {
- const requestUrl = new URL(this.url);
+const self = props.url.startsWith(local);
+const attr = self ? 'to' : 'href';
+const target = self ? null : '_blank';
+let fetching = $ref(true);
+let title = $ref<string | null>(null);
+let description = $ref<string | null>(null);
+let thumbnail = $ref<string | null>(null);
+let icon = $ref<string | null>(null);
+let sitename = $ref<string | null>(null);
+let player = $ref({
+ url: null,
+ width: null,
+ height: null
+});
+let playerEnabled = $ref(false);
+let tweetId = $ref<string | null>(null);
+let tweetExpanded = $ref(props.detail);
+const embedId = `embed${Math.random().toString().replace(/\D/,'')}`;
+let tweetHeight = $ref(150);
- if (requestUrl.hostname == 'twitter.com') {
- const m = requestUrl.pathname.match(/^\/.+\/status(?:es)?\/(\d+)/);
- if (m) this.tweetId = m[1];
- }
+const requestUrl = new URL(props.url);
- if (requestUrl.hostname === 'music.youtube.com' && requestUrl.pathname.match('^/(?:watch|channel)')) {
- requestUrl.hostname = 'www.youtube.com';
- }
+if (requestUrl.hostname == 'twitter.com') {
+ const m = requestUrl.pathname.match(/^\/.+\/status(?:es)?\/(\d+)/);
+ if (m) tweetId = m[1];
+}
- const requestLang = (lang || 'ja-JP').replace('ja-KS', 'ja-JP');
+if (requestUrl.hostname === 'music.youtube.com' && requestUrl.pathname.match('^/(?:watch|channel)')) {
+ requestUrl.hostname = 'www.youtube.com';
+}
- requestUrl.hash = '';
+const requestLang = (lang || 'ja-JP').replace('ja-KS', 'ja-JP');
- fetch(`/url?url=${encodeURIComponent(requestUrl.href)}&lang=${requestLang}`).then(res => {
- res.json().then(info => {
- if (info.url == null) return;
- this.title = info.title;
- this.description = info.description;
- this.thumbnail = info.thumbnail;
- this.icon = info.icon;
- this.sitename = info.sitename;
- this.fetching = false;
- this.player = info.player;
- })
- });
+requestUrl.hash = '';
- (window as any).addEventListener('message', this.adjustTweetHeight);
- },
+fetch(`/url?url=${encodeURIComponent(requestUrl.href)}&lang=${requestLang}`).then(res => {
+ res.json().then(info => {
+ if (info.url == null) return;
+ title = info.title;
+ description = info.description;
+ thumbnail = info.thumbnail;
+ icon = info.icon;
+ sitename = info.sitename;
+ fetching = false;
+ player = info.player;
+ })
+});
- mounted() {
- // 300pxないと絶対右にはみ出るので左に移動してしまう
- const areaWidth = (this.$el as any)?.clientWidth;
- if (areaWidth && areaWidth < 300) this.tweetLeft = areaWidth - 241;
- },
+function adjustTweetHeight(message: any) {
+ if (message.origin !== 'https://platform.twitter.com') return;
+ const embed = message.data?.['twttr.embed'];
+ if (embed?.method !== 'twttr.private.resize') return;
+ if (embed?.id !== embedId) return;
+ const height = embed?.params[0]?.height;
+ if (height) tweetHeight = height;
+}
- beforeUnmount() {
- (window as any).removeEventListener('message', this.adjustTweetHeight);
- },
+(window as any).addEventListener('message', adjustTweetHeight);
- methods: {
- adjustTweetHeight(message: any) {
- if (message.origin !== 'https://platform.twitter.com') return;
- const embed = message.data?.['twttr.embed'];
- if (embed?.method !== 'twttr.private.resize') return;
- if (embed?.id !== this.embedId) return;
- const height = embed?.params[0]?.height;
- if (height) this.tweetHeight = height;
- },
- },
+onUnmounted(() => {
+ (window as any).removeEventListener('message', adjustTweetHeight);
});
</script>
diff --git a/packages/client/src/components/user-online-indicator.vue b/packages/client/src/components/user-online-indicator.vue
index 93e9dea57b..a87b0aeff5 100644
--- a/packages/client/src/components/user-online-indicator.vue
+++ b/packages/client/src/components/user-online-indicator.vue
@@ -2,26 +2,21 @@
<div v-tooltip="text" class="fzgwjkgc" :class="user.onlineStatus"></div>
</template>
-<script lang="ts">
-import { defineComponent } from 'vue';
+<script lang="ts" setup>
+import { } from 'vue';
+import * as misskey from 'misskey-js';
+import { i18n } from '@/i18n';
-export default defineComponent({
- props: {
- user: {
- type: Object,
- required: true
- },
- },
+const props = defineProps<{
+ user: misskey.entities.User;
+}>();
- computed: {
- text(): string {
- switch (this.user.onlineStatus) {
- case 'online': return this.$ts.online;
- case 'active': return this.$ts.active;
- case 'offline': return this.$ts.offline;
- case 'unknown': return this.$ts.unknown;
- }
- }
+const text = $computed(() => {
+ switch (props.user.onlineStatus) {
+ case 'online': return i18n.locale.online;
+ case 'active': return i18n.locale.active;
+ case 'offline': return i18n.locale.offline;
+ case 'unknown': return i18n.locale.unknown;
}
});
</script>
diff --git a/packages/client/src/components/visibility-picker.vue b/packages/client/src/components/visibility-picker.vue
index 4200f4354e..4b20063a51 100644
--- a/packages/client/src/components/visibility-picker.vue
+++ b/packages/client/src/components/visibility-picker.vue
@@ -1,28 +1,28 @@
<template>
-<MkModal ref="modal" :z-priority="'high'" :src="src" @click="$refs.modal.close()" @closed="$emit('closed')">
+<MkModal ref="modal" :z-priority="'high'" :src="src" @click="modal.close()" @closed="emit('closed')">
<div class="gqyayizv _popup">
- <button key="public" class="_button" :class="{ active: v == 'public' }" data-index="1" @click="choose('public')">
+ <button key="public" class="_button" :class="{ active: v === 'public' }" data-index="1" @click="choose('public')">
<div><i class="fas fa-globe"></i></div>
<div>
<span>{{ $ts._visibility.public }}</span>
<span>{{ $ts._visibility.publicDescription }}</span>
</div>
</button>
- <button key="home" class="_button" :class="{ active: v == 'home' }" data-index="2" @click="choose('home')">
+ <button key="home" class="_button" :class="{ active: v === 'home' }" data-index="2" @click="choose('home')">
<div><i class="fas fa-home"></i></div>
<div>
<span>{{ $ts._visibility.home }}</span>
<span>{{ $ts._visibility.homeDescription }}</span>
</div>
</button>
- <button key="followers" class="_button" :class="{ active: v == 'followers' }" data-index="3" @click="choose('followers')">
+ <button key="followers" class="_button" :class="{ active: v === 'followers' }" data-index="3" @click="choose('followers')">
<div><i class="fas fa-unlock"></i></div>
<div>
<span>{{ $ts._visibility.followers }}</span>
<span>{{ $ts._visibility.followersDescription }}</span>
</div>
</button>
- <button key="specified" :disabled="localOnly" class="_button" :class="{ active: v == 'specified' }" data-index="4" @click="choose('specified')">
+ <button key="specified" :disabled="localOnly" class="_button" :class="{ active: v === 'specified' }" data-index="4" @click="choose('specified')">
<div><i class="fas fa-envelope"></i></div>
<div>
<span>{{ $ts._visibility.specified }}</span>
@@ -42,49 +42,40 @@
</MkModal>
</template>
-<script lang="ts">
-import { defineComponent } from 'vue';
+<script lang="ts" setup>
+import { nextTick, watch } from 'vue';
+import * as misskey from 'misskey-js';
import MkModal from '@/components/ui/modal.vue';
-export default defineComponent({
- components: {
- MkModal,
- },
- props: {
- currentVisibility: {
- type: String,
- required: true
- },
- currentLocalOnly: {
- type: Boolean,
- required: true
- },
- src: {
- required: false
- },
- },
- emits: ['change-visibility', 'change-local-only', 'closed'],
- data() {
- return {
- v: this.currentVisibility,
- localOnly: this.currentLocalOnly,
- }
- },
- watch: {
- localOnly() {
- this.$emit('change-local-only', this.localOnly);
- }
- },
- methods: {
- choose(visibility) {
- this.v = visibility;
- this.$emit('change-visibility', visibility);
- this.$nextTick(() => {
- this.$refs.modal.close();
- });
- },
- }
+const modal = $ref<InstanceType<typeof MkModal>>();
+
+const props = withDefaults(defineProps<{
+ currentVisibility: typeof misskey.noteVisibilities[number];
+ currentLocalOnly: boolean;
+ src?: HTMLElement;
+}>(), {
+});
+
+const emit = defineEmits<{
+ (e: 'changeVisibility', v: typeof misskey.noteVisibilities[number]): void;
+ (e: 'changeLocalOnly', v: boolean): void;
+ (e: 'closed'): void;
+}>();
+
+let v = $ref(props.currentVisibility);
+let localOnly = $ref(props.currentLocalOnly);
+
+watch($$(localOnly), () => {
+ emit('changeLocalOnly', localOnly);
});
+
+function choose(visibility: typeof misskey.noteVisibilities[number]): void {
+ v = visibility;
+ emit('changeVisibility', visibility);
+ nextTick(() => {
+ modal.close();
+ });
+}
</script>
<style lang="scss" scoped>
diff --git a/packages/client/src/components/waiting-dialog.vue b/packages/client/src/components/waiting-dialog.vue
index 10aedbd8f6..7dfcc55695 100644
--- a/packages/client/src/components/waiting-dialog.vue
+++ b/packages/client/src/components/waiting-dialog.vue
@@ -1,5 +1,5 @@
<template>
-<MkModal ref="modal" :prefer-type="'dialog'" :z-priority="'high'" @click="success ? done() : () => {}" @closed="$emit('closed')">
+<MkModal ref="modal" :prefer-type="'dialog'" :z-priority="'high'" @click="success ? done() : () => {}" @closed="emit('closed')">
<div class="iuyakobc" :class="{ iconOnly: (text == null) || success }">
<i v-if="success" class="fas fa-check icon success"></i>
<i v-else class="fas fa-spinner fa-pulse icon waiting"></i>
@@ -8,49 +8,30 @@
</MkModal>
</template>
-<script lang="ts">
-import { defineComponent } from 'vue';
+<script lang="ts" setup>
+import { watch, ref } from 'vue';
import MkModal from '@/components/ui/modal.vue';
-export default defineComponent({
- components: {
- MkModal,
- },
+const modal = ref<InstanceType<typeof MkModal>>();
- props: {
- success: {
- type: Boolean,
- required: true,
- },
- showing: {
- type: Boolean,
- required: true,
- },
- text: {
- type: String,
- required: false,
- },
- },
+const props = defineProps<{
+ success: boolean;
+ showing: boolean;
+ text?: string;
+}>();
- emits: ['done', 'closed'],
+const emit = defineEmits<{
+ (e: 'done');
+ (e: 'closed');
+}>();
- data() {
- return {
- };
- },
-
- watch: {
- showing() {
- if (!this.showing) this.done();
- }
- },
+function done() {
+ emit('done');
+ modal.value.close();
+}
- methods: {
- done() {
- this.$emit('done');
- this.$refs.modal.close();
- },
- }
+watch(() => props.showing, () => {
+ if (!props.showing) done();
});
</script>