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:
commit
523bf79273
12 changed files with 258 additions and 4 deletions
|
@ -1114,6 +1114,9 @@ isPatron: "Calckey Patron"
|
|||
reactionPickerSkinTone: "Preferred emoji skin tone"
|
||||
enableServerMachineStats: "Enable server hardware statistics"
|
||||
enableIdenticonGeneration: "Enable Identicon generation"
|
||||
showPopup: "Notify users with popup"
|
||||
showWithSparkles: "Show with sparkles"
|
||||
youHaveUnreadAnnouncements: "You have unread announcements"
|
||||
|
||||
_sensitiveMediaDetection:
|
||||
description: "Reduces the effort of server moderation through automatically recognizing
|
||||
|
|
|
@ -980,6 +980,9 @@ preventAiLearningDescription: "投稿したノート、添付した画像など
|
|||
noGraze: "ブラウザの拡張機能「Graze for Mastodon」は、Calckeyの動作を妨げるため、無効にしてください。"
|
||||
enableServerMachineStats: "サーバーのマシン情報を公開する"
|
||||
enableIdenticonGeneration: "ユーザーごとのIdenticon生成を有効にする"
|
||||
showPopup: "ポップアップを表示してユーザーに知らせる"
|
||||
showWithSparkles: "タイトルをキラキラさせる"
|
||||
youHaveUnreadAnnouncements: "未読のお知らせがあります"
|
||||
|
||||
_sensitiveMediaDetection:
|
||||
description: "機械学習を使って自動でセンシティブなメディアを検出し、モデレーションに役立てられます。サーバーの負荷が少し増えます。"
|
||||
|
|
|
@ -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"`,
|
||||
);
|
||||
}
|
||||
}
|
|
@ -36,6 +36,16 @@ export class Announcement {
|
|||
})
|
||||
public imageUrl: string | null;
|
||||
|
||||
@Column("boolean", {
|
||||
default: false,
|
||||
})
|
||||
public showPopup: boolean;
|
||||
|
||||
@Column("boolean", {
|
||||
default: false,
|
||||
})
|
||||
public isGoodNews: boolean;
|
||||
|
||||
constructor(data: Partial<Announcement>) {
|
||||
if (data == null) return;
|
||||
|
||||
|
|
|
@ -47,6 +47,16 @@ export const meta = {
|
|||
optional: false,
|
||||
nullable: true,
|
||||
},
|
||||
showPopup: {
|
||||
type: "boolean",
|
||||
optional: true,
|
||||
nullable: false,
|
||||
},
|
||||
isGoodNews: {
|
||||
type: "boolean",
|
||||
optional: true,
|
||||
nullable: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
@ -57,6 +67,8 @@ export const paramDef = {
|
|||
title: { type: "string", minLength: 1 },
|
||||
text: { type: "string", minLength: 1 },
|
||||
imageUrl: { type: "string", nullable: true, minLength: 1 },
|
||||
showPopup: { type: "boolean" },
|
||||
isGoodNews: { type: "boolean" },
|
||||
},
|
||||
required: ["title", "text", "imageUrl"],
|
||||
} as const;
|
||||
|
@ -69,6 +81,8 @@ export default define(meta, paramDef, async (ps) => {
|
|||
title: ps.title,
|
||||
text: ps.text,
|
||||
imageUrl: ps.imageUrl,
|
||||
showPopup: ps.showPopup ?? false,
|
||||
isGoodNews: ps.isGoodNews ?? false,
|
||||
}).then((x) => Announcements.findOneByOrFail(x.identifiers[0]));
|
||||
|
||||
return Object.assign({}, announcement, {
|
||||
|
|
|
@ -57,6 +57,16 @@ export const meta = {
|
|||
optional: 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,
|
||||
imageUrl: announcement.imageUrl,
|
||||
reads: reads.get(announcement)!,
|
||||
showPopup: announcement.showPopup,
|
||||
isGoodNews: announcement.isGoodNews,
|
||||
}));
|
||||
});
|
||||
|
|
|
@ -24,6 +24,8 @@ export const paramDef = {
|
|||
title: { type: "string", minLength: 1 },
|
||||
text: { type: "string", minLength: 1 },
|
||||
imageUrl: { type: "string", nullable: true, minLength: 1 },
|
||||
showPopup: { type: "boolean" },
|
||||
isGoodNews: { type: "boolean" },
|
||||
},
|
||||
required: ["id", "title", "text", "imageUrl"],
|
||||
} as const;
|
||||
|
@ -38,5 +40,7 @@ export default define(meta, paramDef, async (ps, me) => {
|
|||
title: ps.title,
|
||||
text: ps.text,
|
||||
imageUrl: ps.imageUrl,
|
||||
showPopup: ps.showPopup ?? false,
|
||||
isGoodNews: ps.isGoodNews ?? false,
|
||||
});
|
||||
});
|
||||
|
|
|
@ -56,6 +56,16 @@ export const meta = {
|
|||
optional: true,
|
||||
nullable: false,
|
||||
},
|
||||
showPopup: {
|
||||
type: "boolean",
|
||||
optional: false,
|
||||
nullable: false,
|
||||
},
|
||||
isGoodNews: {
|
||||
type: "boolean",
|
||||
optional: false,
|
||||
nullable: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
74
packages/client/src/components/MkAnnouncement.vue
Normal file
74
packages/client/src/components/MkAnnouncement.vue
Normal 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>
|
53
packages/client/src/components/MkManyAnnouncements.vue
Normal file
53
packages/client/src/components/MkManyAnnouncements.vue
Normal 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>
|
|
@ -36,7 +36,7 @@ import { version, ui, lang, host } from "@/config";
|
|||
import { applyTheme } from "@/scripts/theme";
|
||||
import { isDeviceDarkmode } from "@/scripts/is-device-darkmode";
|
||||
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 * as sound from "@/scripts/sound";
|
||||
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: この処理は必ず↑のクライアント更新時処理より後に来ること(テーマ再構築のため)
|
||||
watch(
|
||||
defaultStore.reactiveState.darkMode,
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
:display-back-button="true"
|
||||
/></template>
|
||||
<MkSpacer :content-max="900">
|
||||
<div class="ztgjmzrw">
|
||||
<div :class="$style.root">
|
||||
<section
|
||||
v-for="announcement in announcements"
|
||||
class="_card _gap announcements"
|
||||
|
@ -22,6 +22,17 @@
|
|||
<MkInput v-model="announcement.imageUrl">
|
||||
<template #label>{{ i18n.ts.imageUrl }}</template>
|
||||
</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">
|
||||
{{
|
||||
i18n.t("nUsersRead", { n: announcement.reads })
|
||||
|
@ -57,6 +68,7 @@
|
|||
import {} from "vue";
|
||||
import MkButton from "@/components/MkButton.vue";
|
||||
import MkInput from "@/components/form/input.vue";
|
||||
import MkSwitch from "@/components/form/switch.vue";
|
||||
import MkTextarea from "@/components/form/textarea.vue";
|
||||
import * as os from "@/os";
|
||||
import { i18n } from "@/i18n";
|
||||
|
@ -74,6 +86,8 @@ function add() {
|
|||
title: "",
|
||||
text: "",
|
||||
imageUrl: null,
|
||||
showPopup: false,
|
||||
isGoodNews: false,
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -137,8 +151,8 @@ definePageMetadata({
|
|||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.ztgjmzrw {
|
||||
<style lang="scss" module>
|
||||
.root {
|
||||
margin: var(--margin);
|
||||
}
|
||||
</style>
|
||||
|
|
Loading…
Reference in a new issue