enhance: クリップのノート数を表示するように (#13686)

* enhance: クリップのノート数を表示できるように

* Update Changelog
This commit is contained in:
かっこかり 2024-04-14 21:30:24 +09:00 committed by GitHub
parent 8c5d9a6295
commit bba3097765
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 99 additions and 33 deletions

View file

@ -7,6 +7,7 @@
- Enhance: URLプレビューの有効化・無効化を設定できるように #13569 - Enhance: URLプレビューの有効化・無効化を設定できるように #13569
- Enhance: アンテナでBotによるートを除外できるように - Enhance: アンテナでBotによるートを除外できるように
(Cherry-picked from https://github.com/MisskeyIO/misskey/pull/545) (Cherry-picked from https://github.com/MisskeyIO/misskey/pull/545)
- Enhance: クリップのノート数を表示するように
- Fix: Play作成時に設定した公開範囲が機能していない問題を修正 - Fix: Play作成時に設定した公開範囲が機能していない問題を修正
### Client ### Client

4
locales/index.d.ts vendored
View file

@ -4944,6 +4944,10 @@ export interface Locale extends ILocale {
* *
*/ */
"keepOriginalFilenameDescription": string; "keepOriginalFilenameDescription": string;
/**
*
*/
"noDescription": string;
"_bubbleGame": { "_bubbleGame": {
/** /**
* *

View file

@ -1232,6 +1232,7 @@ launchApp: "アプリを起動"
useNativeUIForVideoAudioPlayer: "動画・音声の再生にブラウザのUIを使用する" useNativeUIForVideoAudioPlayer: "動画・音声の再生にブラウザのUIを使用する"
keepOriginalFilename: "オリジナルのファイル名を保持" keepOriginalFilename: "オリジナルのファイル名を保持"
keepOriginalFilenameDescription: "この設定をオフにすると、アップロード時にファイル名が自動でランダム文字列に置き換えられます。" keepOriginalFilenameDescription: "この設定をオフにすると、アップロード時にファイル名が自動でランダム文字列に置き換えられます。"
noDescription: "説明文はありません"
_bubbleGame: _bubbleGame:
howToPlay: "遊び方" howToPlay: "遊び方"

View file

@ -5,7 +5,7 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import type { ClipFavoritesRepository, ClipsRepository, MiUser } from '@/models/_.js'; import type { ClipNotesRepository, ClipFavoritesRepository, ClipsRepository, MiUser } from '@/models/_.js';
import { awaitAll } from '@/misc/prelude/await-all.js'; import { awaitAll } from '@/misc/prelude/await-all.js';
import type { Packed } from '@/misc/json-schema.js'; import type { Packed } from '@/misc/json-schema.js';
import type { } from '@/models/Blocking.js'; import type { } from '@/models/Blocking.js';
@ -20,6 +20,9 @@ export class ClipEntityService {
@Inject(DI.clipsRepository) @Inject(DI.clipsRepository)
private clipsRepository: ClipsRepository, private clipsRepository: ClipsRepository,
@Inject(DI.clipNotesRepository)
private clipNotesRepository: ClipNotesRepository,
@Inject(DI.clipFavoritesRepository) @Inject(DI.clipFavoritesRepository)
private clipFavoritesRepository: ClipFavoritesRepository, private clipFavoritesRepository: ClipFavoritesRepository,
@ -47,6 +50,7 @@ export class ClipEntityService {
isPublic: clip.isPublic, isPublic: clip.isPublic,
favoritedCount: await this.clipFavoritesRepository.countBy({ clipId: clip.id }), favoritedCount: await this.clipFavoritesRepository.countBy({ clipId: clip.id }),
isFavorited: meId ? await this.clipFavoritesRepository.exists({ where: { clipId: clip.id, userId: meId } }) : undefined, isFavorited: meId ? await this.clipFavoritesRepository.exists({ where: { clipId: clip.id, userId: meId } }) : undefined,
notesCount: meId ? await this.clipNotesRepository.countBy({ clipId: clip.id }) : undefined,
}); });
} }

View file

@ -52,5 +52,9 @@ export const packedClipSchema = {
type: 'boolean', type: 'boolean',
optional: true, nullable: false, optional: true, nullable: false,
}, },
notesCount: {
type: 'integer',
optional: true, nullable: false,
},
}, },
} as const; } as const;

View file

@ -4,37 +4,59 @@ SPDX-License-Identifier: AGPL-3.0-only
--> -->
<template> <template>
<div :class="$style.root" class="_panel"> <MkA :to="`/clips/${clip.id}`" :class="$style.link">
<div :class="$style.root" class="_panel _gaps_s">
<b>{{ clip.name }}</b> <b>{{ clip.name }}</b>
<div v-if="clip.description" :class="$style.description">{{ clip.description }}</div> <div :class="$style.description">
<div v-if="clip.description"><Mfm :text="clip.description" :plain="true" :nowrap="true"/></div>
<div v-if="clip.lastClippedAt">{{ i18n.ts.updatedAt }}: <MkTime :time="clip.lastClippedAt" mode="detail"/></div> <div v-if="clip.lastClippedAt">{{ i18n.ts.updatedAt }}: <MkTime :time="clip.lastClippedAt" mode="detail"/></div>
<div :class="$style.user"> <div v-if="clip.notesCount != null">{{ i18n.ts.notesCount }}: {{ number(clip.notesCount) }} / {{ $i?.policies.noteEachClipsLimit }} ({{ i18n.tsx.remainingN({ n: remaining }) }})</div>
</div>
<div :class="$style.divider"></div>
<div>
<MkAvatar :user="clip.user" :class="$style.userAvatar" indicator link preview/> <MkUserName :user="clip.user" :nowrap="false"/> <MkAvatar :user="clip.user" :class="$style.userAvatar" indicator link preview/> <MkUserName :user="clip.user" :nowrap="false"/>
</div> </div>
</div> </div>
</MkA>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import * as Misskey from 'misskey-js';
import { computed } from 'vue';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { $i } from '@/account.js';
import number from '@/filters/number.js';
defineProps<{ const props = defineProps<{
clip: any; clip: Misskey.entities.Clip;
}>(); }>();
const remaining = computed(() => {
return ($i?.policies && props.clip.notesCount != null) ? ($i.policies.noteEachClipsLimit - props.clip.notesCount) : i18n.ts.unknown;
});
</script> </script>
<style lang="scss" module> <style lang="scss" module>
.root { .link {
display: block; display: block;
&:hover {
text-decoration: none;
color: var(--accent);
}
}
.root {
padding: 16px; padding: 16px;
} }
.description { .divider {
padding: 8px 0; height: 1px;
background: var(--divider);
} }
.user { .description {
padding-top: 16px; font-size: 90%;
border-top: solid 0.5px var(--divider);
} }
.userAvatar { .userAvatar {

View file

@ -9,11 +9,16 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkSpacer :contentMax="800"> <MkSpacer :contentMax="800">
<div v-if="clip" class="_gaps"> <div v-if="clip" class="_gaps">
<div class="_panel"> <div class="_panel">
<div v-if="clip.description" :class="$style.description"> <div class="_gaps_s" :class="$style.description">
<div v-if="clip.description">
<Mfm :text="clip.description" :isNote="false"/> <Mfm :text="clip.description" :isNote="false"/>
</div> </div>
<div v-else>({{ i18n.ts.noDescription }})</div>
<div>
<MkButton v-if="favorited" v-tooltip="i18n.ts.unfavorite" asLike rounded primary @click="unfavorite()"><i class="ti ti-heart"></i><span v-if="clip.favoritedCount > 0" style="margin-left: 6px;">{{ clip.favoritedCount }}</span></MkButton> <MkButton v-if="favorited" v-tooltip="i18n.ts.unfavorite" asLike rounded primary @click="unfavorite()"><i class="ti ti-heart"></i><span v-if="clip.favoritedCount > 0" style="margin-left: 6px;">{{ clip.favoritedCount }}</span></MkButton>
<MkButton v-else v-tooltip="i18n.ts.favorite" asLike rounded @click="favorite()"><i class="ti ti-heart"></i><span v-if="clip.favoritedCount > 0" style="margin-left: 6px;">{{ clip.favoritedCount }}</span></MkButton> <MkButton v-else v-tooltip="i18n.ts.favorite" asLike rounded @click="favorite()"><i class="ti ti-heart"></i><span v-if="clip.favoritedCount > 0" style="margin-left: 6px;">{{ clip.favoritedCount }}</span></MkButton>
</div>
</div>
<div :class="$style.user"> <div :class="$style.user">
<MkAvatar :user="clip.user" :class="$style.avatar" indicator link preview/> <MkUserName :user="clip.user" :nowrap="false"/> <MkAvatar :user="clip.user" :class="$style.avatar" indicator link preview/> <MkUserName :user="clip.user" :nowrap="false"/>
</div> </div>

View file

@ -12,15 +12,11 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkButton primary rounded class="add" @click="create"><i class="ti ti-plus"></i> {{ i18n.ts.add }}</MkButton> <MkButton primary rounded class="add" @click="create"><i class="ti ti-plus"></i> {{ i18n.ts.add }}</MkButton>
<MkPagination v-slot="{ items }" ref="pagingComponent" :pagination="pagination" class="_gaps"> <MkPagination v-slot="{ items }" ref="pagingComponent" :pagination="pagination" class="_gaps">
<MkA v-for="item in items" :key="item.id" :to="`/clips/${item.id}`"> <MkClipPreview v-for="item in items" :key="item.id" :clip="item"/>
<MkClipPreview :clip="item"/>
</MkA>
</MkPagination> </MkPagination>
</div> </div>
<div v-else-if="tab === 'favorites'" key="favorites" class="_gaps"> <div v-else-if="tab === 'favorites'" key="favorites" class="_gaps">
<MkA v-for="item in favorites" :key="item.id" :to="`/clips/${item.id}`"> <MkClipPreview v-for="item in favorites" :key="item.id" :clip="item"/>
<MkClipPreview :clip="item"/>
</MkA>
</div> </div>
</MkHorizontalSwipe> </MkHorizontalSwipe>
</MkSpacer> </MkSpacer>

View file

@ -26,9 +26,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div v-if="clips && clips.length > 0" class="_margin"> <div v-if="clips && clips.length > 0" class="_margin">
<div style="font-weight: bold; padding: 12px;">{{ i18n.ts.clip }}</div> <div style="font-weight: bold; padding: 12px;">{{ i18n.ts.clip }}</div>
<div class="_gaps"> <div class="_gaps">
<MkA v-for="item in clips" :key="item.id" :to="`/clips/${item.id}`"> <MkClipPreview v-for="item in clips" :key="item.id" :clip="item"/>
<MkClipPreview :clip="item"/>
</MkA>
</div> </div>
</div> </div>
<div v-if="!showPrev" class="_buttons" :class="$style.loadPrev"> <div v-if="!showPrev" class="_buttons" :class="$style.loadPrev">

View file

@ -26,6 +26,14 @@ export async function getNoteClipMenu(props: {
isDeleted: Ref<boolean>; isDeleted: Ref<boolean>;
currentClip?: Misskey.entities.Clip; currentClip?: Misskey.entities.Clip;
}) { }) {
function getClipName(clip: Misskey.entities.Clip) {
if ($i && clip.userId === $i.id && clip.notesCount != null) {
return `${clip.name} (${clip.notesCount}/${$i.policies.noteEachClipsLimit})`;
} else {
return clip.name;
}
}
const isRenote = ( const isRenote = (
props.note.renote != null && props.note.renote != null &&
props.note.text == null && props.note.text == null &&
@ -37,7 +45,7 @@ export async function getNoteClipMenu(props: {
const clips = await clipsCache.fetch(); const clips = await clipsCache.fetch();
const menu: MenuItem[] = [...clips.map(clip => ({ const menu: MenuItem[] = [...clips.map(clip => ({
text: clip.name, text: getClipName(clip),
action: () => { action: () => {
claimAchievement('noteClipped1'); claimAchievement('noteClipped1');
os.promiseDialog( os.promiseDialog(
@ -50,7 +58,18 @@ export async function getNoteClipMenu(props: {
text: i18n.tsx.confirmToUnclipAlreadyClippedNote({ name: clip.name }), text: i18n.tsx.confirmToUnclipAlreadyClippedNote({ name: clip.name }),
}); });
if (!confirm.canceled) { if (!confirm.canceled) {
os.apiWithDialog('clips/remove-note', { clipId: clip.id, noteId: appearNote.id }); os.apiWithDialog('clips/remove-note', { clipId: clip.id, noteId: appearNote.id }).then(() => {
clipsCache.set(clips.map(c => {
if (c.id === clip.id) {
return {
...c,
notesCount: Math.max(0, ((c.notesCount ?? 0) - 1)),
};
} else {
return c;
}
}));
});
if (props.currentClip?.id === clip.id) props.isDeleted.value = true; if (props.currentClip?.id === clip.id) props.isDeleted.value = true;
} }
} else { } else {
@ -60,7 +79,18 @@ export async function getNoteClipMenu(props: {
}); });
} }
}, },
); ).then(() => {
clipsCache.set(clips.map(c => {
if (c.id === clip.id) {
return {
...c,
notesCount: (c.notesCount ?? 0) + 1,
};
} else {
return c;
}
}));
});
}, },
})), { type: 'divider' }, { })), { type: 'divider' }, {
icon: 'ti ti-plus', icon: 'ti ti-plus',

View file

@ -4460,6 +4460,7 @@ export type components = {
isPublic: boolean; isPublic: boolean;
favoritedCount: number; favoritedCount: number;
isFavorited?: boolean; isFavorited?: boolean;
notesCount?: number;
}; };
FederationInstance: { FederationInstance: {
/** Format: id */ /** Format: id */