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"
|
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
|
||||||
|
|
|
@ -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: "機械学習を使って自動でセンシティブなメディアを検出し、モデレーションに役立てられます。サーバーの負荷が少し増えます。"
|
||||||
|
|
|
@ -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;
|
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;
|
||||||
|
|
||||||
|
|
|
@ -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, {
|
||||||
|
|
|
@ -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,
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
|
@ -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,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -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,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
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 { 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,
|
||||||
|
|
|
@ -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>
|
||||||
|
|
Loading…
Reference in a new issue