Merge pull request '[PR]: feat: notify announcements with popups' (#10441) from naskya/calckey:feat/announcement-popup into develop

Reviewed-on: https://codeberg.org/calckey/calckey/pulls/10441
This commit is contained in:
Kainoa Kanter 2023-07-08 22:41:54 +00:00
commit 523bf79273
12 changed files with 258 additions and 4 deletions

View File

@ -1114,6 +1114,9 @@ isPatron: "Calckey Patron"
reactionPickerSkinTone: "Preferred emoji skin tone" reactionPickerSkinTone: "Preferred emoji skin tone"
enableServerMachineStats: "Enable server hardware statistics" enableServerMachineStats: "Enable server hardware statistics"
enableIdenticonGeneration: "Enable Identicon generation" enableIdenticonGeneration: "Enable Identicon generation"
showPopup: "Notify users with popup"
showWithSparkles: "Show with sparkles"
youHaveUnreadAnnouncements: "You have unread announcements"
_sensitiveMediaDetection: _sensitiveMediaDetection:
description: "Reduces the effort of server moderation through automatically recognizing description: "Reduces the effort of server moderation through automatically recognizing

View File

@ -980,6 +980,9 @@ preventAiLearningDescription: "投稿したノート、添付した画像など
noGraze: "ブラウザの拡張機能「Graze for Mastodon」は、Calckeyの動作を妨げるため、無効にしてください。" noGraze: "ブラウザの拡張機能「Graze for Mastodon」は、Calckeyの動作を妨げるため、無効にしてください。"
enableServerMachineStats: "サーバーのマシン情報を公開する" enableServerMachineStats: "サーバーのマシン情報を公開する"
enableIdenticonGeneration: "ユーザーごとのIdenticon生成を有効にする" enableIdenticonGeneration: "ユーザーごとのIdenticon生成を有効にする"
showPopup: "ポップアップを表示してユーザーに知らせる"
showWithSparkles: "タイトルをキラキラさせる"
youHaveUnreadAnnouncements: "未読のお知らせがあります"
_sensitiveMediaDetection: _sensitiveMediaDetection:
description: "機械学習を使って自動でセンシティブなメディアを検出し、モデレーションに役立てられます。サーバーの負荷が少し増えます。" description: "機械学習を使って自動でセンシティブなメディアを検出し、モデレーションに役立てられます。サーバーの負荷が少し増えます。"

View File

@ -0,0 +1,21 @@
export class AnnouncementPopup1688845537045 {
name = "AnnouncementPopup1688845537045";
async up(queryRunner) {
await queryRunner.query(
`ALTER TABLE "announcement" ADD "showPopup" boolean NOT NULL DEFAULT false`,
);
await queryRunner.query(
`ALTER TABLE "announcement" ADD "isGoodNews" boolean NOT NULL DEFAULT false`,
);
}
async down(queryRunner) {
await queryRunner.query(
`ALTER TABLE "announcement" DROP COLUMN "isGoodNews"`,
);
await queryRunner.query(
`ALTER TABLE "announcement" DROP COLUMN "showPopup"`,
);
}
}

View File

@ -36,6 +36,16 @@ export class Announcement {
}) })
public imageUrl: string | null; public imageUrl: string | null;
@Column("boolean", {
default: false,
})
public showPopup: boolean;
@Column("boolean", {
default: false,
})
public isGoodNews: boolean;
constructor(data: Partial<Announcement>) { constructor(data: Partial<Announcement>) {
if (data == null) return; if (data == null) return;

View File

@ -47,6 +47,16 @@ export const meta = {
optional: false, optional: false,
nullable: true, nullable: true,
}, },
showPopup: {
type: "boolean",
optional: true,
nullable: false,
},
isGoodNews: {
type: "boolean",
optional: true,
nullable: false,
},
}, },
}, },
} as const; } as const;
@ -57,6 +67,8 @@ export const paramDef = {
title: { type: "string", minLength: 1 }, title: { type: "string", minLength: 1 },
text: { type: "string", minLength: 1 }, text: { type: "string", minLength: 1 },
imageUrl: { type: "string", nullable: true, minLength: 1 }, imageUrl: { type: "string", nullable: true, minLength: 1 },
showPopup: { type: "boolean" },
isGoodNews: { type: "boolean" },
}, },
required: ["title", "text", "imageUrl"], required: ["title", "text", "imageUrl"],
} as const; } as const;
@ -69,6 +81,8 @@ export default define(meta, paramDef, async (ps) => {
title: ps.title, title: ps.title,
text: ps.text, text: ps.text,
imageUrl: ps.imageUrl, imageUrl: ps.imageUrl,
showPopup: ps.showPopup ?? false,
isGoodNews: ps.isGoodNews ?? false,
}).then((x) => Announcements.findOneByOrFail(x.identifiers[0])); }).then((x) => Announcements.findOneByOrFail(x.identifiers[0]));
return Object.assign({}, announcement, { return Object.assign({}, announcement, {

View File

@ -57,6 +57,16 @@ export const meta = {
optional: false, optional: false,
nullable: false, nullable: false,
}, },
showPopup: {
type: "boolean",
optional: true,
nullable: false,
},
isGoodNews: {
type: "boolean",
optional: true,
nullable: false,
},
}, },
}, },
}, },
@ -100,5 +110,7 @@ export default define(meta, paramDef, async (ps) => {
text: announcement.text, text: announcement.text,
imageUrl: announcement.imageUrl, imageUrl: announcement.imageUrl,
reads: reads.get(announcement)!, reads: reads.get(announcement)!,
showPopup: announcement.showPopup,
isGoodNews: announcement.isGoodNews,
})); }));
}); });

View File

@ -24,6 +24,8 @@ export const paramDef = {
title: { type: "string", minLength: 1 }, title: { type: "string", minLength: 1 },
text: { type: "string", minLength: 1 }, text: { type: "string", minLength: 1 },
imageUrl: { type: "string", nullable: true, minLength: 1 }, imageUrl: { type: "string", nullable: true, minLength: 1 },
showPopup: { type: "boolean" },
isGoodNews: { type: "boolean" },
}, },
required: ["id", "title", "text", "imageUrl"], required: ["id", "title", "text", "imageUrl"],
} as const; } as const;
@ -38,5 +40,7 @@ export default define(meta, paramDef, async (ps, me) => {
title: ps.title, title: ps.title,
text: ps.text, text: ps.text,
imageUrl: ps.imageUrl, imageUrl: ps.imageUrl,
showPopup: ps.showPopup ?? false,
isGoodNews: ps.isGoodNews ?? false,
}); });
}); });

View File

@ -56,6 +56,16 @@ export const meta = {
optional: true, optional: true,
nullable: false, nullable: false,
}, },
showPopup: {
type: "boolean",
optional: false,
nullable: false,
},
isGoodNews: {
type: "boolean",
optional: false,
nullable: false,
},
}, },
}, },
}, },

View File

@ -0,0 +1,74 @@
<template>
<MkModal ref="modal" :z-priority="'middle'" @closed="$emit('closed')">
<div :class="$style.root">
<div :class="$style.title">
<MkSparkle v-if="isGoodNews">{{ title }}</MkSparkle>
<p v-else>{{ title }}</p>
</div>
<Mfm :text="text" />
<img
v-if="imageUrl != null"
:key="imageUrl"
:src="imageUrl"
alt="attached image"
/>
<MkButton :class="$style.gotIt" primary full @click="gotIt()">{{
i18n.ts.gotIt
}}</MkButton>
</div>
</MkModal>
</template>
<script lang="ts" setup>
import { shallowRef } from "vue";
import MkModal from "@/components/MkModal.vue";
import MkSparkle from "@/components/MkSparkle.vue";
import MkButton from "@/components/MkButton.vue";
import { i18n } from "@/i18n";
import * as os from "@/os";
const props = defineProps<{
announcement: Announcement;
}>();
const { id, text, title, imageUrl, isGoodNews } = props.announcement;
const modal = shallowRef<InstanceType<typeof MkModal>>();
const gotIt = () => {
modal.value.close();
os.api("i/read-announcement", { announcementId: id });
};
</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);
> img {
border-radius: 10px;
max-height: 100%;
max-width: 100%;
}
}
.title {
font-weight: bold;
> p {
margin: 0;
}
}
.gotIt {
margin: 8px 0 0 0;
}
</style>

View File

@ -0,0 +1,53 @@
<template>
<MkModal ref="modal" :z-priority="'middle'" @closed="$emit('closed')">
<div :class="$style.root">
<p :class="$style.title">
{{ i18n.ts.youHaveUnreadAnnouncements }}
</p>
<MkButton
:class="$style.gotIt"
primary
full
@click="checkAnnouncements()"
>{{ i18n.ts.gotIt }}</MkButton
>
</div>
</MkModal>
</template>
<script lang="ts" setup>
import { shallowRef } from "vue";
import MkModal from "@/components/MkModal.vue";
import MkButton from "@/components/MkButton.vue";
import { i18n } from "@/i18n";
import * as os from "@/os";
const modal = shallowRef<InstanceType<typeof MkModal>>();
const checkAnnouncements = () => {
modal.value.close();
location.href = "/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;
margin: 0;
}
.gotIt {
margin: 8px 0 0 0;
}
</style>

View File

@ -36,7 +36,7 @@ import { version, ui, lang, host } from "@/config";
import { applyTheme } from "@/scripts/theme"; import { applyTheme } from "@/scripts/theme";
import { isDeviceDarkmode } from "@/scripts/is-device-darkmode"; import { isDeviceDarkmode } from "@/scripts/is-device-darkmode";
import { i18n } from "@/i18n"; import { i18n } from "@/i18n";
import { confirm, alert, post, popup, toast } from "@/os"; import { confirm, alert, post, popup, toast, api } from "@/os";
import { stream } from "@/stream"; import { stream } from "@/stream";
import * as sound from "@/scripts/sound"; import * as sound from "@/scripts/sound";
import { $i, refreshAccount, login, updateAccount, signout } from "@/account"; import { $i, refreshAccount, login, updateAccount, signout } from "@/account";
@ -272,6 +272,42 @@ function checkForSplash() {
} }
} }
if (
$i &&
defaultStore.state.tutorial === -1 &&
!["/announcements", "/announcements/"].includes(window.location.pathname)
) {
api("announcements", { withUnreads: true, limit: 10 })
.then((announcements) => {
const unreadAnnouncements = announcements.filter((item) => {
return !item.isRead;
});
if (unreadAnnouncements.length > 3) {
popup(
defineAsyncComponent(
() => import("@/components/MkManyAnnouncements.vue"),
),
{},
{},
"closed",
);
} else {
unreadAnnouncements.forEach((item) => {
if (item.showPopup)
popup(
defineAsyncComponent(
() => import("@/components/MkAnnouncement.vue"),
),
{ announcement: item },
{},
"closed",
);
});
}
})
.catch((err) => console.log(err));
}
// NOTE: この処理は必ず↑のクライアント更新時処理より後に来ること(テーマ再構築のため) // NOTE: この処理は必ず↑のクライアント更新時処理より後に来ること(テーマ再構築のため)
watch( watch(
defaultStore.reactiveState.darkMode, defaultStore.reactiveState.darkMode,

View File

@ -7,7 +7,7 @@
:display-back-button="true" :display-back-button="true"
/></template> /></template>
<MkSpacer :content-max="900"> <MkSpacer :content-max="900">
<div class="ztgjmzrw"> <div :class="$style.root">
<section <section
v-for="announcement in announcements" v-for="announcement in announcements"
class="_card _gap announcements" class="_card _gap announcements"
@ -22,6 +22,17 @@
<MkInput v-model="announcement.imageUrl"> <MkInput v-model="announcement.imageUrl">
<template #label>{{ i18n.ts.imageUrl }}</template> <template #label>{{ i18n.ts.imageUrl }}</template>
</MkInput> </MkInput>
<MkSwitch
v-model="announcement.showPopup"
class="_formBlock"
>{{ i18n.ts.showPopup }}</MkSwitch
>
<MkSwitch
v-if="announcement.showPopup"
v-model="announcement.isGoodNews"
class="_formBlock"
>{{ i18n.ts.showWithSparkles }}</MkSwitch
>
<p v-if="announcement.reads"> <p v-if="announcement.reads">
{{ {{
i18n.t("nUsersRead", { n: announcement.reads }) i18n.t("nUsersRead", { n: announcement.reads })
@ -57,6 +68,7 @@
import {} from "vue"; import {} from "vue";
import MkButton from "@/components/MkButton.vue"; import MkButton from "@/components/MkButton.vue";
import MkInput from "@/components/form/input.vue"; import MkInput from "@/components/form/input.vue";
import MkSwitch from "@/components/form/switch.vue";
import MkTextarea from "@/components/form/textarea.vue"; import MkTextarea from "@/components/form/textarea.vue";
import * as os from "@/os"; import * as os from "@/os";
import { i18n } from "@/i18n"; import { i18n } from "@/i18n";
@ -74,6 +86,8 @@ function add() {
title: "", title: "",
text: "", text: "",
imageUrl: null, imageUrl: null,
showPopup: false,
isGoodNews: false,
}); });
} }
@ -137,8 +151,8 @@ definePageMetadata({
}); });
</script> </script>
<style lang="scss" scoped> <style lang="scss" module>
.ztgjmzrw { .root {
margin: var(--margin); margin: var(--margin);
} }
</style> </style>