wip: email notification

This commit is contained in:
syuilo 2021-02-13 12:28:26 +09:00
parent 2d3248504b
commit ebadd7fd3f
20 changed files with 355 additions and 159 deletions

View file

@ -437,6 +437,7 @@ signinWith: "{x}でログイン"
signinFailed: "ログインできませんでした。ユーザー名とパスワードを確認してください。" signinFailed: "ログインできませんでした。ユーザー名とパスワードを確認してください。"
tapSecurityKey: "セキュリティキーにタッチ" tapSecurityKey: "セキュリティキーにタッチ"
or: "もしくは" or: "もしくは"
language: "言語"
uiLanguage: "UIの表示言語" uiLanguage: "UIの表示言語"
groupInvited: "グループに招待されました" groupInvited: "グループに招待されました"
aboutX: "{x}について" aboutX: "{x}について"
@ -701,6 +702,13 @@ inUse: "使用中"
editCode: "コードを編集" editCode: "コードを編集"
apply: "適用" apply: "適用"
receiveAnnouncementFromInstance: "インスタンスからのお知らせを受け取る" receiveAnnouncementFromInstance: "インスタンスからのお知らせを受け取る"
emailNotification: "メール通知"
_email:
_follow:
title: "フォローされました"
_receiveFollowRequest:
title: "フォローリクエストを受け取りました"
_plugin: _plugin:
install: "プラグインのインストール" install: "プラグインのインストール"

View file

@ -0,0 +1,14 @@
import {MigrationInterface, QueryRunner} from "typeorm";
export class emailNotificationTypes1613155914446 implements MigrationInterface {
name = 'emailNotificationTypes1613155914446'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "user_profile" ADD "emailNotificationTypes" jsonb NOT NULL DEFAULT '["follow","receiveFollowRequest","groupInvited"]'`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "user_profile" DROP COLUMN "emailNotificationTypes"`);
}
}

View file

@ -0,0 +1,14 @@
import {MigrationInterface, QueryRunner} from "typeorm";
export class userLang1613181457597 implements MigrationInterface {
name = 'userLang1613181457597'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "user_profile" ADD "lang" character varying(32)`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "user_profile" DROP COLUMN "lang"`);
}
}

View file

@ -1,63 +1,50 @@
<template> <template>
<div class="ztzhwixg _formItem" :class="{ inline, disabled }"> <FormGroup class="_formItem">
<div class="_formLabel"><slot></slot></div> <template #label><slot></slot></template>
<div class="icon" ref="icon"><slot name="icon"></slot></div> <div class="ztzhwixg _formItem" :class="{ inline, disabled }">
<div class="input _formPanel"> <div class="icon" ref="icon"><slot name="icon"></slot></div>
<div class="prefix" ref="prefixEl"><slot name="prefix"></slot></div> <div class="input _formPanel">
<input v-if="debounce" ref="inputEl" <div class="prefix" ref="prefixEl"><slot name="prefix"></slot></div>
v-debounce="500" <input ref="inputEl"
:type="type" :type="type"
v-model.lazy="v" v-model="v"
:disabled="disabled" :disabled="disabled"
:required="required" :required="required"
:readonly="readonly" :readonly="readonly"
:placeholder="placeholder" :placeholder="placeholder"
:pattern="pattern" :pattern="pattern"
:autocomplete="autocomplete" :autocomplete="autocomplete"
:spellcheck="spellcheck" :spellcheck="spellcheck"
:step="step" :step="step"
@focus="focused = true" @focus="focused = true"
@blur="focused = false" @blur="focused = false"
@keydown="onKeydown($event)" @keydown="onKeydown($event)"
@input="onInput" @input="onInput"
:list="id" :list="id"
> >
<input v-else ref="inputEl" <datalist :id="id" v-if="datalist">
:type="type" <option v-for="data in datalist" :value="data"/>
v-model="v" </datalist>
:disabled="disabled" <div class="suffix" ref="suffixEl"><slot name="suffix"></slot></div>
:required="required" </div>
:readonly="readonly"
:placeholder="placeholder"
:pattern="pattern"
:autocomplete="autocomplete"
:spellcheck="spellcheck"
:step="step"
@focus="focused = true"
@blur="focused = false"
@keydown="onKeydown($event)"
@input="onInput"
:list="id"
>
<datalist :id="id" v-if="datalist">
<option v-for="data in datalist" :value="data"/>
</datalist>
<div class="suffix" ref="suffixEl"><slot name="suffix"></slot></div>
</div> </div>
<button class="save _textButton" v-if="save && changed" @click="() => { changed = false; save(); }">{{ $ts.save }}</button> <template #caption><slot name="desc"></slot></template>
<div class="_formCaption"><slot name="desc"></slot></div>
</div> <FormButton v-if="manualSave && changed" @click="updated" primary><Fa :icon="faSave"/> {{ $ts.save }}</FormButton>
</FormGroup>
</template> </template>
<script lang="ts"> <script lang="ts">
import { defineComponent, onMounted, onUnmounted, nextTick, ref, watch, computed, toRefs } from 'vue'; import { defineComponent, onMounted, onUnmounted, nextTick, ref, watch, computed, toRefs } from 'vue';
import debounce from 'v-debounce'; import { faExclamationCircle, faSave } from '@fortawesome/free-solid-svg-icons';
import { faExclamationCircle } from '@fortawesome/free-solid-svg-icons';
import './form.scss'; import './form.scss';
import FormButton from './button.vue';
import FormGroup from './group.vue';
export default defineComponent({ export default defineComponent({
directives: { components: {
debounce FormGroup,
FormButton,
}, },
props: { props: {
value: { value: {
@ -101,9 +88,6 @@ export default defineComponent({
step: { step: {
required: false required: false
}, },
debounce: {
required: false
},
datalist: { datalist: {
type: Array, type: Array,
required: false, required: false,
@ -113,9 +97,10 @@ export default defineComponent({
required: false, required: false,
default: false default: false
}, },
save: { manualSave: {
type: Function, type: Boolean,
required: false, required: false,
default: false
}, },
}, },
emits: ['change', 'keydown', 'enter'], emits: ['change', 'keydown', 'enter'],
@ -144,15 +129,22 @@ export default defineComponent({
} }
}; };
const updated = () => {
changed.value = false;
if (type?.value === 'number') {
context.emit('update:value', parseFloat(v.value));
} else {
context.emit('update:value', v.value);
}
};
watch(value, newValue => { watch(value, newValue => {
v.value = newValue; v.value = newValue;
}); });
watch(v, newValue => { watch(v, newValue => {
if (type?.value === 'number') { if (!props.manualSave) {
context.emit('update:value', parseFloat(newValue)); updated();
} else {
context.emit('update:value', newValue);
} }
invalid.value = inputEl.value.validity.badInput; invalid.value = inputEl.value.validity.badInput;
@ -198,7 +190,8 @@ export default defineComponent({
focus, focus,
onInput, onInput,
onKeydown, onKeydown,
faExclamationCircle, updated,
faExclamationCircle, faSave,
}; };
}, },
}); });
@ -285,11 +278,6 @@ export default defineComponent({
} }
} }
> .save {
margin: 6px 0 0 0;
font-size: 0.8em;
}
&.inline { &.inline {
display: inline-block; display: inline-block;
margin: 0; margin: 0;

View file

@ -1,29 +1,39 @@
<template> <template>
<div class="rivhosbp _formItem" :class="{ tall, pre }"> <FormGroup class="_formItem">
<div class="_formLabel"><slot></slot></div> <template #label><slot></slot></template>
<div class="input _formPanel"> <div class="rivhosbp _formItem" :class="{ tall, pre }">
<textarea ref="input" :class="{ code, _monospace: code }" <div class="input _formPanel">
:value="value" <textarea ref="input" :class="{ code, _monospace: code }"
:required="required" v-model="v"
:readonly="readonly" :required="required"
:pattern="pattern" :readonly="readonly"
:autocomplete="autocomplete" :pattern="pattern"
:spellcheck="!code" :autocomplete="autocomplete"
@input="onInput" :spellcheck="!code"
@focus="focused = true" @input="onInput"
@blur="focused = false" @focus="focused = true"
></textarea> @blur="focused = false"
></textarea>
</div>
</div> </div>
<button class="save _textButton" v-if="save && changed" @click="() => { changed = false; save(); }">{{ $ts.save }}</button> <template #caption><slot name="desc"></slot></template>
<div class="_formCaption"><slot name="desc"></slot></div>
</div> <FormButton v-if="manualSave && changed" @click="updated" primary><Fa :icon="faSave"/> {{ $ts.save }}</FormButton>
</FormGroup>
</template> </template>
<script lang="ts"> <script lang="ts">
import { defineComponent } from 'vue'; import { defineComponent, ref, toRefs, watch } from 'vue';
import { faSave } from '@fortawesome/free-solid-svg-icons';
import './form.scss'; import './form.scss';
import FormButton from './button.vue';
import FormGroup from './group.vue';
export default defineComponent({ export default defineComponent({
components: {
FormGroup,
FormButton,
},
props: { props: {
value: { value: {
required: false required: false
@ -58,24 +68,46 @@ export default defineComponent({
required: false, required: false,
default: false default: false
}, },
save: { manualSave: {
type: Function, type: Boolean,
required: false, required: false,
default: false
}, },
}, },
data() { setup(props, context) {
const { value } = toRefs(props);
const v = ref(value.value);
const changed = ref(false);
const inputEl = ref(null);
const focus = () => inputEl.value.focus();
const onInput = (ev) => {
changed.value = true;
context.emit('change', ev);
};
const updated = () => {
changed.value = false;
context.emit('update:value', v.value);
};
watch(value, newValue => {
v.value = newValue;
});
watch(v, newValue => {
if (!props.manualSave) {
updated();
}
});
return { return {
changed: false, v,
} updated,
}, changed,
methods: { focus,
focus() { onInput,
this.$refs.input.focus(); faSave,
}, };
onInput(ev) {
this.changed = true;
this.$emit('update:value', ev.target.value);
}
} }
}); });
</script> </script>
@ -112,11 +144,6 @@ export default defineComponent({
} }
} }
> .save {
margin: 6px 0 0 0;
font-size: 0.8em;
}
&.tall { &.tall {
> .input { > .input {
> textarea { > textarea {

View file

@ -1,6 +1,6 @@
import { markRaw } from 'vue'; import { markRaw } from 'vue';
import { locale } from '@/config'; import { locale } from '@/config';
import { I18n } from '@/scripts/i18n'; import { I18n } from '../misc/i18n';
export const i18n = markRaw(new I18n(locale)); export const i18n = markRaw(new I18n(locale));

View file

@ -0,0 +1,90 @@
<template>
<FormBase>
<FormGroup>
<FormSwitch v-model:value="mention">
{{ $ts._notification._types.mention }}
</FormSwitch>
<FormSwitch v-model:value="reply">
{{ $ts._notification._types.reply }}
</FormSwitch>
<FormSwitch v-model:value="quote">
{{ $ts._notification._types.quote }}
</FormSwitch>
<FormSwitch v-model:value="follow">
{{ $ts._notification._types.follow }}
</FormSwitch>
<FormSwitch v-model:value="receiveFollowRequest">
{{ $ts._notification._types.receiveFollowRequest }}
</FormSwitch>
<FormSwitch v-model:value="groupInvited">
{{ $ts._notification._types.groupInvited }}
</FormSwitch>
</FormGroup>
</FormBase>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import { faCog } from '@fortawesome/free-solid-svg-icons';
import { faBell, faEnvelope } from '@fortawesome/free-regular-svg-icons';
import FormButton from '@/components/form/button.vue';
import FormSwitch from '@/components/form/switch.vue';
import FormBase from '@/components/form/base.vue';
import FormGroup from '@/components/form/group.vue';
import * as os from '@/os';
export default defineComponent({
components: {
FormBase,
FormSwitch,
FormButton,
FormGroup,
},
emits: ['info'],
data() {
return {
INFO: {
title: this.$ts.emailNotification,
icon: faEnvelope
},
mention: this.$i.emailNotificationTypes.includes('mention'),
reply: this.$i.emailNotificationTypes.includes('reply'),
quote: this.$i.emailNotificationTypes.includes('quote'),
follow: this.$i.emailNotificationTypes.includes('follow'),
receiveFollowRequest: this.$i.emailNotificationTypes.includes('receiveFollowRequest'),
groupInvited: this.$i.emailNotificationTypes.includes('groupInvited'),
}
},
created() {
this.$watch('mention', this.save);
this.$watch('reply', this.save);
this.$watch('quote', this.save);
this.$watch('follow', this.save);
this.$watch('receiveFollowRequest', this.save);
this.$watch('groupInvited', this.save);
},
mounted() {
this.$emit('info', this.INFO);
},
methods: {
save() {
os.api('i/update', {
emailNotificationTypes: [
...[this.mention ? 'mention' : null],
...[this.reply ? 'reply' : null],
...[this.quote ? 'quote' : null],
...[this.follow ? 'follow' : null],
...[this.receiveFollowRequest ? 'receiveFollowRequest' : null],
...[this.groupInvited ? 'groupInvited' : null],
].filter(x => x != null)
});
}
}
});
</script>

View file

@ -9,6 +9,11 @@
</FormLink> </FormLink>
</FormGroup> </FormGroup>
<FormLink to="/settings/email/notification">
<template #icon><Fa :icon="faBell"/></template>
{{ $ts.emailNotification }}
</FormLink>
<FormSwitch :value="$i.receiveAnnouncementEmail" @update:value="onChangeReceiveAnnouncementEmail"> <FormSwitch :value="$i.receiveAnnouncementEmail" @update:value="onChangeReceiveAnnouncementEmail">
{{ $ts.receiveAnnouncementFromInstance }} {{ $ts.receiveAnnouncementFromInstance }}
</FormSwitch> </FormSwitch>
@ -43,7 +48,7 @@ export default defineComponent({
title: this.$ts.email, title: this.$ts.email,
icon: faEnvelope icon: faEnvelope
}, },
faCog, faExclamationTriangle, faCheck faCog, faExclamationTriangle, faCheck, faBell
} }
}, },

View file

@ -99,6 +99,7 @@ export default defineComponent({
case 'general': return defineAsyncComponent(() => import('./general.vue')); case 'general': return defineAsyncComponent(() => import('./general.vue'));
case 'email': return defineAsyncComponent(() => import('./email.vue')); case 'email': return defineAsyncComponent(() => import('./email.vue'));
case 'email/address': return defineAsyncComponent(() => import('./email-address.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': return defineAsyncComponent(() => import('./theme.vue'));
case 'theme/install': return defineAsyncComponent(() => import('./theme.install.vue')); case 'theme/install': return defineAsyncComponent(() => import('./theme.install.vue'));
case 'theme/manage': return defineAsyncComponent(() => import('./theme.manage.vue')); case 'theme/manage': return defineAsyncComponent(() => import('./theme.manage.vue'));

View file

@ -8,25 +8,30 @@
<FormButton @click="changeBanner" primary>{{ $ts._profile.changeBanner }}</FormButton> <FormButton @click="changeBanner" primary>{{ $ts._profile.changeBanner }}</FormButton>
</FormGroup> </FormGroup>
<FormInput v-model:value="name" :max="30"> <FormInput v-model:value="name" :max="30" manual-save>
<span>{{ $ts._profile.name }}</span> <span>{{ $ts._profile.name }}</span>
</FormInput> </FormInput>
<FormTextarea v-model:value="description" :max="500"> <FormTextarea v-model:value="description" :max="500" tall manual-save>
<span>{{ $ts._profile.description }}</span> <span>{{ $ts._profile.description }}</span>
<template #desc>{{ $ts._profile.youCanIncludeHashtags }}</template> <template #desc>{{ $ts._profile.youCanIncludeHashtags }}</template>
</FormTextarea> </FormTextarea>
<FormInput v-model:value="location"> <FormInput v-model:value="location" manual-save>
<span>{{ $ts.location }}</span> <span>{{ $ts.location }}</span>
<template #prefix><Fa :icon="faMapMarkerAlt"/></template> <template #prefix><Fa :icon="faMapMarkerAlt"/></template>
</FormInput> </FormInput>
<FormInput v-model:value="birthday" type="date"> <FormInput v-model:value="birthday" type="date" manual-save>
<span>{{ $ts.birthday }}</span> <span>{{ $ts.birthday }}</span>
<template #prefix><Fa :icon="faBirthdayCake"/></template> <template #prefix><Fa :icon="faBirthdayCake"/></template>
</FormInput> </FormInput>
<FormSelect v-model:value="lang">
<template #label>{{ $ts.language }}</template>
<option v-for="x in langs" :value="x[0]" :key="x[0]">{{ x[1] }}</option>
</FormSelect>
<FormGroup> <FormGroup>
<FormButton @click="editMetadata" primary>{{ $ts._profile.metadataEdit }}</FormButton> <FormButton @click="editMetadata" primary>{{ $ts._profile.metadataEdit }}</FormButton>
<template #caption>{{ $ts._profile.metadataDescription }}</template> <template #caption>{{ $ts._profile.metadataDescription }}</template>
@ -37,8 +42,6 @@
<FormSwitch v-model:value="isBot">{{ $ts.flagAsBot }}<template #desc>{{ $ts.flagAsBotDescription }}</template></FormSwitch> <FormSwitch v-model:value="isBot">{{ $ts.flagAsBot }}<template #desc>{{ $ts.flagAsBotDescription }}</template></FormSwitch>
<FormSwitch v-model:value="alwaysMarkNsfw">{{ $ts.alwaysMarkSensitive }}</FormSwitch> <FormSwitch v-model:value="alwaysMarkNsfw">{{ $ts.alwaysMarkSensitive }}</FormSwitch>
<FormButton @click="save(true)" primary><Fa :icon="faSave"/> {{ $ts.save }}</FormButton>
</FormBase> </FormBase>
</template> </template>
@ -50,10 +53,10 @@ import FormButton from '@/components/form/button.vue';
import FormInput from '@/components/form/input.vue'; import FormInput from '@/components/form/input.vue';
import FormTextarea from '@/components/form/textarea.vue'; import FormTextarea from '@/components/form/textarea.vue';
import FormSwitch from '@/components/form/switch.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 FormBase from '@/components/form/base.vue';
import FormGroup from '@/components/form/group.vue'; import FormGroup from '@/components/form/group.vue';
import { host } from '@/config'; import { host, langs } from '@/config';
import { selectFile } from '@/scripts/select-file'; import { selectFile } from '@/scripts/select-file';
import * as os from '@/os'; import * as os from '@/os';
@ -63,7 +66,7 @@ export default defineComponent({
FormInput, FormInput,
FormTextarea, FormTextarea,
FormSwitch, FormSwitch,
FormTuple, FormSelect,
FormBase, FormBase,
FormGroup, FormGroup,
}, },
@ -77,9 +80,11 @@ export default defineComponent({
icon: faUser icon: faUser
}, },
host, host,
langs,
name: null, name: null,
description: null, description: null,
birthday: null, birthday: null,
lang: null,
location: null, location: null,
fieldName0: null, fieldName0: null,
fieldValue0: null, fieldValue0: null,
@ -104,6 +109,7 @@ export default defineComponent({
this.description = this.$i.description; this.description = this.$i.description;
this.location = this.$i.location; this.location = this.$i.location;
this.birthday = this.$i.birthday; this.birthday = this.$i.birthday;
this.lang = this.$i.lang;
this.avatarId = this.$i.avatarId; this.avatarId = this.$i.avatarId;
this.bannerId = this.$i.bannerId; this.bannerId = this.$i.bannerId;
this.isBot = this.$i.isBot; 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.fieldValue2 = this.$i.fields[2] ? this.$i.fields[2].value : null;
this.fieldName3 = this.$i.fields[3] ? this.$i.fields[3].name : 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.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() { mounted() {
@ -214,14 +229,15 @@ export default defineComponent({
}); });
}, },
save(notify) { save() {
this.saving = true; this.saving = true;
os.api('i/update', { os.apiWithDialog('i/update', {
name: this.name || null, name: this.name || null,
description: this.description || null, description: this.description || null,
location: this.location || null, location: this.location || null,
birthday: this.birthday || null, birthday: this.birthday || null,
lang: this.lang || null,
isBot: !!this.isBot, isBot: !!this.isBot,
isCat: !!this.isCat, isCat: !!this.isCat,
alwaysMarkNsfw: !!this.alwaysMarkNsfw, alwaysMarkNsfw: !!this.alwaysMarkNsfw,
@ -231,16 +247,8 @@ export default defineComponent({
this.$i.avatarUrl = i.avatarUrl; this.$i.avatarUrl = i.avatarUrl;
this.$i.bannerId = i.bannerId; this.$i.bannerId = i.bannerId;
this.$i.bannerUrl = i.bannerUrl; this.$i.bannerUrl = i.bannerUrl;
if (notify) {
os.success();
}
}).catch(err => { }).catch(err => {
this.saving = false; this.saving = false;
os.dialog({
type: 'error',
text: err.id
});
}); });
}, },
} }

View file

@ -5,7 +5,7 @@ declare var self: ServiceWorkerGlobalScope;
import { get, set } from 'idb-keyval'; import { get, set } from 'idb-keyval';
import composeNotification from '@/sw/compose-notification'; import composeNotification from '@/sw/compose-notification';
import { I18n } from '@/scripts/i18n'; import { I18n } from '../../misc/i18n';
//#region Variables //#region Variables
const version = _VERSION_; const version = _VERSION_;

View file

@ -1,14 +1,9 @@
// Notice: Service Workerでも使用します
export class I18n<T extends Record<string, any>> { export class I18n<T extends Record<string, any>> {
public locale: T; public locale: T;
constructor(locale: T) { constructor(locale: T) {
this.locale = locale; this.locale = locale;
if (_DEV_) {
console.log('i18n', this.locale);
}
//#region BIND //#region BIND
this.t = this.t.bind(this); this.t = this.t.bind(this);
//#endregion //#endregion
@ -20,12 +15,6 @@ export class I18n<T extends Record<string, any>> {
try { try {
let str = key.split('.').reduce((o, i) => o[i], this.locale) as string; 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) { if (args) {
for (const [k, v] of Object.entries(args)) { for (const [k, v] of Object.entries(args)) {
str = str.replace(`{${k}}`, v); str = str.replace(`{${k}}`, v);
@ -33,11 +22,7 @@ export class I18n<T extends Record<string, any>> {
} }
return str; return str;
} catch (e) { } catch (e) {
if (_DEV_) { console.warn(`missing localization '${key}'`);
console.warn(`missing localization '${key}'`);
return `⚠'${key}'⚠`;
}
return key; return key;
} }
} }

View file

@ -4,6 +4,8 @@ import { User } from './user';
import { Page } from './page'; import { Page } from './page';
import { notificationTypes } from '../../types'; import { notificationTypes } from '../../types';
// TODO: このテーブルで管理している情報すべてレジストリで管理するようにしても良いかも
// ただ、「emailVerified が true なユーザーを find する」のようなクエリは書けなくなるからウーン
@Entity() @Entity()
export class UserProfile { export class UserProfile {
@PrimaryColumn(id()) @PrimaryColumn(id())
@ -41,6 +43,11 @@ export class UserProfile {
value: string; value: string;
}[]; }[];
@Column('varchar', {
length: 32, nullable: true,
})
public lang: string | null;
@Column('varchar', { @Column('varchar', {
length: 512, nullable: true, length: 512, nullable: true,
comment: 'Remote URL of the user.' comment: 'Remote URL of the user.'
@ -63,6 +70,11 @@ export class UserProfile {
}) })
public emailVerified: boolean; public emailVerified: boolean;
@Column('jsonb', {
default: ['follow', 'receiveFollowRequest', 'groupInvited']
})
public emailNotificationTypes: string[];
@Column('varchar', { @Column('varchar', {
length: 128, nullable: true, length: 128, nullable: true,
}) })

View file

@ -213,6 +213,7 @@ export class UserRepository extends Repository<User> {
description: profile!.description, description: profile!.description,
location: profile!.location, location: profile!.location,
birthday: profile!.birthday, birthday: profile!.birthday,
lang: profile!.lang,
fields: profile!.fields, fields: profile!.fields,
followersCount: user.followersCount, followersCount: user.followersCount,
followingCount: user.followingCount, followingCount: user.followingCount,
@ -258,7 +259,8 @@ export class UserRepository extends Repository<User> {
hasPendingReceivedFollowRequest: this.getHasPendingReceivedFollowRequest(user.id), hasPendingReceivedFollowRequest: this.getHasPendingReceivedFollowRequest(user.id),
integrations: profile!.integrations, integrations: profile!.integrations,
mutedWords: profile!.mutedWords, mutedWords: profile!.mutedWords,
mutingNotificationTypes: profile?.mutingNotificationTypes, mutingNotificationTypes: profile!.mutingNotificationTypes,
emailNotificationTypes: profile!.emailNotificationTypes,
} : {}), } : {}),
...(opts.includeSecrets ? { ...(opts.includeSecrets ? {

View file

@ -22,5 +22,5 @@ export const meta = {
}; };
export default define(meta, async (ps) => { export default define(meta, async (ps) => {
await sendEmail(ps.to, ps.subject, ps.text); await sendEmail(ps.to, ps.subject, ps.text, ps.text);
}); });

View file

@ -72,7 +72,9 @@ export default define(meta, async (ps, user) => {
const link = `${config.url}/verify-email/${code}`; 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:<br><a href="${link}">${link}</a>`,
`To verify email, please click this link: ${link}`);
} }
return iObj; return iObj;

View file

@ -161,6 +161,10 @@ export const meta = {
mutingNotificationTypes: { mutingNotificationTypes: {
validator: $.optional.arr($.str.or(notificationTypes as unknown as string[])) validator: $.optional.arr($.str.or(notificationTypes as unknown as string[]))
}, },
emailNotificationTypes: {
validator: $.optional.arr($.str)
},
}, },
errors: { errors: {
@ -206,7 +210,7 @@ export default define(meta, async (ps, user, token) => {
if (ps.name !== undefined) updates.name = ps.name; if (ps.name !== undefined) updates.name = ps.name;
if (ps.description !== undefined) profileUpdates.description = ps.description; 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.location !== undefined) profileUpdates.location = ps.location;
if (ps.birthday !== undefined) profileUpdates.birthday = ps.birthday; if (ps.birthday !== undefined) profileUpdates.birthday = ps.birthday;
if (ps.avatarId !== undefined) updates.avatarId = ps.avatarId; 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.injectFeaturedNote === 'boolean') profileUpdates.injectFeaturedNote = ps.injectFeaturedNote;
if (typeof ps.receiveAnnouncementEmail === 'boolean') profileUpdates.receiveAnnouncementEmail = ps.receiveAnnouncementEmail; if (typeof ps.receiveAnnouncementEmail === 'boolean') profileUpdates.receiveAnnouncementEmail = ps.receiveAnnouncementEmail;
if (typeof ps.alwaysMarkNsfw === 'boolean') profileUpdates.alwaysMarkNsfw = ps.alwaysMarkNsfw; if (typeof ps.alwaysMarkNsfw === 'boolean') profileUpdates.alwaysMarkNsfw = ps.alwaysMarkNsfw;
if (ps.emailNotificationTypes !== undefined) profileUpdates.emailNotificationTypes = ps.emailNotificationTypes;
if (ps.avatarId) { if (ps.avatarId) {
const avatar = await DriveFiles.findOne(ps.avatarId); const avatar = await DriveFiles.findOne(ps.avatarId);

View file

@ -4,6 +4,7 @@ import { Notifications, Mutings, UserProfiles } from '../models';
import { genId } from '../misc/gen-id'; import { genId } from '../misc/gen-id';
import { User } from '../models/entities/user'; import { User } from '../models/entities/user';
import { Notification } from '../models/entities/notification'; import { Notification } from '../models/entities/notification';
import { sendEmailNotification } from './send-email-notification';
export async function createNotification( export async function createNotification(
notifieeId: User['id'], notifieeId: User['id'],
@ -38,20 +39,22 @@ export async function createNotification(
setTimeout(async () => { setTimeout(async () => {
const fresh = await Notifications.findOne(notification.id); const fresh = await Notifications.findOne(notification.id);
if (fresh == null) return; // 既に削除されているかもしれない if (fresh == null) return; // 既に削除されているかもしれない
if (!fresh.isRead) { if (fresh.isRead) return;
//#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); //#region ただしミュートしているユーザーからの通知なら無視
const mutings = await Mutings.find({
pushSw(notifieeId, 'notification', packed); 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); }, 2000);
return notification; return notification;

View file

@ -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,
};

View file

@ -5,7 +5,7 @@ import config from '../config';
export const logger = new Logger('email'); 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 meta = await fetchMeta(true);
const iconUrl = `${config.url}/assets/mi-white.png`; const iconUrl = `${config.url}/assets/mi-white.png`;
@ -44,6 +44,9 @@ export async function sendEmail(to: string, subject: string, text: string) {
body { body {
padding: 16px; padding: 16px;
margin: 0;
font-family: sans-serif;
font-size: 14px;
} }
a { a {
@ -67,6 +70,7 @@ export async function sendEmail(to: string, subject: string, text: string) {
main > header > img { main > header > img {
max-width: 128px; max-width: 128px;
max-height: 28px; max-height: 28px;
vertical-align: bottom;
} }
main > article { main > article {
padding: 32px; padding: 32px;
@ -97,7 +101,7 @@ export async function sendEmail(to: string, subject: string, text: string) {
</header> </header>
<article> <article>
<h1>${ subject }</h1> <h1>${ subject }</h1>
<div>${ text }</div> <div>${ html }</div>
</article> </article>
<footer> <footer>
<a href="${ emailSettingUrl }">${ 'Email setting' }</a> <a href="${ emailSettingUrl }">${ 'Email setting' }</a>