diff --git a/packages/client/src/components/note.sub.vue b/packages/client/src/components/MkNoteSub.vue
similarity index 66%
rename from packages/client/src/components/note.sub.vue
rename to 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 @@
-
+
-
+
{{ $ts.continueThread }}
@@ -24,63 +24,36 @@
-
\ No newline at end of file
+
diff --git a/packages/client/src/pages/user/reactions.vue b/packages/client/src/pages/user/reactions.vue
index 0381542b4d..d2c1f92ebb 100644
--- a/packages/client/src/pages/user/reactions.vue
+++ b/packages/client/src/pages/user/reactions.vue
@@ -7,7 +7,7 @@
-
+
diff --git a/packages/client/src/scripts/check-word-mute.ts b/packages/client/src/scripts/check-word-mute.ts
index 3b1fa75b1e..55637bb3b3 100644
--- a/packages/client/src/scripts/check-word-mute.ts
+++ b/packages/client/src/scripts/check-word-mute.ts
@@ -1,4 +1,4 @@
-export async function checkWordMute(note: Record, me: Record | null | undefined, mutedWords: string[][]): Promise {
+export function checkWordMute(note: Record, me: Record | null | undefined, mutedWords: string[][]): boolean {
// 自分自身
if (me && (note.userId === me.id)) return false;
diff --git a/packages/client/src/scripts/get-note-menu.ts b/packages/client/src/scripts/get-note-menu.ts
new file mode 100644
index 0000000000..61120d53ba
--- /dev/null
+++ b/packages/client/src/scripts/get-note-menu.ts
@@ -0,0 +1,310 @@
+import { Ref } from 'vue';
+import * as misskey from 'misskey-js';
+import { $i } from '@/account';
+import { i18n } from '@/i18n';
+import { instance } from '@/instance';
+import * as os from '@/os';
+import copyToClipboard from '@/scripts/copy-to-clipboard';
+import { url } from '@/config';
+import { noteActions } from '@/store';
+import { pleaseLogin } from './please-login';
+
+export function getNoteMenu(props: {
+ note: misskey.entities.Note;
+ menuButton: Ref;
+ translation: Ref;
+ translating: Ref;
+}) {
+ const isRenote = (
+ props.note.renote != null &&
+ props.note.text == null &&
+ props.note.fileIds.length === 0 &&
+ props.note.poll == null
+ );
+
+ let appearNote = isRenote ? props.note.renote as misskey.entities.Note : props.note;
+
+ function del(): void {
+ os.confirm({
+ type: 'warning',
+ text: i18n.locale.noteDeleteConfirm,
+ }).then(({ canceled }) => {
+ if (canceled) return;
+
+ os.api('notes/delete', {
+ noteId: appearNote.id
+ });
+ });
+ }
+
+ function delEdit(): void {
+ os.confirm({
+ type: 'warning',
+ text: i18n.locale.deleteAndEditConfirm,
+ }).then(({ canceled }) => {
+ if (canceled) return;
+
+ os.api('notes/delete', {
+ noteId: appearNote.id
+ });
+
+ os.post({ initialNote: appearNote, renote: appearNote.renote, reply: appearNote.reply, channel: appearNote.channel });
+ });
+ }
+
+ function toggleFavorite(favorite: boolean): void {
+ os.apiWithDialog(favorite ? 'notes/favorites/create' : 'notes/favorites/delete', {
+ noteId: appearNote.id
+ });
+ }
+
+ function toggleWatch(watch: boolean): void {
+ os.apiWithDialog(watch ? 'notes/watching/create' : 'notes/watching/delete', {
+ noteId: appearNote.id
+ });
+ }
+
+ function toggleThreadMute(mute: boolean): void {
+ os.apiWithDialog(mute ? 'notes/thread-muting/create' : 'notes/thread-muting/delete', {
+ noteId: appearNote.id
+ });
+ }
+
+ function copyContent(): void {
+ copyToClipboard(appearNote.text);
+ os.success();
+ }
+
+ function copyLink(): void {
+ copyToClipboard(`${url}/notes/${appearNote.id}`);
+ os.success();
+ }
+
+ function togglePin(pin: boolean): void {
+ os.apiWithDialog(pin ? 'i/pin' : 'i/unpin', {
+ noteId: appearNote.id
+ }, undefined, null, e => {
+ if (e.id === '72dab508-c64d-498f-8740-a8eec1ba385a') {
+ os.alert({
+ type: 'error',
+ text: i18n.locale.pinLimitExceeded
+ });
+ }
+ });
+ }
+
+ async function clip(): Promise {
+ const clips = await os.api('clips/list');
+ os.popupMenu([{
+ icon: 'fas fa-plus',
+ text: i18n.locale.createNew,
+ action: async () => {
+ const { canceled, result } = await os.form(i18n.locale.createNewClip, {
+ name: {
+ type: 'string',
+ label: i18n.locale.name
+ },
+ description: {
+ type: 'string',
+ required: false,
+ multiline: true,
+ label: i18n.locale.description
+ },
+ isPublic: {
+ type: 'boolean',
+ label: i18n.locale.public,
+ default: false
+ }
+ });
+ if (canceled) return;
+
+ const clip = await os.apiWithDialog('clips/create', result);
+
+ os.apiWithDialog('clips/add-note', { clipId: clip.id, noteId: appearNote.id });
+ }
+ }, null, ...clips.map(clip => ({
+ text: clip.name,
+ action: () => {
+ os.apiWithDialog('clips/add-note', { clipId: clip.id, noteId: appearNote.id });
+ }
+ }))], props.menuButton.value, {
+ }).then(focus);
+ }
+
+ async function promote(): Promise {
+ const { canceled, result: days } = await os.inputNumber({
+ title: i18n.locale.numberOfDays,
+ });
+
+ if (canceled) return;
+
+ os.apiWithDialog('admin/promo/create', {
+ noteId: appearNote.id,
+ expiresAt: Date.now() + (86400000 * days),
+ });
+ }
+
+ function share(): void {
+ navigator.share({
+ title: i18n.t('noteOf', { user: appearNote.user.name }),
+ text: appearNote.text,
+ url: `${url}/notes/${appearNote.id}`,
+ });
+ }
+
+ async function translate(): Promise {
+ if (props.translation.value != null) return;
+ props.translating.value = true;
+ const res = await os.api('notes/translate', {
+ noteId: appearNote.id,
+ targetLang: localStorage.getItem('lang') || navigator.language,
+ });
+ props.translating.value = false;
+ props.translation.value = res;
+ }
+
+ let menu;
+ if ($i) {
+ const statePromise = os.api('notes/state', {
+ noteId: appearNote.id
+ });
+
+ menu = [{
+ icon: 'fas fa-copy',
+ text: i18n.locale.copyContent,
+ action: copyContent
+ }, {
+ icon: 'fas fa-link',
+ text: i18n.locale.copyLink,
+ action: copyLink
+ }, (appearNote.url || appearNote.uri) ? {
+ icon: 'fas fa-external-link-square-alt',
+ text: i18n.locale.showOnRemote,
+ action: () => {
+ window.open(appearNote.url || appearNote.uri, '_blank');
+ }
+ } : undefined,
+ {
+ icon: 'fas fa-share-alt',
+ text: i18n.locale.share,
+ action: share
+ },
+ instance.translatorAvailable ? {
+ icon: 'fas fa-language',
+ text: i18n.locale.translate,
+ action: translate
+ } : undefined,
+ null,
+ statePromise.then(state => state.isFavorited ? {
+ icon: 'fas fa-star',
+ text: i18n.locale.unfavorite,
+ action: () => toggleFavorite(false)
+ } : {
+ icon: 'fas fa-star',
+ text: i18n.locale.favorite,
+ action: () => toggleFavorite(true)
+ }),
+ {
+ icon: 'fas fa-paperclip',
+ text: i18n.locale.clip,
+ action: () => clip()
+ },
+ (appearNote.userId != $i.id) ? statePromise.then(state => state.isWatching ? {
+ icon: 'fas fa-eye-slash',
+ text: i18n.locale.unwatch,
+ action: () => toggleWatch(false)
+ } : {
+ icon: 'fas fa-eye',
+ text: i18n.locale.watch,
+ action: () => toggleWatch(true)
+ }) : undefined,
+ statePromise.then(state => state.isMutedThread ? {
+ icon: 'fas fa-comment-slash',
+ text: i18n.locale.unmuteThread,
+ action: () => toggleThreadMute(false)
+ } : {
+ icon: 'fas fa-comment-slash',
+ text: i18n.locale.muteThread,
+ action: () => toggleThreadMute(true)
+ }),
+ appearNote.userId == $i.id ? ($i.pinnedNoteIds || []).includes(appearNote.id) ? {
+ icon: 'fas fa-thumbtack',
+ text: i18n.locale.unpin,
+ action: () => togglePin(false)
+ } : {
+ icon: 'fas fa-thumbtack',
+ text: i18n.locale.pin,
+ action: () => togglePin(true)
+ } : undefined,
+ /*
+ ...($i.isModerator || $i.isAdmin ? [
+ null,
+ {
+ icon: 'fas fa-bullhorn',
+ text: i18n.locale.promote,
+ action: promote
+ }]
+ : []
+ ),*/
+ ...(appearNote.userId != $i.id ? [
+ null,
+ {
+ icon: 'fas fa-exclamation-circle',
+ text: i18n.locale.reportAbuse,
+ action: () => {
+ const u = `${url}/notes/${appearNote.id}`;
+ os.popup(import('@/components/abuse-report-window.vue'), {
+ user: appearNote.user,
+ initialComment: `Note: ${u}\n-----\n`
+ }, {}, 'closed');
+ }
+ }]
+ : []
+ ),
+ ...(appearNote.userId == $i.id || $i.isModerator || $i.isAdmin ? [
+ null,
+ appearNote.userId == $i.id ? {
+ icon: 'fas fa-edit',
+ text: i18n.locale.deleteAndEdit,
+ action: delEdit
+ } : undefined,
+ {
+ icon: 'fas fa-trash-alt',
+ text: i18n.locale.delete,
+ danger: true,
+ action: del
+ }]
+ : []
+ )]
+ .filter(x => x !== undefined);
+ } else {
+ menu = [{
+ icon: 'fas fa-copy',
+ text: i18n.locale.copyContent,
+ action: copyContent
+ }, {
+ icon: 'fas fa-link',
+ text: i18n.locale.copyLink,
+ action: copyLink
+ }, (appearNote.url || appearNote.uri) ? {
+ icon: 'fas fa-external-link-square-alt',
+ text: i18n.locale.showOnRemote,
+ action: () => {
+ window.open(appearNote.url || appearNote.uri, '_blank');
+ }
+ } : undefined]
+ .filter(x => x !== undefined);
+ }
+
+ if (noteActions.length > 0) {
+ menu = menu.concat([null, ...noteActions.map(action => ({
+ icon: 'fas fa-plug',
+ text: action.title,
+ action: () => {
+ action.handler(appearNote);
+ }
+ }))]);
+ }
+
+ return menu;
+}
diff --git a/packages/client/src/scripts/use-note-capture.ts b/packages/client/src/scripts/use-note-capture.ts
new file mode 100644
index 0000000000..bb00e464e3
--- /dev/null
+++ b/packages/client/src/scripts/use-note-capture.ts
@@ -0,0 +1,123 @@
+import { onUnmounted, Ref } from 'vue';
+import * as misskey from 'misskey-js';
+import { stream } from '@/stream';
+import { $i } from '@/account';
+
+export function useNoteCapture(props: {
+ rootEl: Ref;
+ appearNote: Ref;
+}) {
+ const appearNote = props.appearNote;
+ const connection = $i ? stream : null;
+
+ function onStreamNoteUpdated(data): void {
+ const { type, id, body } = data;
+
+ if (id !== appearNote.value.id) return;
+
+ switch (type) {
+ case 'reacted': {
+ const reaction = body.reaction;
+
+ const updated = JSON.parse(JSON.stringify(appearNote.value));
+
+ if (body.emoji) {
+ const emojis = appearNote.value.emojis || [];
+ if (!emojis.includes(body.emoji)) {
+ updated.emojis = [...emojis, body.emoji];
+ }
+ }
+
+ // TODO: reactionsプロパティがない場合ってあったっけ? なければ || {} は消せる
+ const currentCount = (appearNote.value.reactions || {})[reaction] || 0;
+
+ updated.reactions[reaction] = currentCount + 1;
+
+ if ($i && (body.userId === $i.id)) {
+ updated.myReaction = reaction;
+ }
+
+ appearNote.value = updated;
+ break;
+ }
+
+ case 'unreacted': {
+ const reaction = body.reaction;
+
+ const updated = JSON.parse(JSON.stringify(appearNote.value));
+
+ // TODO: reactionsプロパティがない場合ってあったっけ? なければ || {} は消せる
+ const currentCount = (appearNote.value.reactions || {})[reaction] || 0;
+
+ updated.reactions[reaction] = Math.max(0, currentCount - 1);
+
+ if ($i && (body.userId === $i.id)) {
+ updated.myReaction = null;
+ }
+
+ appearNote.value = updated;
+ break;
+ }
+
+ case 'pollVoted': {
+ const choice = body.choice;
+
+ const updated = JSON.parse(JSON.stringify(appearNote.value));
+
+ const choices = [...appearNote.value.poll.choices];
+ choices[choice] = {
+ ...choices[choice],
+ votes: choices[choice].votes + 1,
+ ...($i && (body.userId === $i.id) ? {
+ isVoted: true
+ } : {})
+ };
+
+ updated.poll.choices = choices;
+
+ appearNote.value = updated;
+ break;
+ }
+
+ case 'deleted': {
+ const updated = JSON.parse(JSON.stringify(appearNote.value));
+ updated.value = true;
+ appearNote.value = updated;
+ break;
+ }
+ }
+ }
+
+ function capture(withHandler = false): void {
+ if (connection) {
+ // TODO: このノートがストリーミング経由で流れてきた場合のみ sr する
+ connection.send(document.body.contains(props.rootEl.value) ? 'sr' : 's', { id: appearNote.value.id });
+ if (withHandler) connection.on('noteUpdated', onStreamNoteUpdated);
+ }
+ }
+
+ function decapture(withHandler = false): void {
+ if (connection) {
+ connection.send('un', {
+ id: appearNote.value.id,
+ });
+ if (withHandler) connection.off('noteUpdated', onStreamNoteUpdated);
+ }
+ }
+
+ function onStreamConnected() {
+ capture(false);
+ }
+
+ capture(true);
+ if (connection) {
+ connection.on('_connected_', onStreamConnected);
+ }
+
+ onUnmounted(() => {
+ decapture(true);
+ if (connection) {
+ connection.off('_connected_', onStreamConnected);
+ }
+ });
+}
diff --git a/packages/client/src/store.ts b/packages/client/src/store.ts
index 745d323100..a57e8ec62b 100644
--- a/packages/client/src/store.ts
+++ b/packages/client/src/store.ts
@@ -160,7 +160,7 @@ export const defaultStore = markRaw(new Storage('base', {
},
useReactionPickerForContextMenu: {
where: 'device',
- default: true
+ default: false
},
showGapBetweenNotesInTimeline: {
where: 'device',