feat: 個人宛てお知らせ機能 (#107)

* feat: 個人宛てお知らせ機能

* Remove unused import

* Update packages/backend/src/server/api/endpoints/admin/announcements/create.ts

Co-authored-by: riku6460 <17585784+riku6460@users.noreply.github.com>

* Update packages/frontend/src/pages/announcements.vue

Co-authored-by: riku6460 <17585784+riku6460@users.noreply.github.com>

* Restore breakline

* 一般向けAPIにはuserオブジェクトを提供しない

* fix

* Fix

* Update packages/misskey-js/src/entities.ts

Co-authored-by: riku6460 <17585784+riku6460@users.noreply.github.com>

* Fix

* Update misskey-js.api.md

* Fix lint

* 他のテーブルに合わせて character varying(32) にした

* count クエリを1つにまとめた

* user を pack するようにした

* いろいろ修正

* 個人宛てのお知らせの表示を改善

* Update misskey-js.api.md

* Merge migration scripts

* Fix

* Update packages/backend/migration/1688647797135-userannouncement.js

Co-authored-by: riku6460 <17585784+riku6460@users.noreply.github.com>

* Update packages/backend/src/models/entities/Announcement.ts

Co-authored-by: まっちゃとーにゅ <17376330+u1-liquid@users.noreply.github.com>

* Fix

* Update migration script

---------

Co-authored-by: CyberRex <hspwinx86@gmail.com>
Co-authored-by: まっちゃとーにゅ <17376330+u1-liquid@users.noreply.github.com>
This commit is contained in:
riku6460
2023-07-24 13:08:39 +09:00
committed by GitHub
parent 6d3f64f606
commit 7b1efd6b97
18 changed files with 374 additions and 18 deletions

View File

@@ -2,7 +2,7 @@ import { computed, createApp, watch, markRaw, version as vueVersion, defineAsync
import { common } from './common';
import { version, ui, lang, updateLocale } from '@/config';
import { i18n, updateI18n } from '@/i18n';
import { confirm, alert, post, popup, toast } from '@/os';
import { confirm, alert, post, popup, toast, api } from '@/os';
import { useStream } from '@/stream';
import * as sound from '@/scripts/sound';
import { $i, refreshAccount, login, updateAccount, signout } from '@/account';
@@ -246,6 +246,11 @@ export async function mainBoot() {
main.on('myTokenRegenerated', () => {
signout();
});
const unreadUserAnnouncementsList = await api('announcements', { privateOnly: true, withUnreads: true });
if (unreadUserAnnouncementsList.length > 0) {
unreadUserAnnouncementsList.forEach((v) => popup(defineAsyncComponent(() => import('@/components/MkUserAnnouncementModal.vue')), { title: v.title, text: v.text, closeDuration: v.closeDuration, announcementId: v.id }, {}, 'closed'));
}
}
// shortcut

View File

@@ -0,0 +1,60 @@
<template>
<MkModal ref="modal" :zPriority="'middle'" @click="$refs.modal.close()" @closed="$emit('closed')">
<div :class="$style.root">
<div :class="$style.title">{{ i18n.ts.newUserAnnouncementAvailable }}</div>
<MkButton :class="$style.gotIt" primary full :disabled="gotItDisabled" @click="gotIt">{{ i18n.ts.gotIt }}</MkButton>
</div>
</MkModal>
</template>
<script setup lang="ts">
import { shallowRef } from 'vue';
import MkModal from '@/components/MkModal.vue';
import MkButton from '@/components/MkButton.vue';
import { i18n } from '@/i18n';
import { useRouter } from '@/router';
import { api } from '@/os';
const router = useRouter();
const modal = shallowRef<InstanceType<typeof MkModal>>();
const props = defineProps<{
title: string;
text: string;
announcementId: string;
}>();
async function gotIt() {
await api('i/read-announcement', { announcementId: props.announcementId });
}
function jumpTo() {
modal.value.close();
router.push('/announcements');
}
</script>
<style lang="scss" module>
.root {
margin: auto;
position: relative;
padding: 32px;
min-width: 320px;
max-width: 480px;
box-sizing: border-box;
text-align: center;
background: var(--panel);
border-radius: var(--radius);
}
.title {
font-weight: bold;
}
.version {
margin: 1em 0;
}
.gotIt {
margin: 8px 0 0 0;
}
</style>

View File

@@ -0,0 +1,90 @@
<template>
<MkModal ref="modal" :zPriority="'middle'" @click="closeModal" @closed="$emit('closed')">
<div :class="$style.root">
<div :class="$style.title"><Mfm :text="props.title"/></div>
<div :class="$style.text">
<Mfm :text="props.text"/>
</div>
<MkButton :class="$style.gotIt" primary full :disabled="gotItDisabled" @click="gotIt">{{ i18n.ts.gotIt }}<span v-if="secVisible">({{ sec }})</span></MkButton>
</div>
</MkModal>
</template>
<script setup lang="ts">
import { onMounted, ref, shallowRef } from 'vue';
import MkModal from '@/components/MkModal.vue';
import MkButton from '@/components/MkButton.vue';
import { i18n } from '@/i18n';
import { api } from '@/os';
const modal = shallowRef<InstanceType<typeof MkModal>>();
const gotItDisabled = ref(true);
const secVisible = ref(true);
const props = defineProps<{
title: string;
text: string;
announcementId: string | null;
closeDuration: number;
}>();
const sec = ref(props.closeDuration);
async function gotIt() {
gotItDisabled.value = true;
if (props.announcementId) {
await api('i/read-announcement', { announcementId: props.announcementId });
}
modal.value.close();
}
function closeModal() {
if (sec.value === 0) {
modal.value.close();
}
}
onMounted(() => {
if (sec.value > 0 ) {
const waitTimer = setInterval(() => {
if (sec.value === 0) {
clearInterval(waitTimer);
gotItDisabled.value = false;
secVisible.value = false;
} else {
gotItDisabled.value = true;
}
sec.value = sec.value - 1;
}, 1000);
} else {
gotItDisabled.value = false;
secVisible.value = false;
}
});
</script>
<style lang="scss" module>
.root {
margin: auto;
position: relative;
padding: 32px;
min-width: 320px;
max-width: 480px;
box-sizing: border-box;
text-align: center;
background: var(--panel);
border-radius: var(--radius);
}
.title {
font-weight: bold;
}
.text {
margin: 1em 0;
}
.gotIt {
margin: 8px 0 0 0;
}
</style>

View File

@@ -3,10 +3,26 @@
<template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template>
<MkSpacer :contentMax="900">
<div class="_gaps_m">
<MkFolder>
<template #label>{{ i18n.ts.options }}</template>
<MkFolder>
<template #label>{{ i18n.ts.specifyUser }}</template>
<template v-if="user" #suffix>@{{ user.username }}</template>
<div style="text-align: center;" class="_gaps">
<div v-if="user">@{{ user.username }}</div>
<div>
<MkButton v-if="user == null" primary rounded inline @click="selectUserFilter">{{ i18n.ts.selectUser }}</MkButton>
<MkButton v-else danger rounded inline @click="user = null">{{ i18n.ts.remove }}</MkButton>
</div>
</div>
</MkFolder>
</MkFolder>
<section v-for="announcement in announcements" class="">
<div class="_panel _gaps_m" style="padding: 24px;">
<MkInput v-model="announcement.title">
<template #label>{{ i18n.ts.title }}</template>
<MkInput ref="announceTitleEl" v-model="announcement.title" :large="false">
<template #label>{{ i18n.ts.title }}&nbsp;<button v-tooltip="i18n.ts.emoji" :class="['_button']" @click="insertEmoji"><i class="ti ti-mood-happy"></i></button></template>
</MkInput>
<MkTextarea v-model="announcement.text">
<template #label>{{ i18n.ts.text }}</template>
@@ -14,7 +30,13 @@
<MkInput v-model="announcement.imageUrl">
<template #label>{{ i18n.ts.imageUrl }}</template>
</MkInput>
<MkInput v-model="announcement.closeDuration" type="number">
<template #label>{{ i18n.ts.dialogCloseDuration }}</template>
<template #suffix>{{ i18n.ts._time.second }}</template>
</MkInput>
<p v-if="announcement.reads">{{ i18n.t('nUsersRead', { n: announcement.reads }) }}</p>
<MkUserCardMini v-if="announcement.userId" :user="announcement.user" @click="editUser(announcement)"></MkUserCardMini>
<MkButton v-else class="button" inline primary @click="editUser(announcement)">{{ i18n.ts.specifyUser }}</MkButton>
<div class="buttons _buttons">
<MkButton class="button" inline primary @click="save(announcement)"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton>
<MkButton class="button" inline danger @click="remove(announcement)"><i class="ti ti-trash"></i> {{ i18n.ts.remove }}</MkButton>
@@ -27,17 +49,40 @@
</template>
<script lang="ts" setup>
import { } from 'vue';
import { ref, watch } from 'vue';
import { UserLite } from 'misskey-js/built/entities';
import XHeader from './_header_.vue';
import MkButton from '@/components/MkButton.vue';
import MkInput from '@/components/MkInput.vue';
import MkTextarea from '@/components/MkTextarea.vue';
import MkFolder from '@/components/MkFolder.vue';
import MkUserCardMini from '@/components/MkUserCardMini.vue';
import * as os from '@/os';
import { i18n } from '@/i18n';
import { definePageMetadata } from '@/scripts/page-metadata';
let announcements: any[] = $ref([]);
const user = ref<UserLite>(null);
const announceTitleEl = $shallowRef<HTMLInputElement | null>(null);
function selectUserFilter() {
os.selectUser().then(_user => {
user.value = _user;
});
}
function editUser(an) {
os.selectUser().then(_user => {
an.userId = _user.id;
an.user = _user;
});
}
async function insertEmoji(ev: MouseEvent) {
os.openEmojiPicker(ev.currentTarget ?? ev.target, {}, announceTitleEl);
}
os.api('admin/announcements/list').then(announcementResponse => {
announcements = announcementResponse;
});
@@ -48,6 +93,9 @@ function add() {
title: '',
text: '',
imageUrl: null,
userId: null,
user: null,
closeDuration: 10,
});
}
@@ -92,11 +140,13 @@ function save(announcement) {
}
function refresh() {
os.api('admin/announcements/list').then(announcementResponse => {
os.api('admin/announcements/list', { userId: user.value?.id }).then(announcementResponse => {
announcements = announcementResponse;
});
}
watch(user, refresh);
refresh();
const headerActions = $computed(() => [{

View File

@@ -3,8 +3,8 @@
<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
<MkSpacer :contentMax="800">
<MkPagination v-slot="{items}" :pagination="pagination" class="ruryvtyk _gaps_m">
<section v-for="(announcement, i) in items" :key="announcement.id" class="announcement _panel">
<div class="header"><span v-if="$i && !announcement.isRead">🆕 </span>{{ announcement.title }}</div>
<section v-for="(announcement, i) in items" :key="announcement.id" :class="{ announcement: true, _panel: true, private: announcement.isPrivate }">
<div class="header"><span v-if="$i && !announcement.isRead"><span class="ti ti-speakerphone"></span></span><Mfm :text="announcement.title"/></div>
<div class="content">
<Mfm :text="announcement.text"/>
<img v-if="announcement.imageUrl" :src="announcement.imageUrl"/>
@@ -53,6 +53,11 @@ definePageMetadata({
<style lang="scss" scoped>
.ruryvtyk {
> .private {
border-left: 4px solid olivedrab;
}
> .announcement {
padding: 16px;
@@ -74,4 +79,16 @@ definePageMetadata({
}
}
}
@keyframes fade {
0% {
opacity: 0;
}
50% {
opacity: 1;
}
100% {
opacity: 0;
}
}
</style>