feat: サブノートにアクションボタンを表示するように

This commit is contained in:
NoriDev 2023-07-04 20:53:05 +09:00
parent 9f573903ce
commit 545b2d166c
11 changed files with 321 additions and 10 deletions

View file

@ -36,6 +36,7 @@
- 모바일 환경에서 타임라인의 헤더 디자인을 변경할 수 있음
- 「제어판 - 유저」에서 최근 온라인 유저를 정렬해서 볼 수 있음
- 「이미 팔로우한 경우 알림 필드에 팔로우 버튼을 표시하지 않음」설정 사용 시, 팔로우 했다는 문구를 표시하도록
- 서브 노트에 액션 버튼을 표시하는 기능
### Client
- 리노트 전 확인 팝업을 띄움

View file

@ -1,5 +1,7 @@
---
_lang_: "English"
showSubNoteFooterButton: "Show action buttons in subnotes"
showSubNoteFooterButtonDescription: "Enabling this setting will show an action button on the parent note of the replied-to note."
alreadyFollowed: "You've been followed!"
enableMarkByDate: "Show note times as dates"
renoteConfirm: "Do you want to Renote?"

2
locales/index.d.ts vendored
View file

@ -3,6 +3,8 @@
// Do not edit this file directly.
export interface Locale {
"_lang_": string;
"showSubNoteFooterButton": string;
"showSubNoteFooterButtonDescription": string;
"alreadyFollowed": string;
"enableMarkByDate": string;
"renoteConfirm": string;

View file

@ -1,5 +1,7 @@
_lang_: "日本語"
showSubNoteFooterButton: "サブノートにアクションボタンを表示"
showSubNoteFooterButtonDescription: "この設定を有効にすると、返信があるノートの親ノートにアクションボタンを表示します。"
alreadyFollowed: "フォローしました!"
enableMarkByDate: "ノート時刻を日付で表示"
renoteConfirm: "Renoteしますか"

View file

@ -1,5 +1,7 @@
---
_lang_: "한국어"
showSubNoteFooterButton: "서브 노트에 액션 버튼 표시"
showSubNoteFooterButtonDescription: "이 설정을 활성화하면 답글이 달린 노트의 상위 노트에 액션 버튼을 표시해요."
alreadyFollowed: "팔로우 했어요!"
enableMarkByDate: "노트 시간을 일자로 표시"
renoteConfirm: "리노트 할까요?"

View file

@ -190,7 +190,6 @@ import { deepClone } from '@/scripts/clone';
import { useTooltip } from '@/scripts/use-tooltip';
import { claimAchievement } from '@/scripts/achievements';
import { getNoteSummary } from '@/scripts/get-note-summary';
import { MenuItem } from '@/types/menu';
import MkRippleEffect from '@/components/MkRippleEffect.vue';
import { showMovedDialog } from '@/scripts/show-moved-dialog';
import { eventBus } from '@/scripts/cherrypick/eventBus';

View file

@ -13,7 +13,7 @@
<MkCwButton v-model="showContent" style="width: 100%" :note="note"/>
</p>
<div v-show="note.cw == null || showContent">
<MkSubNoteContent :class="$style.text" :note="note"/>
<MkSubNoteContent :class="$style.text" :note="note" :showSubNoteFooterButton="defaultStore.state.showSubNoteFooterButton"/>
</div>
</div>
</div>

View file

@ -1,5 +1,5 @@
<template>
<div :class="[$style.root, { [$style.collapsed]: collapsed }]">
<div ref="el" :class="[$style.root, { [$style.collapsed]: collapsed }]">
<div>
<span v-if="note.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span>
<span v-if="note.deletedAt" style="opacity: 0.5">({{ i18n.ts.deleted }})</span>
@ -35,21 +35,92 @@
<button v-if="collapsed" :class="$style.fade" class="_button" @click="collapsed = false">
<span :class="$style.fadeLabel">{{ i18n.ts.showMore }}</span>
</button>
<div v-if="showSubNoteFooterButton">
<MkReactionsViewer :note="note" :maxNumber="16">
<template #more>
<button class="_button" :class="$style.reactionDetailsButton" @click="showReactions">
{{ i18n.ts.more }}
</button>
</template>
</MkReactionsViewer>
<footer :class="$style.footer">
<button v-tooltip="i18n.ts.reply" :class="$style.footerButton" class="_button" @click="reply()">
<i class="ti ti-arrow-back-up"></i>
<p v-if="note.repliesCount > 0" :class="$style.footerButtonCount">{{ note.repliesCount }}</p>
</button>
<button
v-if="canRenote"
ref="renoteButton"
v-tooltip="i18n.ts.renote"
:class="$style.footerButton"
class="_button"
@mousedown="renote()"
>
<i class="ti ti-repeat"></i>
<p v-if="note.renoteCount > 0" :class="$style.footerButtonCount">{{ note.renoteCount }}</p>
</button>
<button v-else :class="$style.footerButton" class="_button" disabled>
<i class="ti ti-ban"></i>
</button>
<button v-if="note.myReaction == null" ref="heartReactButton" v-tooltip="i18n.ts.like" :class="$style.footerButton" class="_button" @mousedown="heartReact()">
<i class="ti ti-heart"></i>
</button>
<button v-if="note.myReaction == null" ref="reactButton" v-tooltip="i18n.ts.reaction" :class="$style.footerButton" class="_button" @mousedown="react()">
<i v-if="note.reactionAcceptance === 'likeOnly'" class="ti ti-heart"></i>
<i v-else class="ti ti-mood-plus"></i>
</button>
<button v-if="note.myReaction != null" ref="reactButton" :class="$style.footerButton" class="_button" @click="undoReact(note)">
<i class="ti ti-mood-minus"></i>
</button>
<button v-if="canRenote" v-tooltip="i18n.ts.quote" class="_button" :class="$style.footerButton" @mousedown="quote()"><i class="ti ti-quote"></i></button>
<button v-if="defaultStore.state.showClipButtonInNoteFooter" ref="clipButton" v-tooltip="i18n.ts.clip" :class="$style.footerButton" class="_button" @mousedown="clip()">
<i class="ti ti-paperclip"></i>
</button>
<MkA v-if="defaultStore.state.infoButtonForNoteActionsEnabled && defaultStore.state.showNoteActionsOnlyHover" v-tooltip="i18n.ts.details" :to="notePage(note)" :class="$style.footerButton" style="text-decoration: none;" class="_button">
<i class="ti ti-info-circle"></i>
</MkA>
<button ref="menuButton" v-tooltip="i18n.ts.more" :class="$style.footerButton" class="_button" @mousedown="menu()">
<i class="ti ti-dots"></i>
</button>
</footer>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import {computed, defineAsyncComponent, inject, Ref, ref, shallowRef} from 'vue';
import * as misskey from 'cherrypick-js';
import * as os from '@/os';
import MkMediaList from '@/components/MkMediaList.vue';
import MkPoll from '@/components/MkPoll.vue';
import MkDetailsButton from '@/components/MkDetailsButton.vue';
import MkUsersTooltip from '@/components/MkUsersTooltip.vue';
import MkRippleEffect from '@/components/MkRippleEffect.vue';
import { i18n } from '@/i18n';
import { $i } from '@/account';
import { defaultStore } from '@/store';
import { miLocalStorage } from '@/local-storage';
import { instance } from '@/instance';
import { notePage } from '@/filters/note';
import { useTooltip } from '@/scripts/use-tooltip';
import { pleaseLogin } from '@/scripts/please-login';
import { showMovedDialog } from '@/scripts/show-moved-dialog';
import { getNoteClipMenu, getNoteMenu } from '@/scripts/get-note-menu';
import { deepClone } from "@/scripts/clone";
import MkReactionsViewer from "@/components/MkReactionsViewer.vue";
import {reactionPicker} from "@/scripts/reaction-picker";
import {claimAchievement} from "@/scripts/achievements";
import {useNoteCapture} from "@/scripts/use-note-capture";
const el = shallowRef<HTMLElement>();
const menuButton = shallowRef<HTMLElement>();
const renoteButton = shallowRef<HTMLElement>();
const reactButton = shallowRef<HTMLElement>();
const heartReactButton = shallowRef<HTMLElement>();
const clipButton = shallowRef<HTMLElement>();
const canRenote = computed(() => ['public', 'home'].includes(props.note.visibility) || props.note.userId === $i.id);
const isDeleted = ref(false);
const currentClip = inject<Ref<misskey.entities.Clip> | null>('currentClip', null);
const showContent = ref(false);
const translation = ref<any>(null);
@ -57,14 +128,33 @@ const translating = ref(false);
const props = defineProps<{
note: misskey.entities.Note;
showSubNoteFooterButton: boolean;
}>();
let note = $ref(deepClone(props.note));
const collapsed = $ref(
props.note.cw == null && props.note.text != null && (
(props.note.text.split('\n').length > 9) ||
(props.note.text.length > 500)
));
useNoteCapture({
rootEl: el,
note: $$(note),
isDeletedRef: isDeleted,
});
function menu(viaKeyboard = false): void {
os.popupMenu(getNoteMenu({ note: note, translating, translation, menuButton, isDeleted, currentClip: currentClip?.value }), menuButton.value, {
viaKeyboard,
}).then(focus);
}
async function clip() {
os.popupMenu(await getNoteClipMenu({ note: note, isDeleted, currentClip: currentClip?.value }), clipButton.value).then(focus);
}
async function translate(): Promise<void> {
if (translation.value != null) return;
translating.value = true;
@ -75,6 +165,156 @@ async function translate(): Promise<void> {
translating.value = false;
translation.value = res;
}
useTooltip(renoteButton, async (showing) => {
const renotes = await os.api('notes/renotes', {
noteId: props.note.id,
limit: 11,
});
const users = renotes.map(x => x.user);
if (users.length < 1) return;
os.popup(MkUsersTooltip, {
showing,
users,
count: props.note.renoteCount,
targetElement: renoteButton.value,
}, {}, 'closed');
});
async function renote() {
pleaseLogin();
showMovedDialog();
const { canceled } = await os.confirm({
type: 'info',
text: i18n.ts.renoteConfirm,
});
if (canceled) return;
if (props.note.channel) {
const el = renoteButton.value as HTMLElement | null | undefined;
if (el) {
const rect = el.getBoundingClientRect();
const x = rect.left + (el.offsetWidth / 2);
const y = rect.top + (el.offsetHeight / 2);
os.popup(MkRippleEffect, { x, y }, {}, 'end');
}
os.api('notes/create', {
renoteId: props.note.id,
channelId: props.note.channelId,
}).then(() => {
os.noteToast(i18n.ts.renoted);
});
}
const el = renoteButton.value as HTMLElement | null | undefined;
if (el) {
const rect = el.getBoundingClientRect();
const x = rect.left + (el.offsetWidth / 2);
const y = rect.top + (el.offsetHeight / 2);
os.popup(MkRippleEffect, { x, y }, {}, 'end');
}
os.api('notes/create', {
renoteId: props.note.id,
}).then(() => {
os.noteToast(i18n.ts.renoted);
});
}
function quote() {
pleaseLogin();
showMovedDialog();
if (props.note.channel) {
os.post({
renote: props.note,
channel: props.note.channel,
});
}
os.post({
renote: props.note,
});
}
function reply(viaKeyboard = false): void {
pleaseLogin();
os.post({
reply: props.note,
animation: !viaKeyboard,
}, () => {
focus();
});
}
function react(viaKeyboard = false): void {
pleaseLogin();
showMovedDialog();
if (props.note.reactionAcceptance === 'likeOnly') {
os.api('notes/reactions/create', {
noteId: props.note.id,
reaction: '❤️',
});
const el = reactButton.value as HTMLElement | null | undefined;
if (el) {
const rect = el.getBoundingClientRect();
const x = rect.left + (el.offsetWidth / 2);
const y = rect.top + (el.offsetHeight / 2);
os.popup(MkRippleEffect, { x, y }, {}, 'end');
}
} else {
blur();
reactionPicker.show(reactButton.value, reaction => {
os.api('notes/reactions/create', {
noteId: props.note.id,
reaction: reaction,
});
if (props.note.text && props.note.text.length > 100 && (Date.now() - new Date(props.note.createdAt).getTime() < 1000 * 3)) {
claimAchievement('reactWithoutRead');
}
}, () => {
focus();
});
}
}
function heartReact(): void {
pleaseLogin();
showMovedDialog();
os.api('notes/reactions/create', {
noteId: props.note.id,
reaction: '❤️',
});
if (props.note.text && props.note.text.length > 100 && (Date.now() - new Date(props.note.createdAt).getTime() < 1000 * 3)) {
claimAchievement('reactWithoutRead');
}
const el = heartReactButton.value as HTMLElement | null | undefined;
if (el) {
const rect = el.getBoundingClientRect();
const x = rect.left + (el.offsetWidth / 2);
const y = rect.top + (el.offsetHeight / 2);
os.popup(MkRippleEffect, { x, y }, {}, 'end');
}
}
function undoReact(note): void {
const oldReaction = note.myReaction;
if (!oldReaction) return;
os.api('notes/reactions/delete', {
noteId: note.id,
});
}
function showReactions(): void {
os.popup(defineAsyncComponent(() => import('@/components/MkReactedUsersDialog.vue')), {
noteId: props.note.id,
}, {}, 'closed');
}
</script>
<style lang="scss" module>
@ -111,6 +351,15 @@ async function translate(): Promise<void> {
}
}
}
.footer {
position: relative;
z-index: 1;
}
&:hover > .article > .main > .footer > .footerButton {
opacity: 1;
}
}
.reply {
@ -130,4 +379,49 @@ async function translate(): Promise<void> {
padding: 12px;
margin-top: 8px;
}
.footer {
margin: 7px 0 -14px;
}
.footerButton {
margin: 0;
padding: 8px;
opacity: 0.7;
&:not(:last-child) {
margin-right: 10px;
}
&:hover {
color: var(--fgHighlighted);
}
}
.footerButtonCount {
display: inline;
margin: 0 0 0 8px;
opacity: 0.7;
}
@container (max-width: 500px) {
.footer {
margin-bottom: -8px;
}
}
.reactionDetailsButton {
display: inline-block;
height: 32px;
margin: 2px;
padding: 0 6px;
border: dashed 1px var(--divider);
border-radius: 4px;
background: transparent;
opacity: .8;
&:hover {
background: var(--X5);
}
}
</style>

View file

@ -46,16 +46,16 @@
</template>
<script lang="ts" setup>
import { computed, ref, watch } from 'vue';
import { Note, User } from 'cherrypick-js/src/entities';
import { computed, watch } from 'vue';
// import { Note, User } from 'cherrypick-js/src/entities';
import MkSwitch from '@/components/MkSwitch.vue';
import MkNote from '@/components/MkNote.vue';
// import MkNote from '@/components/MkNote.vue';
import FormSection from '@/components/form/section.vue';
import { defaultStore } from '@/store';
import * as os from '@/os';
import { unisonReload } from '@/scripts/unison-reload';
import { i18n } from '@/i18n';
import { $i } from '@/account';
// import { $i } from '@/account';
import { definePageMetadata } from '@/scripts/page-metadata';
async function reloadAsk() {
@ -74,11 +74,12 @@ const stealEnabled = computed(defaultStore.makeGetterSetter('stealEnabled'));
const infoButtonForNoteActionsEnabled = computed(defaultStore.makeGetterSetter('infoButtonForNoteActionsEnabled'));
const reactableRemoteReactionEnabled = computed(defaultStore.makeGetterSetter('reactableRemoteReactionEnabled'));
const rememberPostFormToggleStateEnabled = computed(defaultStore.makeGetterSetter('rememberPostFormToggleStateEnabled'));
const usePostFormWindow = computed(defaultStore.makeGetterSetter('usePostFormWindow'));
const cherrypickNoteViewEnabled = computed(defaultStore.makeGetterSetter('cherrypickNoteViewEnabledLab'));
// const usePostFormWindow = computed(defaultStore.makeGetterSetter('usePostFormWindow'));
// const cherrypickNoteViewEnabled = computed(defaultStore.makeGetterSetter('cherrypickNoteViewEnabledLab'));
const showFollowingMessageInsteadOfButtonEnabled = computed(defaultStore.makeGetterSetter('showFollowingMessageInsteadOfButtonEnabled'));
const mobileTimelineHeaderChange = computed(defaultStore.makeGetterSetter('mobileTimelineHeaderChange'));
/*
const noteMock: Note = {
id: 'abc',
createdAt: new Date().toISOString(),
@ -97,6 +98,7 @@ const noteMock: Note = {
emojis: [],
localOnly: true,
};
*/
watch([
numberQuoteEnabled,

View file

@ -62,6 +62,7 @@
</MkSwitch>
<MkSwitch v-model="enableAbsoluteTime">{{ i18n.ts.enableAbsoluteTime }} <span class="_beta">CherryPick</span></MkSwitch>
<MkSwitch v-model="enableMarkByDate" :disabled="defaultStore.state.enableAbsoluteTime">{{ i18n.ts.enableMarkByDate }} <span class="_beta">CherryPick</span></MkSwitch>
<MkSwitch v-model="showSubNoteFooterButton">{{ i18n.ts.showSubNoteFooterButton }}<template #caption>{{ i18n.ts.showSubNoteFooterButtonDescription }}</template> <span class="_beta">CherryPick</span></MkSwitch>
</div>
<MkSelect v-model="instanceTicker">
@ -336,6 +337,7 @@ const hideAvatarsInNote = computed(defaultStore.makeGetterSetter('hideAvatarsInN
const showTranslateButtonInNote = computed(defaultStore.makeGetterSetter('showTranslateButtonInNote'));
const enableAbsoluteTime = computed(defaultStore.makeGetterSetter('enableAbsoluteTime'));
const enableMarkByDate = computed(defaultStore.makeGetterSetter('enableMarkByDate'));
const showSubNoteFooterButton = computed(defaultStore.makeGetterSetter('showSubNoteFooterButton'));
watch(lang, () => {
miLocalStorage.setItem('lang', lang.value as string);
@ -382,6 +384,7 @@ watch([
enableDataSaverMode,
enableAbsoluteTime,
enableMarkByDate,
showSubNoteFooterButton,
], async () => {
await reloadAsk();
});

View file

@ -391,6 +391,10 @@ export const defaultStore = markRaw(new Storage('base', {
where: 'device',
default: false,
},
showSubNoteFooterButton: {
where: 'device',
default: true,
},
// - Settings/Timeline
enableHomeTimeline: {