diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 5a7272b48..394577f37 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -437,6 +437,7 @@ signinWith: "{x}でログイン" signinFailed: "ログインできませんでした。ユーザー名とパスワードを確認してください。" tapSecurityKey: "セキュリティキーにタッチ" or: "もしくは" +language: "言語" uiLanguage: "UIの表示言語" groupInvited: "グループに招待されました" aboutX: "{x}について" @@ -701,6 +702,13 @@ inUse: "使用中" editCode: "コードを編集" apply: "適用" receiveAnnouncementFromInstance: "インスタンスからのお知らせを受け取る" +emailNotification: "メール通知" + +_email: + _follow: + title: "フォローされました" + _receiveFollowRequest: + title: "フォローリクエストを受け取りました" _plugin: install: "プラグインのインストール" diff --git a/migration/1613155914446-emailNotificationTypes.ts b/migration/1613155914446-emailNotificationTypes.ts new file mode 100644 index 000000000..d6908aecf --- /dev/null +++ b/migration/1613155914446-emailNotificationTypes.ts @@ -0,0 +1,14 @@ +import {MigrationInterface, QueryRunner} from "typeorm"; + +export class emailNotificationTypes1613155914446 implements MigrationInterface { + name = 'emailNotificationTypes1613155914446' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "user_profile" ADD "emailNotificationTypes" jsonb NOT NULL DEFAULT '["follow","receiveFollowRequest","groupInvited"]'`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "user_profile" DROP COLUMN "emailNotificationTypes"`); + } + +} diff --git a/migration/1613181457597-user-lang.ts b/migration/1613181457597-user-lang.ts new file mode 100644 index 000000000..ac1fc88c9 --- /dev/null +++ b/migration/1613181457597-user-lang.ts @@ -0,0 +1,14 @@ +import {MigrationInterface, QueryRunner} from "typeorm"; + +export class userLang1613181457597 implements MigrationInterface { + name = 'userLang1613181457597' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "user_profile" ADD "lang" character varying(32)`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "user_profile" DROP COLUMN "lang"`); + } + +} diff --git a/src/client/components/form/input.vue b/src/client/components/form/input.vue index c8c22e95c..f0aa6b053 100644 --- a/src/client/components/form/input.vue +++ b/src/client/components/form/input.vue @@ -1,63 +1,50 @@ @@ -112,11 +144,6 @@ export default defineComponent({ } } - > .save { - margin: 6px 0 0 0; - font-size: 0.8em; - } - &.tall { > .input { > textarea { diff --git a/src/client/i18n.ts b/src/client/i18n.ts index fbc10a0ba..6c29ef153 100644 --- a/src/client/i18n.ts +++ b/src/client/i18n.ts @@ -1,6 +1,6 @@ import { markRaw } from 'vue'; import { locale } from '@/config'; -import { I18n } from '@/scripts/i18n'; +import { I18n } from '../misc/i18n'; export const i18n = markRaw(new I18n(locale)); diff --git a/src/client/pages/settings/email-notification.vue b/src/client/pages/settings/email-notification.vue new file mode 100644 index 000000000..de2cfd391 --- /dev/null +++ b/src/client/pages/settings/email-notification.vue @@ -0,0 +1,90 @@ + + + diff --git a/src/client/pages/settings/email.vue b/src/client/pages/settings/email.vue index 5ccb79a41..e334e23cb 100644 --- a/src/client/pages/settings/email.vue +++ b/src/client/pages/settings/email.vue @@ -9,6 +9,11 @@ + + + {{ $ts.emailNotification }} + + {{ $ts.receiveAnnouncementFromInstance }} @@ -43,7 +48,7 @@ export default defineComponent({ title: this.$ts.email, icon: faEnvelope }, - faCog, faExclamationTriangle, faCheck + faCog, faExclamationTriangle, faCheck, faBell } }, diff --git a/src/client/pages/settings/index.vue b/src/client/pages/settings/index.vue index df53eb513..54bf56930 100644 --- a/src/client/pages/settings/index.vue +++ b/src/client/pages/settings/index.vue @@ -99,6 +99,7 @@ export default defineComponent({ case 'general': return defineAsyncComponent(() => import('./general.vue')); case 'email': return defineAsyncComponent(() => import('./email.vue')); case 'email/address': return defineAsyncComponent(() => import('./email-address.vue')); + case 'email/notification': return defineAsyncComponent(() => import('./email-notification.vue')); case 'theme': return defineAsyncComponent(() => import('./theme.vue')); case 'theme/install': return defineAsyncComponent(() => import('./theme.install.vue')); case 'theme/manage': return defineAsyncComponent(() => import('./theme.manage.vue')); diff --git a/src/client/pages/settings/profile.vue b/src/client/pages/settings/profile.vue index 8c2c63e31..e9bffc3b0 100644 --- a/src/client/pages/settings/profile.vue +++ b/src/client/pages/settings/profile.vue @@ -8,25 +8,30 @@ {{ $ts._profile.changeBanner }} - + {{ $ts._profile.name }} - + {{ $ts._profile.description }} - + {{ $ts.location }} - + {{ $ts.birthday }} + + + + + {{ $ts._profile.metadataEdit }} @@ -37,8 +42,6 @@ {{ $ts.flagAsBot }} {{ $ts.alwaysMarkSensitive }} - - {{ $ts.save }} @@ -50,10 +53,10 @@ import FormButton from '@/components/form/button.vue'; import FormInput from '@/components/form/input.vue'; import FormTextarea from '@/components/form/textarea.vue'; import FormSwitch from '@/components/form/switch.vue'; -import FormTuple from '@/components/form/tuple.vue'; +import FormSelect from '@/components/form/select.vue'; import FormBase from '@/components/form/base.vue'; import FormGroup from '@/components/form/group.vue'; -import { host } from '@/config'; +import { host, langs } from '@/config'; import { selectFile } from '@/scripts/select-file'; import * as os from '@/os'; @@ -63,7 +66,7 @@ export default defineComponent({ FormInput, FormTextarea, FormSwitch, - FormTuple, + FormSelect, FormBase, FormGroup, }, @@ -77,9 +80,11 @@ export default defineComponent({ icon: faUser }, host, + langs, name: null, description: null, birthday: null, + lang: null, location: null, fieldName0: null, fieldValue0: null, @@ -104,6 +109,7 @@ export default defineComponent({ this.description = this.$i.description; this.location = this.$i.location; this.birthday = this.$i.birthday; + this.lang = this.$i.lang; this.avatarId = this.$i.avatarId; this.bannerId = this.$i.bannerId; this.isBot = this.$i.isBot; @@ -118,6 +124,15 @@ export default defineComponent({ this.fieldValue2 = this.$i.fields[2] ? this.$i.fields[2].value : null; this.fieldName3 = this.$i.fields[3] ? this.$i.fields[3].name : null; this.fieldValue3 = this.$i.fields[3] ? this.$i.fields[3].value : null; + + this.$watch('name', this.save); + this.$watch('description', this.save); + this.$watch('location', this.save); + this.$watch('birthday', this.save); + this.$watch('lang', this.save); + this.$watch('isBot', this.save); + this.$watch('isCat', this.save); + this.$watch('alwaysMarkNsfw', this.save); }, mounted() { @@ -214,14 +229,15 @@ export default defineComponent({ }); }, - save(notify) { + save() { this.saving = true; - os.api('i/update', { + os.apiWithDialog('i/update', { name: this.name || null, description: this.description || null, location: this.location || null, birthday: this.birthday || null, + lang: this.lang || null, isBot: !!this.isBot, isCat: !!this.isCat, alwaysMarkNsfw: !!this.alwaysMarkNsfw, @@ -231,16 +247,8 @@ export default defineComponent({ this.$i.avatarUrl = i.avatarUrl; this.$i.bannerId = i.bannerId; this.$i.bannerUrl = i.bannerUrl; - - if (notify) { - os.success(); - } }).catch(err => { this.saving = false; - os.dialog({ - type: 'error', - text: err.id - }); }); }, } diff --git a/src/client/sw/sw.ts b/src/client/sw/sw.ts index a18d305ea..c93fe4926 100644 --- a/src/client/sw/sw.ts +++ b/src/client/sw/sw.ts @@ -5,7 +5,7 @@ declare var self: ServiceWorkerGlobalScope; import { get, set } from 'idb-keyval'; import composeNotification from '@/sw/compose-notification'; -import { I18n } from '@/scripts/i18n'; +import { I18n } from '../../misc/i18n'; //#region Variables const version = _VERSION_; diff --git a/src/client/scripts/i18n.ts b/src/misc/i18n.ts similarity index 66% rename from src/client/scripts/i18n.ts rename to src/misc/i18n.ts index d535e236b..4fa398763 100644 --- a/src/client/scripts/i18n.ts +++ b/src/misc/i18n.ts @@ -1,14 +1,9 @@ -// Notice: Service Workerでも使用します export class I18n> { public locale: T; constructor(locale: T) { this.locale = locale; - if (_DEV_) { - console.log('i18n', this.locale); - } - //#region BIND this.t = this.t.bind(this); //#endregion @@ -20,12 +15,6 @@ export class I18n> { try { let str = key.split('.').reduce((o, i) => o[i], this.locale) as string; - if (_DEV_) { - if (!str.includes('{')) { - console.warn(`i18n: '${key}' has no any arg. so ref prop directly instead of call this method.`); - } - } - if (args) { for (const [k, v] of Object.entries(args)) { str = str.replace(`{${k}}`, v); @@ -33,11 +22,7 @@ export class I18n> { } return str; } catch (e) { - if (_DEV_) { - console.warn(`missing localization '${key}'`); - return `⚠'${key}'⚠`; - } - + console.warn(`missing localization '${key}'`); return key; } } diff --git a/src/models/entities/user-profile.ts b/src/models/entities/user-profile.ts index 4fab52868..3a9043fac 100644 --- a/src/models/entities/user-profile.ts +++ b/src/models/entities/user-profile.ts @@ -4,6 +4,8 @@ import { User } from './user'; import { Page } from './page'; import { notificationTypes } from '../../types'; +// TODO: このテーブルで管理している情報すべてレジストリで管理するようにしても良いかも +// ただ、「emailVerified が true なユーザーを find する」のようなクエリは書けなくなるからウーン @Entity() export class UserProfile { @PrimaryColumn(id()) @@ -41,6 +43,11 @@ export class UserProfile { value: string; }[]; + @Column('varchar', { + length: 32, nullable: true, + }) + public lang: string | null; + @Column('varchar', { length: 512, nullable: true, comment: 'Remote URL of the user.' @@ -63,6 +70,11 @@ export class UserProfile { }) public emailVerified: boolean; + @Column('jsonb', { + default: ['follow', 'receiveFollowRequest', 'groupInvited'] + }) + public emailNotificationTypes: string[]; + @Column('varchar', { length: 128, nullable: true, }) diff --git a/src/models/repositories/user.ts b/src/models/repositories/user.ts index 88861224a..a3453b1aa 100644 --- a/src/models/repositories/user.ts +++ b/src/models/repositories/user.ts @@ -213,6 +213,7 @@ export class UserRepository extends Repository { description: profile!.description, location: profile!.location, birthday: profile!.birthday, + lang: profile!.lang, fields: profile!.fields, followersCount: user.followersCount, followingCount: user.followingCount, @@ -258,7 +259,8 @@ export class UserRepository extends Repository { hasPendingReceivedFollowRequest: this.getHasPendingReceivedFollowRequest(user.id), integrations: profile!.integrations, mutedWords: profile!.mutedWords, - mutingNotificationTypes: profile?.mutingNotificationTypes, + mutingNotificationTypes: profile!.mutingNotificationTypes, + emailNotificationTypes: profile!.emailNotificationTypes, } : {}), ...(opts.includeSecrets ? { diff --git a/src/server/api/endpoints/admin/send-email.ts b/src/server/api/endpoints/admin/send-email.ts index 9af931ad9..c0e77e162 100644 --- a/src/server/api/endpoints/admin/send-email.ts +++ b/src/server/api/endpoints/admin/send-email.ts @@ -22,5 +22,5 @@ export const meta = { }; export default define(meta, async (ps) => { - await sendEmail(ps.to, ps.subject, ps.text); + await sendEmail(ps.to, ps.subject, ps.text, ps.text); }); diff --git a/src/server/api/endpoints/i/update-email.ts b/src/server/api/endpoints/i/update-email.ts index 20d970332..d3d7bace7 100644 --- a/src/server/api/endpoints/i/update-email.ts +++ b/src/server/api/endpoints/i/update-email.ts @@ -72,7 +72,9 @@ export default define(meta, async (ps, user) => { const link = `${config.url}/verify-email/${code}`; - sendEmail(ps.email, 'Email verification', `To verify email, please click this link: ${link}`); + sendEmail(ps.email, 'Email verification', + `To verify email, please click this link:
${link}`, + `To verify email, please click this link: ${link}`); } return iObj; diff --git a/src/server/api/endpoints/i/update.ts b/src/server/api/endpoints/i/update.ts index e4c0e8cec..bf1796924 100644 --- a/src/server/api/endpoints/i/update.ts +++ b/src/server/api/endpoints/i/update.ts @@ -161,6 +161,10 @@ export const meta = { mutingNotificationTypes: { validator: $.optional.arr($.str.or(notificationTypes as unknown as string[])) }, + + emailNotificationTypes: { + validator: $.optional.arr($.str) + }, }, errors: { @@ -206,7 +210,7 @@ export default define(meta, async (ps, user, token) => { if (ps.name !== undefined) updates.name = ps.name; if (ps.description !== undefined) profileUpdates.description = ps.description; - //if (ps.lang !== undefined) updates.lang = ps.lang; + if (ps.lang !== undefined) profileUpdates.lang = ps.lang; if (ps.location !== undefined) profileUpdates.location = ps.location; if (ps.birthday !== undefined) profileUpdates.birthday = ps.birthday; if (ps.avatarId !== undefined) updates.avatarId = ps.avatarId; @@ -226,6 +230,7 @@ export default define(meta, async (ps, user, token) => { if (typeof ps.injectFeaturedNote === 'boolean') profileUpdates.injectFeaturedNote = ps.injectFeaturedNote; if (typeof ps.receiveAnnouncementEmail === 'boolean') profileUpdates.receiveAnnouncementEmail = ps.receiveAnnouncementEmail; if (typeof ps.alwaysMarkNsfw === 'boolean') profileUpdates.alwaysMarkNsfw = ps.alwaysMarkNsfw; + if (ps.emailNotificationTypes !== undefined) profileUpdates.emailNotificationTypes = ps.emailNotificationTypes; if (ps.avatarId) { const avatar = await DriveFiles.findOne(ps.avatarId); diff --git a/src/services/create-notification.ts b/src/services/create-notification.ts index 5dddaa572..6cd116040 100644 --- a/src/services/create-notification.ts +++ b/src/services/create-notification.ts @@ -4,6 +4,7 @@ import { Notifications, Mutings, UserProfiles } from '../models'; import { genId } from '../misc/gen-id'; import { User } from '../models/entities/user'; import { Notification } from '../models/entities/notification'; +import { sendEmailNotification } from './send-email-notification'; export async function createNotification( notifieeId: User['id'], @@ -38,20 +39,22 @@ export async function createNotification( setTimeout(async () => { const fresh = await Notifications.findOne(notification.id); if (fresh == null) return; // 既に削除されているかもしれない - if (!fresh.isRead) { - //#region ただしミュートしているユーザーからの通知なら無視 - const mutings = await Mutings.find({ - muterId: notifieeId - }); - if (data.notifierId && mutings.map(m => m.muteeId).includes(data.notifierId)) { - return; - } - //#endregion + if (fresh.isRead) return; - publishMainStream(notifieeId, 'unreadNotification', packed); - - pushSw(notifieeId, 'notification', packed); + //#region ただしミュートしているユーザーからの通知なら無視 + const mutings = await Mutings.find({ + muterId: notifieeId + }); + if (data.notifierId && mutings.map(m => m.muteeId).includes(data.notifierId)) { + return; } + //#endregion + + publishMainStream(notifieeId, 'unreadNotification', packed); + + pushSw(notifieeId, 'notification', packed); + if (type === 'follow') sendEmailNotification.follow(notifieeId, data); + if (type === 'receiveFollowRequest') sendEmailNotification.receiveFollowRequest(notifieeId, data); }, 2000); return notification; diff --git a/src/services/send-email-notification.ts b/src/services/send-email-notification.ts new file mode 100644 index 000000000..7579d5b67 --- /dev/null +++ b/src/services/send-email-notification.ts @@ -0,0 +1,28 @@ +import { UserProfiles } from '../models'; +import { User } from '../models/entities/user'; +import { sendEmail } from './send-email'; +import * as locales from '../../locales/'; +import { I18n } from '../misc/i18n'; + +// TODO: locale ファイルをクライアント用とサーバー用で分けたい + +async function follow(userId: User['id'], args: {}) { + const userProfile = await UserProfiles.findOneOrFail({ userId: userId }); + if (!userProfile.email || !userProfile.emailNotificationTypes.includes('follow')) return; + const locale = locales[userProfile.lang || 'ja-JP']; + const i18n = new I18n(locale); + sendEmail(userProfile.email, i18n.t('_email._follow.title'), 'test', 'test'); +} + +async function receiveFollowRequest(userId: User['id'], args: {}) { + const userProfile = await UserProfiles.findOneOrFail({ userId: userId }); + if (!userProfile.email || !userProfile.emailNotificationTypes.includes('receiveFollowRequest')) return; + const locale = locales[userProfile.lang || 'ja-JP']; + const i18n = new I18n(locale); + sendEmail(userProfile.email, i18n.t('_email._receiveFollowRequest.title'), 'test', 'test'); +} + +export const sendEmailNotification = { + follow, + receiveFollowRequest, +}; diff --git a/src/services/send-email.ts b/src/services/send-email.ts index 151228c7e..c716b3671 100644 --- a/src/services/send-email.ts +++ b/src/services/send-email.ts @@ -5,7 +5,7 @@ import config from '../config'; export const logger = new Logger('email'); -export async function sendEmail(to: string, subject: string, text: string) { +export async function sendEmail(to: string, subject: string, html: string, text: string) { const meta = await fetchMeta(true); const iconUrl = `${config.url}/assets/mi-white.png`; @@ -44,6 +44,9 @@ export async function sendEmail(to: string, subject: string, text: string) { body { padding: 16px; + margin: 0; + font-family: sans-serif; + font-size: 14px; } a { @@ -67,6 +70,7 @@ export async function sendEmail(to: string, subject: string, text: string) { main > header > img { max-width: 128px; max-height: 28px; + vertical-align: bottom; } main > article { padding: 32px; @@ -97,7 +101,7 @@ export async function sendEmail(to: string, subject: string, text: string) {

${ subject }

-
${ text }
+
${ html }