mirror of
https://github.com/kokonect-link/cherrypick
synced 2024-11-27 14:28:53 +09:00
feat: サブノートにアクションボタンを表示するように
This commit is contained in:
parent
9f573903ce
commit
545b2d166c
@ -36,6 +36,7 @@
|
||||
- 모바일 환경에서 타임라인의 헤더 디자인을 변경할 수 있음
|
||||
- 「제어판 - 유저」에서 최근 온라인 유저를 정렬해서 볼 수 있음
|
||||
- 「이미 팔로우한 경우 알림 필드에 팔로우 버튼을 표시하지 않음」설정 사용 시, 팔로우 했다는 문구를 표시하도록
|
||||
- 서브 노트에 액션 버튼을 표시하는 기능
|
||||
|
||||
### Client
|
||||
- 리노트 전 확인 팝업을 띄움
|
||||
|
@ -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
2
locales/index.d.ts
vendored
@ -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;
|
||||
|
@ -1,5 +1,7 @@
|
||||
_lang_: "日本語"
|
||||
|
||||
showSubNoteFooterButton: "サブノートにアクションボタンを表示"
|
||||
showSubNoteFooterButtonDescription: "この設定を有効にすると、返信があるノートの親ノートにアクションボタンを表示します。"
|
||||
alreadyFollowed: "フォローしました!"
|
||||
enableMarkByDate: "ノート時刻を日付で表示"
|
||||
renoteConfirm: "Renoteしますか?"
|
||||
|
@ -1,5 +1,7 @@
|
||||
---
|
||||
_lang_: "한국어"
|
||||
showSubNoteFooterButton: "서브 노트에 액션 버튼 표시"
|
||||
showSubNoteFooterButtonDescription: "이 설정을 활성화하면 답글이 달린 노트의 상위 노트에 액션 버튼을 표시해요."
|
||||
alreadyFollowed: "팔로우 했어요!"
|
||||
enableMarkByDate: "노트 시간을 일자로 표시"
|
||||
renoteConfirm: "리노트 할까요?"
|
||||
|
@ -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';
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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,
|
||||
|
@ -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();
|
||||
});
|
||||
|
@ -391,6 +391,10 @@ export const defaultStore = markRaw(new Storage('base', {
|
||||
where: 'device',
|
||||
default: false,
|
||||
},
|
||||
showSubNoteFooterButton: {
|
||||
where: 'device',
|
||||
default: true,
|
||||
},
|
||||
|
||||
// - Settings/Timeline
|
||||
enableHomeTimeline: {
|
||||
|
Loading…
Reference in New Issue
Block a user