wip: email notification
This commit is contained in:
		
							parent
							
								
									2d3248504b
								
							
						
					
					
						commit
						ebadd7fd3f
					
				
					 20 changed files with 355 additions and 159 deletions
				
			
		|  | @ -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: "プラグインのインストール" | ||||
|  |  | |||
							
								
								
									
										14
									
								
								migration/1613155914446-emailNotificationTypes.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								migration/1613155914446-emailNotificationTypes.ts
									
										
									
									
									
										Normal 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"`); | ||||
|     } | ||||
| 
 | ||||
| } | ||||
							
								
								
									
										14
									
								
								migration/1613181457597-user-lang.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								migration/1613181457597-user-lang.ts
									
										
									
									
									
										Normal 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"`); | ||||
|     } | ||||
| 
 | ||||
| } | ||||
|  | @ -1,28 +1,11 @@ | |||
| <template> | ||||
| <FormGroup class="_formItem"> | ||||
| 	<template #label><slot></slot></template> | ||||
| 	<div class="ztzhwixg _formItem" :class="{ inline, disabled }"> | ||||
| 	<div class="_formLabel"><slot></slot></div> | ||||
| 		<div class="icon" ref="icon"><slot name="icon"></slot></div> | ||||
| 		<div class="input _formPanel"> | ||||
| 			<div class="prefix" ref="prefixEl"><slot name="prefix"></slot></div> | ||||
| 		<input v-if="debounce" ref="inputEl" | ||||
| 			v-debounce="500" | ||||
| 			:type="type" | ||||
| 			v-model.lazy="v" | ||||
| 			:disabled="disabled" | ||||
| 			:required="required" | ||||
| 			: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" | ||||
| 		> | ||||
| 		<input v-else ref="inputEl" | ||||
| 			<input ref="inputEl" | ||||
| 				:type="type" | ||||
| 				v-model="v" | ||||
| 				:disabled="disabled" | ||||
|  | @ -44,20 +27,24 @@ | |||
| 			</datalist> | ||||
| 			<div class="suffix" ref="suffixEl"><slot name="suffix"></slot></div> | ||||
| 		</div> | ||||
| 	<button class="save _textButton" v-if="save && changed" @click="() => { changed = false; save(); }">{{ $ts.save }}</button> | ||||
| 	<div class="_formCaption"><slot name="desc"></slot></div> | ||||
| 	</div> | ||||
| 	<template #caption><slot name="desc"></slot></template> | ||||
| 
 | ||||
| 	<FormButton v-if="manualSave && changed" @click="updated" primary><Fa :icon="faSave"/> {{ $ts.save }}</FormButton> | ||||
| </FormGroup> | ||||
| </template> | ||||
| 
 | ||||
| <script lang="ts"> | ||||
| import { defineComponent, onMounted, onUnmounted, nextTick, ref, watch, computed, toRefs } from 'vue'; | ||||
| import debounce from 'v-debounce'; | ||||
| import { faExclamationCircle } from '@fortawesome/free-solid-svg-icons'; | ||||
| import { faExclamationCircle, faSave } from '@fortawesome/free-solid-svg-icons'; | ||||
| import './form.scss'; | ||||
| import FormButton from './button.vue'; | ||||
| import FormGroup from './group.vue'; | ||||
| 
 | ||||
| export default defineComponent({ | ||||
| 	directives: { | ||||
| 		debounce | ||||
| 	components: { | ||||
| 		FormGroup, | ||||
| 		FormButton, | ||||
| 	}, | ||||
| 	props: { | ||||
| 		value: { | ||||
|  | @ -101,9 +88,6 @@ export default defineComponent({ | |||
| 		step: { | ||||
| 			required: false | ||||
| 		}, | ||||
| 		debounce: { | ||||
| 			required: false | ||||
| 		}, | ||||
| 		datalist: { | ||||
| 			type: Array, | ||||
| 			required: false, | ||||
|  | @ -113,9 +97,10 @@ export default defineComponent({ | |||
| 			required: false, | ||||
| 			default: false | ||||
| 		}, | ||||
| 		save: { | ||||
| 			type: Function, | ||||
| 		manualSave: { | ||||
| 			type: Boolean, | ||||
| 			required: false, | ||||
| 			default: false | ||||
| 		}, | ||||
| 	}, | ||||
| 	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 => { | ||||
| 			v.value = newValue; | ||||
| 		}); | ||||
| 
 | ||||
| 		watch(v, newValue => { | ||||
| 			if (type?.value === 'number') { | ||||
| 				context.emit('update:value', parseFloat(newValue)); | ||||
| 			} else { | ||||
| 				context.emit('update:value', newValue); | ||||
| 			if (!props.manualSave) { | ||||
| 				updated(); | ||||
| 			} | ||||
| 
 | ||||
| 			invalid.value = inputEl.value.validity.badInput; | ||||
|  | @ -198,7 +190,8 @@ export default defineComponent({ | |||
| 			focus, | ||||
| 			onInput, | ||||
| 			onKeydown, | ||||
| 			faExclamationCircle, | ||||
| 			updated, | ||||
| 			faExclamationCircle, faSave, | ||||
| 		}; | ||||
| 	}, | ||||
| }); | ||||
|  | @ -285,11 +278,6 @@ export default defineComponent({ | |||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	> .save { | ||||
| 		margin: 6px 0 0 0; | ||||
| 		font-size: 0.8em; | ||||
| 	} | ||||
| 
 | ||||
| 	&.inline { | ||||
| 		display: inline-block; | ||||
| 		margin: 0; | ||||
|  |  | |||
|  | @ -1,9 +1,10 @@ | |||
| <template> | ||||
| <FormGroup class="_formItem"> | ||||
| 	<template #label><slot></slot></template> | ||||
| 	<div class="rivhosbp _formItem" :class="{ tall, pre }"> | ||||
| 	<div class="_formLabel"><slot></slot></div> | ||||
| 		<div class="input _formPanel"> | ||||
| 			<textarea ref="input" :class="{ code, _monospace: code }" | ||||
| 			:value="value" | ||||
| 				v-model="v" | ||||
| 				:required="required" | ||||
| 				:readonly="readonly" | ||||
| 				:pattern="pattern" | ||||
|  | @ -14,16 +15,25 @@ | |||
| 				@blur="focused = false" | ||||
| 			></textarea> | ||||
| 		</div> | ||||
| 	<button class="save _textButton" v-if="save && changed" @click="() => { changed = false; save(); }">{{ $ts.save }}</button> | ||||
| 	<div class="_formCaption"><slot name="desc"></slot></div> | ||||
| 	</div> | ||||
| 	<template #caption><slot name="desc"></slot></template> | ||||
| 
 | ||||
| 	<FormButton v-if="manualSave && changed" @click="updated" primary><Fa :icon="faSave"/> {{ $ts.save }}</FormButton> | ||||
| </FormGroup> | ||||
| </template> | ||||
| 
 | ||||
| <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 FormButton from './button.vue'; | ||||
| import FormGroup from './group.vue'; | ||||
| 
 | ||||
| export default defineComponent({ | ||||
| 	components: { | ||||
| 		FormGroup, | ||||
| 		FormButton, | ||||
| 	}, | ||||
| 	props: { | ||||
| 		value: { | ||||
| 			required: false | ||||
|  | @ -58,24 +68,46 @@ export default defineComponent({ | |||
| 			required: false, | ||||
| 			default: false | ||||
| 		}, | ||||
| 		save: { | ||||
| 			type: Function, | ||||
| 		manualSave: { | ||||
| 			type: Boolean, | ||||
| 			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 { | ||||
| 			changed: false, | ||||
| 		} | ||||
| 	}, | ||||
| 	methods: { | ||||
| 		focus() { | ||||
| 			this.$refs.input.focus(); | ||||
| 		}, | ||||
| 		onInput(ev) { | ||||
| 			this.changed = true; | ||||
| 			this.$emit('update:value', ev.target.value); | ||||
| 		} | ||||
| 			v, | ||||
| 			updated, | ||||
| 			changed, | ||||
| 			focus, | ||||
| 			onInput, | ||||
| 			faSave, | ||||
| 		}; | ||||
| 	} | ||||
| }); | ||||
| </script> | ||||
|  | @ -112,11 +144,6 @@ export default defineComponent({ | |||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	> .save { | ||||
| 		margin: 6px 0 0 0; | ||||
| 		font-size: 0.8em; | ||||
| 	} | ||||
| 
 | ||||
| 	&.tall { | ||||
| 		> .input { | ||||
| 			> textarea { | ||||
|  |  | |||
|  | @ -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)); | ||||
| 
 | ||||
|  |  | |||
							
								
								
									
										90
									
								
								src/client/pages/settings/email-notification.vue
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										90
									
								
								src/client/pages/settings/email-notification.vue
									
										
									
									
									
										Normal 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> | ||||
|  | @ -9,6 +9,11 @@ | |||
| 		</FormLink> | ||||
| 	</FormGroup> | ||||
| 
 | ||||
| 	<FormLink to="/settings/email/notification"> | ||||
| 		<template #icon><Fa :icon="faBell"/></template> | ||||
| 		{{ $ts.emailNotification }} | ||||
| 	</FormLink> | ||||
| 
 | ||||
| 	<FormSwitch :value="$i.receiveAnnouncementEmail" @update:value="onChangeReceiveAnnouncementEmail"> | ||||
| 		{{ $ts.receiveAnnouncementFromInstance }} | ||||
| 	</FormSwitch> | ||||
|  | @ -43,7 +48,7 @@ export default defineComponent({ | |||
| 				title: this.$ts.email, | ||||
| 				icon: faEnvelope | ||||
| 			}, | ||||
| 			faCog, faExclamationTriangle, faCheck | ||||
| 			faCog, faExclamationTriangle, faCheck, faBell | ||||
| 		} | ||||
| 	}, | ||||
| 
 | ||||
|  |  | |||
|  | @ -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')); | ||||
|  |  | |||
|  | @ -8,25 +8,30 @@ | |||
| 		<FormButton @click="changeBanner" primary>{{ $ts._profile.changeBanner }}</FormButton> | ||||
| 	</FormGroup> | ||||
| 
 | ||||
| 	<FormInput v-model:value="name" :max="30"> | ||||
| 	<FormInput v-model:value="name" :max="30" manual-save> | ||||
| 		<span>{{ $ts._profile.name }}</span> | ||||
| 	</FormInput> | ||||
| 
 | ||||
| 	<FormTextarea v-model:value="description" :max="500"> | ||||
| 	<FormTextarea v-model:value="description" :max="500" tall manual-save> | ||||
| 		<span>{{ $ts._profile.description }}</span> | ||||
| 		<template #desc>{{ $ts._profile.youCanIncludeHashtags }}</template> | ||||
| 	</FormTextarea> | ||||
| 
 | ||||
| 	<FormInput v-model:value="location"> | ||||
| 	<FormInput v-model:value="location" manual-save> | ||||
| 		<span>{{ $ts.location }}</span> | ||||
| 		<template #prefix><Fa :icon="faMapMarkerAlt"/></template> | ||||
| 	</FormInput> | ||||
| 
 | ||||
| 	<FormInput v-model:value="birthday" type="date"> | ||||
| 	<FormInput v-model:value="birthday" type="date" manual-save> | ||||
| 		<span>{{ $ts.birthday }}</span> | ||||
| 		<template #prefix><Fa :icon="faBirthdayCake"/></template> | ||||
| 	</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> | ||||
| 		<FormButton @click="editMetadata" primary>{{ $ts._profile.metadataEdit }}</FormButton> | ||||
| 		<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="alwaysMarkNsfw">{{ $ts.alwaysMarkSensitive }}</FormSwitch> | ||||
| 
 | ||||
| 	<FormButton @click="save(true)" primary><Fa :icon="faSave"/> {{ $ts.save }}</FormButton> | ||||
| </FormBase> | ||||
| </template> | ||||
| 
 | ||||
|  | @ -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 | ||||
| 				}); | ||||
| 			}); | ||||
| 		}, | ||||
| 	} | ||||
|  |  | |||
|  | @ -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_; | ||||
|  |  | |||
|  | @ -1,14 +1,9 @@ | |||
| // Notice: Service Workerでも使用します
 | ||||
| export class I18n<T extends Record<string, any>> { | ||||
| 	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<T extends Record<string, any>> { | |||
| 		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<T extends Record<string, any>> { | |||
| 			} | ||||
| 			return str; | ||||
| 		} catch (e) { | ||||
| 			if (_DEV_) { | ||||
| 			console.warn(`missing localization '${key}'`); | ||||
| 				return `⚠'${key}'⚠`; | ||||
| 			} | ||||
| 
 | ||||
| 			return key; | ||||
| 		} | ||||
| 	} | ||||
|  | @ -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, | ||||
| 	}) | ||||
|  |  | |||
|  | @ -213,6 +213,7 @@ export class UserRepository extends Repository<User> { | |||
| 				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<User> { | |||
| 				hasPendingReceivedFollowRequest: this.getHasPendingReceivedFollowRequest(user.id), | ||||
| 				integrations: profile!.integrations, | ||||
| 				mutedWords: profile!.mutedWords, | ||||
| 				mutingNotificationTypes: profile?.mutingNotificationTypes, | ||||
| 				mutingNotificationTypes: profile!.mutingNotificationTypes, | ||||
| 				emailNotificationTypes: profile!.emailNotificationTypes, | ||||
| 			} : {}), | ||||
| 
 | ||||
| 			...(opts.includeSecrets ? { | ||||
|  |  | |||
|  | @ -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); | ||||
| }); | ||||
|  |  | |||
|  | @ -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:<br><a href="${link}">${link}</a>`, | ||||
| 			`To verify email, please click this link: ${link}`); | ||||
| 	} | ||||
| 
 | ||||
| 	return iObj; | ||||
|  |  | |||
|  | @ -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); | ||||
|  |  | |||
|  | @ -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,7 +39,8 @@ export async function createNotification( | |||
| 	setTimeout(async () => { | ||||
| 		const fresh = await Notifications.findOne(notification.id); | ||||
| 		if (fresh == null) return; // 既に削除されているかもしれない
 | ||||
| 		if (!fresh.isRead) { | ||||
| 		if (fresh.isRead) return; | ||||
| 
 | ||||
| 		//#region ただしミュートしているユーザーからの通知なら無視
 | ||||
| 		const mutings = await Mutings.find({ | ||||
| 			muterId: notifieeId | ||||
|  | @ -51,7 +53,8 @@ export async function createNotification( | |||
| 		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; | ||||
|  |  | |||
							
								
								
									
										28
									
								
								src/services/send-email-notification.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								src/services/send-email-notification.ts
									
										
									
									
									
										Normal 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, | ||||
| }; | ||||
|  | @ -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) { | |||
| 						</header> | ||||
| 						<article> | ||||
| 							<h1>${ subject }</h1> | ||||
| 							<div>${ text }</div> | ||||
| 							<div>${ html }</div> | ||||
| 						</article> | ||||
| 						<footer> | ||||
| 							<a href="${ emailSettingUrl }">${ 'Email setting' }</a> | ||||
|  |  | |||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue