diff options
Diffstat (limited to 'src')
67 files changed, 768 insertions, 148 deletions
diff --git a/src/client/app/boot.js b/src/client/app/boot.js index 6cb1488e40..583cc4fc8b 100644 --- a/src/client/app/boot.js +++ b/src/client/app/boot.js @@ -84,7 +84,14 @@ // Detect the user agent const ua = navigator.userAgent.toLowerCase(); - const isMobile = /mobile|iphone|ipad|android/.test(ua); + let isMobile = /mobile|iphone|ipad|android/.test(ua) || window.innerWidth < 576; + if (settings && settings.device.appTypeForce) { + if (settings.device.appTypeForce === 'mobile') { + isMobile = true; + } else if (settings.device.appTypeForce === 'desktop') { + isMobile = false; + } + } // Get the <head> element const head = document.getElementsByTagName('head')[0]; diff --git a/src/client/app/common/scripts/post-form.ts b/src/client/app/common/scripts/post-form.ts index 85a578484f..7cf26f65bf 100644 --- a/src/client/app/common/scripts/post-form.ts +++ b/src/client/app/common/scripts/post-form.ts @@ -8,6 +8,7 @@ import { host, url } from '../../config'; import i18n from '../../i18n'; import { erase, unique } from '../../../../prelude/array'; import extractMentions from '../../../../misc/extract-mentions'; +import { formatTimeString } from '../../../../misc/format-time-string'; export default (opts) => ({ i18n: i18n(), @@ -151,9 +152,16 @@ export default (opts) => ({ // 公開以外へのリプライ時は元の公開範囲を引き継ぐ if (this.reply && ['home', 'followers', 'specified'].includes(this.reply.visibility)) { this.visibility = this.reply.visibility; + if (this.reply.visibility === 'specified') { + this.$root.api('users/show', { + userIds: this.reply.visibleUserIds.filter(uid => uid !== this.$store.state.i.id && uid !== this.reply.userId) + }).then(users => { + this.visibleUsers.push(...users); + }); + } } - if (this.reply) { + if (this.reply && this.reply.userId !== this.$store.state.i.id) { this.$root.api('users/show', { userId: this.reply.userId }).then(user => { this.visibleUsers.push(user); }); @@ -237,8 +245,8 @@ export default (opts) => ({ for (const x of Array.from((this.$refs.file as any).files)) this.upload(x); }, - upload(file) { - (this.$refs.uploader as any).upload(file); + upload(file: File, name?: string) { + (this.$refs.uploader as any).upload(file, this.$store.state.settings.uploadFolder, name); }, onChangeUploadings(uploads) { @@ -327,10 +335,23 @@ export default (opts) => ({ if ((e.which == 10 || e.which == 13) && (e.ctrlKey || e.metaKey) && this.canPost) this.post(); }, - async onPaste(e) { - for (const item of Array.from(e.clipboardData.items)) { + async onPaste(e: ClipboardEvent) { + for (const { item, i } of Array.from(e.clipboardData.items).map((item, i) => ({item, i}))) { if (item.kind == 'file') { - this.upload(item.getAsFile()); + const file = item.getAsFile(); + const lio = file.name.lastIndexOf('.'); + const ext = lio >= 0 ? file.name.slice(lio) : ''; + const formatted = `${formatTimeString(new Date(file.lastModified), this.$store.state.settings.pastedFileName).replace(/{{number}}/g, `${i + 1}`)}${ext}`; + const name = this.$store.state.settings.pasteDialog + ? await this.$root.dialog({ + title: this.$t('@.post-form.enter-file-name'), + input: { + default: formatted + }, + allowEmpty: false + }).then(({ canceled, result }) => canceled ? false : result) + : formatted; + if (name) this.upload(file, name); } } diff --git a/src/client/app/common/views/components/games/reversi/reversi.game.vue b/src/client/app/common/views/components/games/reversi/reversi.game.vue index 3f481e8eb5..a7c918aa71 100644 --- a/src/client/app/common/views/components/games/reversi/reversi.game.vue +++ b/src/client/app/common/views/components/games/reversi/reversi.game.vue @@ -5,17 +5,17 @@ <div style="overflow: hidden; line-height: 28px;"> <p class="turn" v-if="!iAmPlayer && !game.isEnded"> - <mfm :key="'turn:' + $options.filters.userName(turnUser)" :text="$t('@.reversi.turn-of', { name: $options.filters.userName(turnUser) })" :should-break="false" :plain-text="true" :custom-emojis="turnUser.emojis"/> + <mfm :key="'turn:' + $options.filters.userName(turnUser)" :text="$t('@.reversi.turn-of', { name: $options.filters.userName(turnUser) })" :plain="true" :custom-emojis="turnUser.emojis"/> <mk-ellipsis/> </p> <p class="turn" v-if="logPos != logs.length"> - <mfm :key="'past-turn-of:' + $options.filters.userName(turnUser)" :text="$t('@.reversi.past-turn-of', { name: $options.filters.userName(turnUser) })" :should-break="false" :plain-text="true" :custom-emojis="turnUser.emojis"/> + <mfm :key="'past-turn-of:' + $options.filters.userName(turnUser)" :text="$t('@.reversi.past-turn-of', { name: $options.filters.userName(turnUser) })" :plain="true" :custom-emojis="turnUser.emojis"/> </p> <p class="turn1" v-if="iAmPlayer && !game.isEnded && !isMyTurn">{{ $t('@.reversi.opponent-turn') }}<mk-ellipsis/></p> <p class="turn2" v-if="iAmPlayer && !game.isEnded && isMyTurn" v-animate-css="{ classes: 'tada', iteration: 'infinite' }">{{ $t('@.reversi.my-turn') }}</p> <p class="result" v-if="game.isEnded && logPos == logs.length"> <template v-if="game.winner"> - <mfm :key="'won'" :text="$t('@.reversi.won', { name: $options.filters.userName(game.winner) })" :should-break="false" :plain-text="true" :custom-emojis="game.winner.emojis"/> + <mfm :key="'won'" :text="$t('@.reversi.won', { name: $options.filters.userName(game.winner) })" :plain="true" :custom-emojis="game.winner.emojis"/> <span v-if="game.surrendered != null"> ({{ $t('surrendered') }})</span> </template> <template v-else>{{ $t('@.reversi.drawn') }}</template> diff --git a/src/client/app/common/views/components/messaging-room.form.vue b/src/client/app/common/views/components/messaging-room.form.vue index 1dfb0589e4..bd63bab2c1 100644 --- a/src/client/app/common/views/components/messaging-room.form.vue +++ b/src/client/app/common/views/components/messaging-room.form.vue @@ -30,6 +30,7 @@ import Vue from 'vue'; import i18n from '../../../i18n'; import * as autosize from 'autosize'; +import { formatTimeString } from '../../../../../misc/format-time-string'; export default Vue.extend({ i18n: i18n('common/views/components/messaging-room.form.vue'), @@ -84,13 +85,26 @@ export default Vue.extend({ } }, methods: { - onPaste(e) { + async onPaste(e: ClipboardEvent) { const data = e.clipboardData; const items = data.items; if (items.length == 1) { if (items[0].kind == 'file') { - this.upload(items[0].getAsFile()); + const file = items[0].getAsFile(); + const lio = file.name.lastIndexOf('.'); + const ext = lio >= 0 ? file.name.slice(lio) : ''; + const formatted = `${formatTimeString(new Date(file.lastModified), this.$store.state.settings.pastedFileName).replace(/{{number}}/g, '1')}${ext}`; + const name = this.$store.state.settings.pasteDialog + ? await this.$root.dialog({ + title: this.$t('@.post-form.enter-file-name'), + input: { + default: formatted + }, + allowEmpty: false + }).then(({ canceled, result }) => canceled ? false : result) + : formatted; + if (name) this.upload(file, name); } } else { if (items[0].kind == 'file') { @@ -157,8 +171,8 @@ export default Vue.extend({ this.upload((this.$refs.file as any).files[0]); }, - upload(file) { - (this.$refs.uploader as any).upload(file); + upload(file: File, name?: string) { + (this.$refs.uploader as any).upload(file, this.$store.state.settings.uploadFolder, name); }, onUploaded(file) { diff --git a/src/client/app/common/views/components/mfm.ts b/src/client/app/common/views/components/mfm.ts index fa798504c7..561c3d8e30 100644 --- a/src/client/app/common/views/components/mfm.ts +++ b/src/client/app/common/views/components/mfm.ts @@ -22,11 +22,11 @@ export default Vue.component('misskey-flavored-markdown', { type: String, required: true }, - shouldBreak: { + plain: { type: Boolean, - default: true + default: false }, - plainText: { + nowrap: { type: Boolean, default: false }, @@ -50,7 +50,7 @@ export default Vue.component('misskey-flavored-markdown', { render(createElement) { if (this.text == null || this.text == '') return; - const ast = (this.plainText ? parsePlain : parse)(this.text); + const ast = (this.plain ? parsePlain : parse)(this.text); let bigCount = 0; let motionCount = 0; @@ -60,7 +60,7 @@ export default Vue.component('misskey-flavored-markdown', { case 'text': { const text = token.node.props.text.replace(/(\r\n|\n|\r)/g, '\n'); - if (this.shouldBreak) { + if (!this.plain) { const x = text.split('\n') .map(t => t == '' ? [createElement('br')] : [createElement('span', t), createElement('br')]); x[x.length - 1].pop(); @@ -270,7 +270,7 @@ export default Vue.component('misskey-flavored-markdown', { }, props: { customEmojis: this.customEmojis || customEmojis, - normal: this.plainText + normal: this.plain } })]; } diff --git a/src/client/app/common/views/components/misskey-flavored-markdown.vue b/src/client/app/common/views/components/misskey-flavored-markdown.vue index a0ecbecbb2..64496f9c84 100644 --- a/src/client/app/common/views/components/misskey-flavored-markdown.vue +++ b/src/client/app/common/views/components/misskey-flavored-markdown.vue @@ -1,5 +1,5 @@ <template> -<mfm-core v-bind="$attrs" class="havbbuyv" :class="{ plain: $attrs['plain-text'] }" v-once/> +<mfm-core v-bind="$attrs" class="havbbuyv" :class="{ nowrap: $attrs['nowrap'] }" v-once/> </template> <script lang="ts"> @@ -17,7 +17,7 @@ export default Vue.extend({ .havbbuyv white-space pre-wrap - &.plain + &.nowrap white-space pre >>> .title diff --git a/src/client/app/common/views/pages/page/page.block.vue b/src/client/app/common/views/components/page/page.block.vue index 1c421fc2c0..1c421fc2c0 100644 --- a/src/client/app/common/views/pages/page/page.block.vue +++ b/src/client/app/common/views/components/page/page.block.vue diff --git a/src/client/app/common/views/pages/page/page.button.vue b/src/client/app/common/views/components/page/page.button.vue index 3747be96ce..87112aca0d 100644 --- a/src/client/app/common/views/pages/page/page.button.vue +++ b/src/client/app/common/views/components/page/page.button.vue @@ -1,6 +1,6 @@ <template> <div> - <ui-button class="kudkigyw" @click="click()">{{ script.interpolate(value.text) }}</ui-button> + <ui-button class="kudkigyw" @click="click()" :primary="value.primary">{{ script.interpolate(value.text) }}</ui-button> </div> </template> @@ -27,6 +27,19 @@ export default Vue.extend({ } else if (this.value.action === 'resetRandom') { this.script.aiScript.updateRandomSeed(Math.random()); this.script.eval(); + } else if (this.value.action === 'pushEvent') { + this.$root.api('page-push', { + pageId: this.script.page.id, + event: this.value.event, + ...(this.value.var ? { + var: this.script.vars[this.value.var] + } : {}) + }); + + this.$root.dialog({ + type: 'success', + text: this.script.interpolate(this.value.message) + }); } } } @@ -36,7 +49,7 @@ export default Vue.extend({ <style lang="stylus" scoped> .kudkigyw display inline-block - min-width 300px + min-width 200px max-width 450px margin 8px 0 </style> diff --git a/src/client/app/common/views/pages/page/page.counter.vue b/src/client/app/common/views/components/page/page.counter.vue index 8d55319fe9..8d55319fe9 100644 --- a/src/client/app/common/views/pages/page/page.counter.vue +++ b/src/client/app/common/views/components/page/page.counter.vue diff --git a/src/client/app/common/views/pages/page/page.if.vue b/src/client/app/common/views/components/page/page.if.vue index 417ef0c553..417ef0c553 100644 --- a/src/client/app/common/views/pages/page/page.if.vue +++ b/src/client/app/common/views/components/page/page.if.vue diff --git a/src/client/app/common/views/pages/page/page.image.vue b/src/client/app/common/views/components/page/page.image.vue index 1285445eb0..1285445eb0 100644 --- a/src/client/app/common/views/pages/page/page.image.vue +++ b/src/client/app/common/views/components/page/page.image.vue diff --git a/src/client/app/common/views/pages/page/page.number-input.vue b/src/client/app/common/views/components/page/page.number-input.vue index 31da37330a..31da37330a 100644 --- a/src/client/app/common/views/pages/page/page.number-input.vue +++ b/src/client/app/common/views/components/page/page.number-input.vue diff --git a/src/client/app/common/views/pages/page/page.post.vue b/src/client/app/common/views/components/page/page.post.vue index cb695e21e9..cb695e21e9 100644 --- a/src/client/app/common/views/pages/page/page.post.vue +++ b/src/client/app/common/views/components/page/page.post.vue diff --git a/src/client/app/common/views/pages/page/page.section.vue b/src/client/app/common/views/components/page/page.section.vue index 03c009d9c3..03c009d9c3 100644 --- a/src/client/app/common/views/pages/page/page.section.vue +++ b/src/client/app/common/views/components/page/page.section.vue diff --git a/src/client/app/common/views/pages/page/page.switch.vue b/src/client/app/common/views/components/page/page.switch.vue index 53695f1b36..53695f1b36 100644 --- a/src/client/app/common/views/pages/page/page.switch.vue +++ b/src/client/app/common/views/components/page/page.switch.vue diff --git a/src/client/app/common/views/pages/page/page.text-input.vue b/src/client/app/common/views/components/page/page.text-input.vue index cf917dd5a8..cf917dd5a8 100644 --- a/src/client/app/common/views/pages/page/page.text-input.vue +++ b/src/client/app/common/views/components/page/page.text-input.vue diff --git a/src/client/app/common/views/pages/page/page.text.vue b/src/client/app/common/views/components/page/page.text.vue index 326fd39050..326fd39050 100644 --- a/src/client/app/common/views/pages/page/page.text.vue +++ b/src/client/app/common/views/components/page/page.text.vue diff --git a/src/client/app/common/views/pages/page/page.textarea-input.vue b/src/client/app/common/views/components/page/page.textarea-input.vue index eece59fefb..eece59fefb 100644 --- a/src/client/app/common/views/pages/page/page.textarea-input.vue +++ b/src/client/app/common/views/components/page/page.textarea-input.vue diff --git a/src/client/app/common/views/pages/page/page.textarea.vue b/src/client/app/common/views/components/page/page.textarea.vue index 03c8542cb0..03c8542cb0 100644 --- a/src/client/app/common/views/pages/page/page.textarea.vue +++ b/src/client/app/common/views/components/page/page.textarea.vue diff --git a/src/client/app/common/views/pages/page/page.vue b/src/client/app/common/views/components/page/page.vue index 96a2cfafb7..99e627fd89 100644 --- a/src/client/app/common/views/pages/page/page.vue +++ b/src/client/app/common/views/components/page/page.vue @@ -1,6 +1,6 @@ <template> -<div v-if="page" class="iroscrza" :class="{ shadow: $store.state.device.useShadow, round: $store.state.device.roundedCorners, center: page.alignCenter }" :style="{ fontFamily: page.font }" :key="path"> - <header> +<div class="iroscrza" :class="{ shadow: $store.state.device.useShadow, round: $store.state.device.roundedCorners, center: page.alignCenter }" :style="{ fontFamily: page.font }"> + <header v-if="showTitle"> <div class="title">{{ page.title }}</div> </header> @@ -8,9 +8,13 @@ <x-block v-for="child in page.content" :value="child" @input="v => updateBlock(v)" :page="page" :script="script" :key="child.id" :h="2"/> </div> - <footer> + <footer v-if="showFooter"> <small>@{{ page.user.username }}</small> - <router-link v-if="$store.getters.isSignedIn && $store.state.i.id === page.userId" :to="`/i/pages/edit/${page.id}`">{{ $t('edit-this-page') }}</router-link> + <template v-if="$store.getters.isSignedIn && $store.state.i.id === page.userId"> + <router-link :to="`/i/pages/edit/${page.id}`">{{ $t('edit-this-page') }}</router-link> + <a v-if="$store.state.i.pinnedPageId === page.id" @click="pin(false)">{{ $t('unpin-this-page') }}</a> + <a v-else @click="pin(true)">{{ $t('pin-this-page') }}</a> + </template> <router-link :to="`./${page.name}/view-source`">{{ $t('view-source') }}</router-link> <div class="like"> <button @click="unlike()" v-if="page.isLiked" :title="$t('unlike')"><fa :icon="faHeartS"/></button> @@ -25,7 +29,7 @@ import Vue from 'vue'; import i18n from '../../../../i18n'; import { faHeart as faHeartS } from '@fortawesome/free-solid-svg-icons'; -import { faHeart, faStickyNote } from '@fortawesome/free-regular-svg-icons'; +import { faHeart } from '@fortawesome/free-regular-svg-icons'; import XBlock from './page.block.vue'; import { ASEvaluator } from '../../../../../../misc/aiscript/evaluator'; import { collectPageVars } from '../../../scripts/collect-page-vars'; @@ -35,8 +39,10 @@ class Script { public aiScript: ASEvaluator; private onError: any; public vars: Record<string, any>; + public page: Record<string, any>; - constructor(aiScript, onError) { + constructor(page, aiScript, onError) { + this.page = page; this.aiScript = aiScript; this.onError = onError; this.eval(); @@ -67,64 +73,43 @@ export default Vue.extend({ }, props: { - pageName: { - type: String, + page: { + type: Object, required: true }, - username: { - type: String, - required: true + showTitle: { + type: Boolean, + required: false, + default: true + }, + showFooter: { + type: Boolean, + required: false, + default: false }, }, data() { return { - page: null, script: null, faHeartS, faHeart }; }, - computed: { - path(): string { - return this.username + '/' + this.pageName; - } - }, - - watch: { - path() { - this.fetch(); - } - }, - created() { - this.fetch(); + const pageVars = this.getPageVars(); + this.script = new Script(this.page, new ASEvaluator(this.page.variables, pageVars, { + randomSeed: Math.random(), + user: this.page.user, + visitor: this.$store.state.i, + page: this.page, + url: url + }), e => { + console.dir(e); + }); }, methods: { - fetch() { - this.$root.api('pages/show', { - name: this.pageName, - username: this.username, - }).then(page => { - this.page = page; - this.$emit('init', { - title: this.page.title, - icon: faStickyNote - }); - const pageVars = this.getPageVars(); - this.script = new Script(new ASEvaluator(this.page.variables, pageVars, { - randomSeed: Math.random(), - user: page.user, - visitor: this.$store.state.i, - page: page, - url: url - }), e => { - console.dir(e); - }); - }); - }, - getPageVars() { return collectPageVars(this.page.content); }, @@ -145,6 +130,17 @@ export default Vue.extend({ this.page.isLiked = false; this.page.likedCount--; }); + }, + + pin(pin) { + this.$root.api('i/update', { + pinnedPageId: pin ? this.page.id : null, + }).then(() => { + this.$root.dialog({ + type: 'success', + splash: true + }); + }); } } }); diff --git a/src/client/app/common/views/components/poll.vue b/src/client/app/common/views/components/poll.vue index dc3aaa34f3..bd5eeaf832 100644 --- a/src/client/app/common/views/components/poll.vue +++ b/src/client/app/common/views/components/poll.vue @@ -5,7 +5,7 @@ <div class="backdrop" :style="{ 'width': `${showResult ? (choice.votes / total * 100) : 0}%` }"></div> <span> <template v-if="choice.isVoted"><fa icon="check"/></template> - <mfm :text="choice.text" :should-break="false" :plain-text="true" :custom-emojis="note.emojis"/> + <mfm :text="choice.text" :plain="true" :custom-emojis="note.emojis"/> <span class="votes" v-if="showResult">({{ $t('vote-count').replace('{}', choice.votes) }})</span> </span> </li> diff --git a/src/client/app/common/views/components/settings/2fa.vue b/src/client/app/common/views/components/settings/2fa.vue index eb645898e2..813a91b5c0 100644 --- a/src/client/app/common/views/components/settings/2fa.vue +++ b/src/client/app/common/views/components/settings/2fa.vue @@ -28,6 +28,10 @@ </div> </div> + <ui-switch v-model="usePasswordLessLogin" @change="updatePasswordLessLogin" v-if="$store.state.i.securityKeysList.length > 0"> + {{ $t('use-password-less-login') }} + </ui-switch> + <ui-info warn v-if="registration && registration.error">{{ $t('something-went-wrong') }} {{ registration.error }}</ui-info> <ui-button v-if="!registration || registration.error" @click="addSecurityKey">{{ $t('register') }}</ui-button> @@ -80,6 +84,7 @@ export default Vue.extend({ return { data: null, supportsCredentials: !!navigator.credentials, + usePasswordLessLogin: this.$store.state.i.usePasswordLessLogin, registration: null, keyName: '', token: null @@ -113,6 +118,9 @@ export default Vue.extend({ this.$root.api('i/2fa/unregister', { password: password }).then(() => { + this.usePasswordLessLogin = false; + this.updatePasswordLessLogin(); + }).then(() => { this.$notify(this.$t('unregistered')); this.$store.state.i.twoFactorEnabled = false; }); @@ -158,6 +166,9 @@ export default Vue.extend({ password, credentialId: key.id }).then(() => { + this.usePasswordLessLogin = false; + this.updatePasswordLessLogin(); + }).then(() => { this.$notify(this.$t('key-unregistered')); }); }); @@ -213,6 +224,11 @@ export default Vue.extend({ this.registration.stage = -1; }); }); + }, + updatePasswordLessLogin() { + this.$root.api('i/2fa/password-less', { + value: !!this.usePasswordLessLogin + }); } } }); diff --git a/src/client/app/common/views/components/settings/app-type.vue b/src/client/app/common/views/components/settings/app-type.vue new file mode 100644 index 0000000000..90ff28803b --- /dev/null +++ b/src/client/app/common/views/components/settings/app-type.vue @@ -0,0 +1,36 @@ +<template> +<ui-card> + <template #title><fa :icon="faMobileAlt"/> {{ $t('title') }}</template> + + <section class="fit-top"> + <p>{{ $t('intro') }}</p> + <ui-select v-model="appTypeForce" :placeholder="$t('intro')"> + <option v-for="x in ['auto', 'desktop', 'mobile']" :value="x" :key="x">{{ $t(`choices.${x}`) }}</option> + </ui-select> + <ui-info warn>{{ $t('info') }}</ui-info> + </section> +</ui-card> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import i18n from '../../../../i18n'; +import { faMobileAlt } from '@fortawesome/free-solid-svg-icons' + +export default Vue.extend({ + i18n: i18n('common/views/components/settings/app-type.vue'), + + data() { + return { + faMobileAlt + }; + }, + + computed: { + appTypeForce: { + get() { return this.$store.state.device.appTypeForce; }, + set(value) { this.$store.commit('device/set', { key: 'appTypeForce', value }); } + }, + }, +}); +</script> diff --git a/src/client/app/common/views/components/settings/drive.vue b/src/client/app/common/views/components/settings/drive.vue index 7bdc806ae7..da028e85ef 100644 --- a/src/client/app/common/views/components/settings/drive.vue +++ b/src/client/app/common/views/components/settings/drive.vue @@ -11,6 +11,12 @@ <header>{{ $t('stats') }}</header> <div ref="chart" style="margin-bottom: -16px; margin-left: -8px; color: #000;"></div> </section> + + <section> + <header>{{ $t('default-upload-folder') }}</header> + <ui-input v-model="uploadFolderName" readonly>{{ $t('default-upload-folder-name') }}</ui-input> + <ui-button @click="chooseUploadFolder()">{{ $t('change-default-upload-folder') }}</ui-button> + </section> </ui-card> </template> @@ -26,7 +32,8 @@ export default Vue.extend({ return { fetching: true, usage: null, - capacity: null + capacity: null, + uploadFolderName: null }; }, @@ -40,10 +47,25 @@ export default Vue.extend({ l: 0.5 }) }; - } + }, + + uploadFolder: { + get() { return this.$store.state.settings.uploadFolder; }, + set(value) { this.$store.dispatch('settings/set', { key: 'uploadFolder', value }); } + }, }, mounted() { + if (this.uploadFolder == null) { + this.uploadFolderName = this.$t('@._settings.root'); + } else { + this.$root.api('drive/folders/show', { + folderId: this.uploadFolder + }).then(folder => { + this.uploadFolderName = folder.name; + }); + } + this.$root.api('drive').then(info => { this.capacity = info.capacity; this.usage = info.usage; @@ -89,6 +111,9 @@ export default Vue.extend({ height: 150, zoom: { enabled: false + }, + toolbar: { + show: false } }, plotOptions: { @@ -152,6 +177,13 @@ export default Vue.extend({ chart.render(); }); + }, + + chooseUploadFolder() { + this.$chooseDriveFolder().then(folder => { + this.uploadFolder = folder ? folder.id : null; + this.uploadFolderName = folder ? folder.name : this.$t('@._settings.root'); + }) } } }); diff --git a/src/client/app/common/views/components/settings/settings.vue b/src/client/app/common/views/components/settings/settings.vue index 1254eb5e5e..281524979e 100644 --- a/src/client/app/common/views/components/settings/settings.vue +++ b/src/client/app/common/views/components/settings/settings.vue @@ -140,7 +140,19 @@ <section> <header>{{ $t('@._settings.web-search-engine') }}</header> - <ui-input v-model="webSearchEngine">{{ $t('@._settings.web-search-engine') }}<template #desc>{{ $t('@._settings.web-search-engine-desc') }}</template></ui-input> + <ui-input v-model="webSearchEngine">{{ $t('@._settings.web-search-engine') }} + <template #desc>{{ $t('@._settings.web-search-engine-desc') }}</template> + </ui-input> + </section> + + <section v-if="!$root.isMobile"> + <header>{{ $t('@._settings.paste') }}</header> + <ui-input v-model="pastedFileName">{{ $t('@._settings.pasted-file-name') }} + <template #desc>{{ $t('@._settings.pasted-file-name-desc') }}</template> + </ui-input> + <ui-switch v-model="pasteDialog">{{ $t('@._settings.paste-dialog') }} + <template #desc>{{ $t('@._settings.paste-dialog-desc') }}</template> + </ui-switch> </section> </ui-card> @@ -163,6 +175,7 @@ </ui-card> <x-language/> + <x-app-type/> </template> <template v-if="page == null || page == 'notification'"> @@ -271,6 +284,7 @@ import XPassword from './password.vue'; import XProfile from './profile.vue'; import XApi from './api.vue'; import XLanguage from './language.vue'; +import XAppType from './app-type.vue'; import XNotification from './notification.vue'; import { url, version } from '../../../../config'; @@ -291,6 +305,7 @@ export default Vue.extend({ XProfile, XApi, XLanguage, + XAppType, XNotification, }, props: { @@ -409,6 +424,16 @@ export default Vue.extend({ set(value) { this.$store.dispatch('settings/set', { key: 'webSearchEngine', value }); } }, + pastedFileName: { + get() { return this.$store.state.settings.pastedFileName; }, + set(value) { this.$store.dispatch('settings/set', { key: 'pastedFileName', value }); } + }, + + pasteDialog: { + get() { return this.$store.state.settings.pasteDialog; }, + set(value) { this.$store.dispatch('settings/set', { key: 'pasteDialog', value }); } + }, + showReplyTarget: { get() { return this.$store.state.settings.showReplyTarget; }, set(value) { this.$store.dispatch('settings/set', { key: 'showReplyTarget', value }); } diff --git a/src/client/app/common/views/components/signin.vue b/src/client/app/common/views/components/signin.vue index 8498a1dc3e..f76f989d6d 100644 --- a/src/client/app/common/views/components/signin.vue +++ b/src/client/app/common/views/components/signin.vue @@ -7,7 +7,7 @@ <template #prefix>@</template> <template #suffix>@{{ host }}</template> </ui-input> - <ui-input v-model="password" type="password" :with-password-toggle="true" required> + <ui-input v-model="password" type="password" :with-password-toggle="true" v-if="!user || user && !user.usePasswordLessLogin" required> <span>{{ $t('password') }}</span> <template #prefix><fa icon="lock"/></template> </ui-input> @@ -28,6 +28,10 @@ </div> <div class="twofa-group totp-group"> <p style="margin-bottom:0;">{{ $t('enter-2fa-code') }}</p> + <ui-input v-model="password" type="password" :with-password-toggle="true" v-if="user && user.usePasswordLessLogin" required> + <span>{{ $t('password') }}</span> + <template #prefix><fa icon="lock"/></template> + </ui-input> <ui-input v-model="token" type="text" pattern="^[0-9]{6}$" autocomplete="off" spellcheck="false" required> <span>{{ $t('@.2fa') }}</span> <template #prefix><fa icon="gavel"/></template> diff --git a/src/client/app/common/views/components/uploader.vue b/src/client/app/common/views/components/uploader.vue index 78fbcbf6b8..9f02da6c1e 100644 --- a/src/client/app/common/views/components/uploader.vue +++ b/src/client/app/common/views/components/uploader.vue @@ -46,7 +46,7 @@ export default Vue.extend({ }); }, - upload(file: File, folder: any) { + upload(file: File, folder: any, name?: string) { if (folder && typeof folder == 'object') folder = folder.id; const id = Math.random(); @@ -61,7 +61,7 @@ export default Vue.extend({ const ctx = { id: id, - name: file.name || 'untitled', + name: name || file.name || 'untitled', progress: undefined, img: window.URL.createObjectURL(file) }; @@ -75,6 +75,7 @@ export default Vue.extend({ data.append('file', file); if (folder) data.append('folderId', folder); + if (name) data.append('name', name); const xhr = new XMLHttpRequest(); xhr.open('POST', apiUrl + '/drive/files/create', true); diff --git a/src/client/app/common/views/components/url-preview.vue b/src/client/app/common/views/components/url-preview.vue index 20fbcbb046..476c671e77 100644 --- a/src/client/app/common/views/components/url-preview.vue +++ b/src/client/app/common/views/components/url-preview.vue @@ -63,6 +63,7 @@ export default Vue.extend({ data() { const isSelf = this.url.startsWith(local); const hasRoute = + (this.url.substr(local.length) === '/') || this.url.substr(local.length).startsWith('/@') || this.url.substr(local.length).startsWith('/notes/') || this.url.substr(local.length).startsWith('/pages/'); diff --git a/src/client/app/common/views/components/user-list.vue b/src/client/app/common/views/components/user-list.vue index 466be41506..69cb65b445 100644 --- a/src/client/app/common/views/components/user-list.vue +++ b/src/client/app/common/views/components/user-list.vue @@ -16,7 +16,7 @@ <p class="username">@{{ user | acct }}</p> </div> <div class="description" v-if="user.description" :title="user.description"> - <mfm :text="user.description" :is-note="false" :author="user" :i="$store.state.i" :custom-emojis="user.emojis" :should-break="false" :plain-text="true"/> + <mfm :text="user.description" :is-note="false" :author="user" :i="$store.state.i" :custom-emojis="user.emojis" :plain="true" :nowrap="true"/> </div> <mk-follow-button class="follow-button" v-if="$store.getters.isSignedIn && user.id != $store.state.i.id" :user="user" mini/> </div> diff --git a/src/client/app/common/views/components/user-name.vue b/src/client/app/common/views/components/user-name.vue index 3959193eb4..6aca36acbd 100644 --- a/src/client/app/common/views/components/user-name.vue +++ b/src/client/app/common/views/components/user-name.vue @@ -1,5 +1,5 @@ <template> -<mfm :text="user.name || user.username" :should-break="false" :plain-text="true" :custom-emojis="user.emojis"/> +<mfm :text="user.name || user.username" :plain="true" :nowrap="true" :custom-emojis="user.emojis"/> </template> <script lang="ts"> diff --git a/src/client/app/common/views/deck/deck.notification.vue b/src/client/app/common/views/deck/deck.notification.vue index 27a2dffef5..e1d9669002 100644 --- a/src/client/app/common/views/deck/deck.notification.vue +++ b/src/client/app/common/views/deck/deck.notification.vue @@ -12,7 +12,7 @@ </header> <router-link class="note-ref" :to="notification.note | notePage" :title="getNoteSummary(notification.note)"> <fa icon="quote-left"/> - <mfm :text="getNoteSummary(notification.note)" :should-break="false" :plain-text="true" :custom-emojis="notification.note.emojis"/> + <mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="true" :custom-emojis="notification.note.emojis"/> <fa icon="quote-right"/> </router-link> </div> @@ -30,7 +30,7 @@ </header> <router-link class="note-ref" :to="notification.note | notePage" :title="getNoteSummary(notification.note.renote)"> <fa icon="quote-left"/> - <mfm :text="getNoteSummary(notification.note.renote)" :should-break="false" :plain-text="true" :custom-emojis="notification.note.renote.emojis"/> + <mfm :text="getNoteSummary(notification.note.renote)" :plain="true" :nowrap="true" :custom-emojis="notification.note.renote.emojis"/> <fa icon="quote-right"/> </router-link> </div> @@ -74,7 +74,7 @@ </header> <router-link class="note-ref" :to="notification.note | notePage" :title="getNoteSummary(notification.note)"> <fa icon="quote-left"/> - <mfm :text="getNoteSummary(notification.note)" :should-break="false" :plain-text="true" :custom-emojis="notification.note.emojis"/> + <mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="true" :custom-emojis="notification.note.emojis"/> <fa icon="quote-right"/> </router-link> </div> diff --git a/src/client/app/common/views/deck/deck.page-column.vue b/src/client/app/common/views/deck/deck.page-column.vue new file mode 100644 index 0000000000..0ef391a51d --- /dev/null +++ b/src/client/app/common/views/deck/deck.page-column.vue @@ -0,0 +1,69 @@ +<template> +<x-column> + <template #header> + <fa :icon="faStickyNote"/>{{ page ? page.name : '' }} + </template> + + <div v-if="page"> + <x-page :page="page" :key="page.id"/> + </div> +</x-column> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { faStickyNote } from '@fortawesome/free-regular-svg-icons'; +import i18n from '../../../i18n'; +import XColumn from './deck.column.vue'; +import XPage from '../../../common/views/components/page/page.vue'; + +export default Vue.extend({ + i18n: i18n(), + + components: { + XColumn, + XPage + }, + + props: { + pageName: { + type: String, + required: true + }, + username: { + type: String, + required: true + }, + }, + + data() { + return { + page: null, + faStickyNote + }; + }, + + watch: { + $route: 'fetch' + }, + + created() { + this.fetch(); + }, + + methods: { + fetch() { + this.$root.api('pages/show', { + name: this.pageName, + username: this.username, + }).then(page => { + this.page = page; + this.$emit('init', { + title: this.page.title, + icon: faStickyNote + }); + }); + } + } +}); +</script> diff --git a/src/client/app/common/views/deck/deck.user-column.home.vue b/src/client/app/common/views/deck/deck.user-column.home.vue index 56b117a7dd..9fb50a6672 100644 --- a/src/client/app/common/views/deck/deck.user-column.home.vue +++ b/src/client/app/common/views/deck/deck.user-column.home.vue @@ -1,5 +1,11 @@ <template> <div> + <ui-container v-if="user.pinnedPage" :body-togglable="true"> + <template #header><fa icon="thumbtack"/> {{ $t('pinned-page') }}</template> + <div> + <x-page :page="user.pinnedPage" :key="user.pinnedPage.id" :show-title="!user.pinnedPage.hideTitleWhenPinned"/> + </div> + </ui-container> <ui-container v-if="user.pinnedNotes && user.pinnedNotes.length > 0" :body-togglable="true"> <template #header><fa icon="thumbtack"/> {{ $t('pinned-notes') }}</template> <div> @@ -48,6 +54,7 @@ export default Vue.extend({ components: { XNotes, + XPage: () => import('../../../common/views/components/page/page.vue').then(m => m.default), }, props: { diff --git a/src/client/app/common/views/deck/deck.user-column.vue b/src/client/app/common/views/deck/deck.user-column.vue index c508f3f69f..8ab61f2047 100644 --- a/src/client/app/common/views/deck/deck.user-column.vue +++ b/src/client/app/common/views/deck/deck.user-column.vue @@ -30,7 +30,7 @@ <div class="fields" v-if="user.fields"> <dl class="field" v-for="(field, i) in user.fields" :key="i"> <dt class="name"> - <mfm :text="field.name" :should-break="false" :plain-text="true" :custom-emojis="user.emojis"/> + <mfm :text="field.name" :plain="true" :custom-emojis="user.emojis"/> </dt> <dd class="value"> <mfm :text="field.value" :author="user" :i="$store.state.i" :custom-emojis="user.emojis"/> diff --git a/src/client/app/common/views/pages/follow.vue b/src/client/app/common/views/pages/follow.vue index f6a11a7b4f..f08ddf2642 100644 --- a/src/client/app/common/views/pages/follow.vue +++ b/src/client/app/common/views/pages/follow.vue @@ -1,7 +1,7 @@ <template> <div class="syxhndwprovvuqhmyvveewmbqayniwkv" v-if="!fetching"> <div class="signed-in-as"> - <mfm :text="$t('signed-in-as').replace('{}', myName)" :should-break="false" :plain-text="true" :custom-emojis="$store.state.i.emojis"/> + <mfm :text="$t('signed-in-as').replace('{}', myName)" :plain="true" :custom-emojis="$store.state.i.emojis"/> </div> <main> <div class="banner" :style="bannerStyle"></div> diff --git a/src/client/app/common/views/pages/page-editor/els/page-editor.el.button.vue b/src/client/app/common/views/pages/page-editor/els/page-editor.el.button.vue index f89279f05a..6a82b0eec9 100644 --- a/src/client/app/common/views/pages/page-editor/els/page-editor.el.button.vue +++ b/src/client/app/common/views/pages/page-editor/els/page-editor.el.button.vue @@ -4,12 +4,31 @@ <section class="xfhsjczc"> <ui-input v-model="value.text"><span>{{ $t('blocks._button.text') }}</span></ui-input> + <ui-switch v-model="value.primary"><span>{{ $t('blocks._button.colored') }}</span></ui-switch> <ui-select v-model="value.action"> <template #label>{{ $t('blocks._button.action') }}</template> <option value="dialog">{{ $t('blocks._button._action.dialog') }}</option> <option value="resetRandom">{{ $t('blocks._button._action.resetRandom') }}</option> + <option value="pushEvent">{{ $t('blocks._button._action.pushEvent') }}</option> </ui-select> - <ui-input v-if="value.action === 'dialog'" v-model="value.content"><span>{{ $t('blocks._button._action._dialog.content') }}</span></ui-input> + <template v-if="value.action === 'dialog'"> + <ui-input v-model="value.content"><span>{{ $t('blocks._button._action._dialog.content') }}</span></ui-input> + </template> + <template v-else-if="value.action === 'pushEvent'"> + <ui-input v-model="value.event"><span>{{ $t('blocks._button._action._pushEvent.event') }}</span></ui-input> + <ui-input v-model="value.message"><span>{{ $t('blocks._button._action._pushEvent.message') }}</span></ui-input> + <ui-select v-model="value.var"> + <template #label>{{ $t('blocks._button._action._pushEvent.variable') }}</template> + <option :value="null">{{ $t('blocks._button._action._pushEvent.no-variable') }}</option> + <option v-for="v in aiScript.getVarsByType()" :value="v.name">{{ v.name }}</option> + <optgroup :label="$t('script.pageVariables')"> + <option v-for="v in aiScript.getPageVarsByType()" :value="v">{{ v }}</option> + </optgroup> + <optgroup :label="$t('script.enviromentVariables')"> + <option v-for="v in aiScript.getEnvVarsByType()" :value="v">{{ v }}</option> + </optgroup> + </ui-select> + </template> </section> </x-container> </template> @@ -31,6 +50,9 @@ export default Vue.extend({ value: { required: true }, + aiScript: { + required: true, + }, }, data() { @@ -43,6 +65,10 @@ export default Vue.extend({ if (this.value.text == null) Vue.set(this.value, 'text', ''); if (this.value.action == null) Vue.set(this.value, 'action', 'dialog'); if (this.value.content == null) Vue.set(this.value, 'content', null); + if (this.value.event == null) Vue.set(this.value, 'event', null); + if (this.value.message == null) Vue.set(this.value, 'message', null); + if (this.value.primary == null) Vue.set(this.value, 'primary', false); + if (this.value.var == null) Vue.set(this.value, 'var', null); }, }); </script> diff --git a/src/client/app/common/views/pages/page-editor/page-editor.vue b/src/client/app/common/views/pages/page-editor/page-editor.vue index ebe0f4688d..ade7d86991 100644 --- a/src/client/app/common/views/pages/page-editor/page-editor.vue +++ b/src/client/app/common/views/pages/page-editor/page-editor.vue @@ -35,6 +35,8 @@ <option value="sans-serif">{{ $t('fontSansSerif') }}</option> </ui-select> + <ui-switch v-model="hideTitleWhenPinned">{{ $t('hide-title-when-pinned') }}</ui-switch> + <div class="eyeCatch"> <ui-button v-if="eyeCatchingImageId == null && !readonly" @click="setEyeCatchingImage()"><fa :icon="faPlus"/> {{ $t('set-eye-catching-image') }}</ui-button> <div v-else-if="eyeCatchingImage"> @@ -140,6 +142,7 @@ export default Vue.extend({ font: 'sans-serif', content: [], alignCenter: false, + hideTitleWhenPinned: false, variables: [], aiScript: null, showOptions: false, @@ -192,6 +195,7 @@ export default Vue.extend({ this.currentName = this.page.name; this.summary = this.page.summary; this.font = this.page.font; + this.hideTitleWhenPinned = this.page.hideTitleWhenPinned; this.alignCenter = this.page.alignCenter; this.content = this.page.content; this.variables = this.page.variables; @@ -223,6 +227,7 @@ export default Vue.extend({ name: this.name.trim(), summary: this.summary, font: this.font, + hideTitleWhenPinned: this.hideTitleWhenPinned, alignCenter: this.alignCenter, content: this.content, variables: this.variables, @@ -240,6 +245,7 @@ export default Vue.extend({ name: this.name.trim(), summary: this.summary, font: this.font, + hideTitleWhenPinned: this.hideTitleWhenPinned, alignCenter: this.alignCenter, content: this.content, variables: this.variables, diff --git a/src/client/app/common/views/pages/page.vue b/src/client/app/common/views/pages/page.vue new file mode 100644 index 0000000000..d1c4c2be43 --- /dev/null +++ b/src/client/app/common/views/pages/page.vue @@ -0,0 +1,63 @@ +<template> +<x-page v-if="page" :page="page" :key="page.id" :show-footer="true"/> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { faStickyNote } from '@fortawesome/free-regular-svg-icons'; +import XPage from '../components/page/page.vue'; + +export default Vue.extend({ + components: { + XPage + }, + + props: { + pageName: { + type: String, + required: true + }, + username: { + type: String, + required: true + }, + }, + + data() { + return { + page: null, + }; + }, + + computed: { + path(): string { + return this.username + '/' + this.pageName; + } + }, + + watch: { + path() { + this.fetch(); + } + }, + + created() { + this.fetch(); + }, + + methods: { + fetch() { + this.$root.api('pages/show', { + name: this.pageName, + username: this.username, + }).then(page => { + this.page = page; + this.$emit('init', { + title: this.page.title, + icon: faStickyNote + }); + }); + }, + } +}); +</script> diff --git a/src/client/app/common/views/widgets/post-form.vue b/src/client/app/common/views/widgets/post-form.vue index e180290f95..6680a11435 100644 --- a/src/client/app/common/views/widgets/post-form.vue +++ b/src/client/app/common/views/widgets/post-form.vue @@ -38,6 +38,7 @@ import define from '../../../common/define-widget'; import i18n from '../../../i18n'; import insertTextAtCursor from 'insert-text-at-cursor'; +import { formatTimeString } from '../../../../../misc/format-time-string'; export default define({ name: 'post-form', @@ -109,10 +110,23 @@ export default define({ if ((e.which == 10 || e.which == 13) && (e.ctrlKey || e.metaKey) && !this.posting && this.text) this.post(); }, - onPaste(e) { - for (const item of Array.from(e.clipboardData.items)) { + async onPaste(e: ClipboardEvent) { + for (const { item, i } of Array.from(e.clipboardData.items).map((item, i) => ({item, i}))) { if (item.kind == 'file') { - this.upload(item.getAsFile()); + const file = item.getAsFile(); + const lio = file.name.lastIndexOf('.'); + const ext = lio >= 0 ? file.name.slice(lio) : ''; + const formatted = `${formatTimeString(new Date(file.lastModified), this.$store.state.settings.pastedFileName).replace(/{{number}}/g, `${i + 1}`)}${ext}`; + const name = this.$store.state.settings.pasteDialog + ? await this.$root.dialog({ + title: this.$t('@.post-form.enter-file-name'), + input: { + default: formatted + }, + allowEmpty: false + }).then(({ canceled, result }) => canceled ? false : result) + : formatted; + if (name) this.upload(file, name); } } }, @@ -121,8 +135,8 @@ export default define({ for (const x of Array.from((this.$refs.file as any).files)) this.upload(x); }, - upload(file) { - (this.$refs.uploader as any).upload(file); + upload(file: File, name?: string) { + (this.$refs.uploader as any).upload(file, this.$store.state.settings.uploadFolder, name); }, onDragover(e) { diff --git a/src/client/app/desktop/script.ts b/src/client/app/desktop/script.ts index 8065241714..1a4be33020 100644 --- a/src/client/app/desktop/script.ts +++ b/src/client/app/desktop/script.ts @@ -148,6 +148,7 @@ init(async (launch, os) => { { path: '/i/groups', component: DeckColumn, props: route => ({ component: () => import('../common/views/pages/user-groups.vue').then(m => m.default) }) }, { path: '/i/groups/:groupId', component: DeckColumn, props: route => ({ component: () => import('../common/views/pages/user-group-editor.vue').then(m => m.default), groupId: route.params.groupId }) }, { path: '/i/follow-requests', component: DeckColumn, props: route => ({ component: () => import('../common/views/pages/follow-requests.vue').then(m => m.default) }) }, + { path: '/@:username/pages/:pageName', name: 'page', props: true, component: () => import('../common/views/deck/deck.page-column.vue').then(m => m.default) }, ]} : { path: '/', component: MkHome, children: [ { path: '', name: 'index', component: MkHomeTimeline }, @@ -171,12 +172,11 @@ init(async (launch, os) => { { path: '/i/follow-requests', component: () => import('../common/views/pages/follow-requests.vue').then(m => m.default) }, { path: '/i/pages/new', component: () => import('../common/views/pages/page-editor/page-editor.vue').then(m => m.default) }, { path: '/i/pages/edit/:pageId', component: () => import('../common/views/pages/page-editor/page-editor.vue').then(m => m.default), props: route => ({ initPageId: route.params.pageId }) }, - { path: '/@:user/pages/:page', component: () => import('../common/views/pages/page/page.vue').then(m => m.default), props: route => ({ pageName: route.params.page, username: route.params.user }) }, + { path: '/@:user/pages/:page', component: () => import('../common/views/pages/page.vue').then(m => m.default), props: route => ({ pageName: route.params.page, username: route.params.user }) }, { path: '/@:user/pages/:pageName/view-source', component: () => import('../common/views/pages/page-editor/page-editor.vue').then(m => m.default), props: route => ({ initUser: route.params.user, initPageName: route.params.pageName }) }, ]}, { path: '/i/pages/new', component: () => import('../common/views/pages/page-editor/page-editor.vue').then(m => m.default) }, { path: '/i/pages/edit/:pageId', component: () => import('../common/views/pages/page-editor/page-editor.vue').then(m => m.default), props: route => ({ initPageId: route.params.pageId }) }, - { path: '/@:user/pages/:page', component: () => import('../common/views/pages/page/page.vue').then(m => m.default), props: route => ({ pageName: route.params.page, username: route.params.user }) }, { path: '/@:user/pages/:pageName/view-source', component: () => import('../common/views/pages/page-editor/page-editor.vue').then(m => m.default), props: route => ({ initUser: route.params.user, initPageName: route.params.pageName }) }, { path: '/i/messaging/group/:group', component: MkMessagingRoom }, { path: '/i/messaging/:user', component: MkMessagingRoom }, diff --git a/src/client/app/desktop/views/components/drive.folder.vue b/src/client/app/desktop/views/components/drive.folder.vue index fd6de5a05e..cf59d51b01 100644 --- a/src/client/app/desktop/views/components/drive.folder.vue +++ b/src/client/app/desktop/views/components/drive.folder.vue @@ -20,6 +20,9 @@ <template v-if="!hover"><fa :icon="['far', 'folder']" fixed-width/></template> {{ folder.name }} </p> + <p class="upload" v-if="$store.state.settings.uploadFolder == folder.id"> + {{ $t('upload-folder') }} + </p> </div> </template> @@ -73,6 +76,14 @@ export default Vue.extend({ text: this.$t('@.delete'), icon: ['far', 'trash-alt'], action: this.deleteFolder + }, null, { + type: 'nest', + text: this.$t('contextmenu.else-folders'), + menu: [{ + type: 'item', + text: this.$t('contextmenu.set-as-upload-folder'), + action: this.setAsUploadFolder + }] }], { closed: () => { this.isContextmenuShowing = false; @@ -213,8 +224,37 @@ export default Vue.extend({ deleteFolder() { this.$root.api('drive/folders/delete', { folderId: this.folder.id + }).then(() => { + if (this.$store.state.settings.uploadFolder === this.folder.id) { + this.$store.dispatch('settings/set', { + key: 'uploadFolder', + value: null + }); + } + }).catch(err => { + switch(err.id) { + case 'b0fc8a17-963c-405d-bfbc-859a487295e1': + this.$root.dialog({ + type: 'error', + title: this.$t('unable-to-delete'), + text: this.$t('has-child-files-or-folders') + }); + break; + default: + this.$root.dialog({ + type: 'error', + text: this.$t('unable-to-delete') + }); + } }); - } + }, + + setAsUploadFolder() { + this.$store.dispatch('settings/set', { + key: 'uploadFolder', + value: this.folder.id + }); + }, } }); </script> @@ -264,4 +304,10 @@ export default Vue.extend({ margin-left 2px text-align left + > .upload + margin 4px 4px + font-size 0.8em + text-align right + color var(--desktopDriveFolderFg) + </style> diff --git a/src/client/app/desktop/views/components/notifications.vue b/src/client/app/desktop/views/components/notifications.vue index 6a8bc48ff2..b25f122e0e 100644 --- a/src/client/app/desktop/views/components/notifications.vue +++ b/src/client/app/desktop/views/components/notifications.vue @@ -24,7 +24,7 @@ </p> <router-link class="note-ref" :to="notification.note | notePage" :title="getNoteSummary(notification.note)"> <fa icon="quote-left"/> - <mfm :text="getNoteSummary(notification.note)" :should-break="false" :plain-text="true" :custom-emojis="notification.note.emojis"/> + <mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="true" :custom-emojis="notification.note.emojis"/> <fa icon="quote-right"/> </router-link> </div> @@ -40,7 +40,7 @@ </p> <router-link class="note-ref" :to="notification.note | notePage" :title="getNoteSummary(notification.note.renote)"> <fa icon="quote-left"/> - <mfm :text="getNoteSummary(notification.note.renote)" :should-break="false" :plain-text="true" :custom-emojis="notification.note.renote.emojis"/> + <mfm :text="getNoteSummary(notification.note.renote)" :plain="true" :nowrap="true" :custom-emojis="notification.note.renote.emojis"/> <fa icon="quote-right"/> </router-link> </div> @@ -55,7 +55,7 @@ </router-link> </p> <router-link class="note-preview" :to="notification.note | notePage" :title="getNoteSummary(notification.note)"> - <mfm :text="getNoteSummary(notification.note)" :should-break="false" :plain-text="true" :custom-emojis="notification.note.emojis"/> + <mfm :text="getNoteSummary(notification.note)" :plain="true" :custom-emojis="notification.note.emojis"/> </router-link> </div> </template> @@ -91,7 +91,7 @@ </router-link> </p> <router-link class="note-preview" :to="notification.note | notePage" :title="getNoteSummary(notification.note)"> - <mfm :text="getNoteSummary(notification.note)" :should-break="false" :plain-text="true" :custom-emojis="notification.note.emojis"/> + <mfm :text="getNoteSummary(notification.note)" :plain="true" :custom-emojis="notification.note.emojis"/> </router-link> </div> </template> @@ -105,7 +105,7 @@ </router-link> </p> <router-link class="note-preview" :to="notification.note | notePage" :title="getNoteSummary(notification.note)"> - <mfm :text="getNoteSummary(notification.note)" :should-break="false" :plain-text="true" :custom-emojis="notification.note.emojis"/> + <mfm :text="getNoteSummary(notification.note)" :plain="true" :custom-emojis="notification.note.emojis"/> </router-link> </div> </template> @@ -118,7 +118,7 @@ </router-link></p> <router-link class="note-ref" :to="notification.note | notePage" :title="getNoteSummary(notification.note)"> <fa icon="quote-left"/> - <mfm :text="getNoteSummary(notification.note)" :should-break="false" :plain-text="true" :custom-emojis="notification.note.emojis"/> + <mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="true" :custom-emojis="notification.note.emojis"/> <fa icon="quote-right"/> </router-link> </div> diff --git a/src/client/app/desktop/views/home/home.vue b/src/client/app/desktop/views/home/home.vue index 3d05bee48d..527c2d52ef 100644 --- a/src/client/app/desktop/views/home/home.vue +++ b/src/client/app/desktop/views/home/home.vue @@ -62,7 +62,7 @@ </div> </template> <template v-else> - <div v-for="place in ['left', 'right']" :class="place"> + <div v-for="place in ['left', 'right']" :class="place" :key="place"> <component v-for="widget in widgets[place]" :is="`mkw-${widget.name}`" :key="widget.id" :ref="widget.id" :widget="widget" platform="desktop"/> </div> <div class="main"> @@ -392,7 +392,7 @@ export default Vue.extend({ margin 0 auto &:not(.side) - @media (max-width 1200px) + @media (max-width 1100px) > *:not(.main) display none diff --git a/src/client/app/desktop/views/home/timeline.vue b/src/client/app/desktop/views/home/timeline.vue index d8da6e4e26..b870395c18 100644 --- a/src/client/app/desktop/views/home/timeline.vue +++ b/src/client/app/desktop/views/home/timeline.vue @@ -4,12 +4,12 @@ <div class="main"> <component :is="src == 'list' ? 'mk-user-list-timeline' : 'x-core'" ref="tl" v-bind="options"> <header class="zahtxcqi"> - <span :data-active="src == 'home'" @click="src = 'home'"><fa icon="home"/> {{ $t('home') }}</span> - <span :data-active="src == 'local'" @click="src = 'local'" v-if="enableLocalTimeline"><fa :icon="['far', 'comments']"/> {{ $t('local') }}</span> - <span :data-active="src == 'hybrid'" @click="src = 'hybrid'" v-if="enableLocalTimeline"><fa icon="share-alt"/> {{ $t('hybrid') }}</span> - <span :data-active="src == 'global'" @click="src = 'global'" v-if="enableGlobalTimeline"><fa icon="globe"/> {{ $t('global') }}</span> - <span :data-active="src == 'tag'" @click="src = 'tag'" v-if="tagTl"><fa icon="hashtag"/> {{ tagTl.title }}</span> - <span :data-active="src == 'list'" @click="src = 'list'" v-if="list"><fa icon="list"/> {{ list.name }}</span> + <div :data-active="src == 'home'" @click="src = 'home'"><fa icon="home"/> {{ $t('home') }}</div> + <div :data-active="src == 'local'" @click="src = 'local'" v-if="enableLocalTimeline"><fa :icon="['far', 'comments']"/> {{ $t('local') }}</div> + <div :data-active="src == 'hybrid'" @click="src = 'hybrid'" v-if="enableLocalTimeline"><fa icon="share-alt"/> {{ $t('hybrid') }}</div> + <div :data-active="src == 'global'" @click="src = 'global'" v-if="enableGlobalTimeline"><fa icon="globe"/> {{ $t('global') }}</div> + <div :data-active="src == 'tag'" @click="src = 'tag'" v-if="tagTl"><fa icon="hashtag"/> {{ tagTl.title }}</div> + <div :data-active="src == 'list'" @click="src = 'list'" v-if="list"><fa icon="list"/> {{ list.name }}</div> <div class="buttons"> <button :data-active="src == 'mentions'" @click="src = 'mentions'" :title="$t('mentions')"><fa icon="at"/><i class="indicator" v-if="$store.state.i.hasUnreadMentions"><fa icon="circle"/></i></button> <button :data-active="src == 'messages'" @click="src = 'messages'" :title="$t('messages')"><fa :icon="['far', 'envelope']"/><i class="indicator" v-if="$store.state.i.hasUnreadSpecifiedNotes"><fa icon="circle"/></i></button> @@ -200,18 +200,19 @@ export default Vue.extend({ &.shadow box-shadow 0 3px 8px rgba(0, 0, 0, 0.2) - .zahtxcqi + header.zahtxcqi + display flex + flex-wrap wrap padding 0 8px z-index 10 background var(--faceHeader) box-shadow 0 var(--lineWidth) var(--desktopTimelineHeaderShadow) + > * + flex-shrink 0 + > .buttons - position absolute - z-index 2 - top 0 - right 0 - padding-right 8px + margin-left auto > button padding 0 8px @@ -244,8 +245,7 @@ export default Vue.extend({ height 2px background var(--primary) - > span - display inline-block + > div:not(.buttons) padding 0 10px line-height 42px font-size 12px diff --git a/src/client/app/desktop/views/home/user/user.header.vue b/src/client/app/desktop/views/home/user/user.header.vue index 52a5165c3f..ca4ad0802c 100644 --- a/src/client/app/desktop/views/home/user/user.header.vue +++ b/src/client/app/desktop/views/home/user/user.header.vue @@ -28,7 +28,7 @@ <div class="fields" v-if="user.fields"> <dl class="field" v-for="(field, i) in user.fields" :key="i"> <dt class="name"> - <mfm :text="field.name" :should-break="false" :plain-text="true" :custom-emojis="user.emojis"/> + <mfm :text="field.name" :plain="true" :custom-emojis="user.emojis"/> </dt> <dd class="value"> <mfm :text="field.value" :author="user" :i="$store.state.i" :custom-emojis="user.emojis"/> diff --git a/src/client/app/desktop/views/home/user/user.home.vue b/src/client/app/desktop/views/home/user/user.home.vue index ec533efd3e..c47e0a0771 100644 --- a/src/client/app/desktop/views/home/user/user.home.vue +++ b/src/client/app/desktop/views/home/user/user.home.vue @@ -1,5 +1,6 @@ <template> <div class="lnctpgve"> + <x-page v-if="user.pinnedPage" :page="user.pinnedPage" :key="user.pinnedPage.id" :show-title="!user.pinnedPage.hideTitleWhenPinned"/> <mk-note-detail v-for="n in user.pinnedNotes" :key="n.id" :note="n" :compact="true"/> <!--<mk-calendar @chosen="warp" :start="new Date(user.createdAt)"/>--> <div class="activity"> @@ -21,13 +22,15 @@ import i18n from '../../../../i18n'; import XTimeline from './user.timeline.vue'; import XPhotos from './user.photos.vue'; import XActivity from '../../../../common/views/components/activity.vue'; +import XPage from '../../../../common/views/components/page/page.vue'; export default Vue.extend({ i18n: i18n(), components: { XTimeline, XPhotos, - XActivity + XActivity, + XPage, }, props: { user: { diff --git a/src/client/app/desktop/views/widgets/profile.vue b/src/client/app/desktop/views/widgets/profile.vue index 57a604ff49..bad1925f69 100644 --- a/src/client/app/desktop/views/widgets/profile.vue +++ b/src/client/app/desktop/views/widgets/profile.vue @@ -118,6 +118,8 @@ export default define({ line-height 16px font-weight bold color var(--text) + overflow hidden + text-overflow ellipsis > .username display block diff --git a/src/client/app/mobile/script.ts b/src/client/app/mobile/script.ts index d04662cc1f..6222409931 100644 --- a/src/client/app/mobile/script.ts +++ b/src/client/app/mobile/script.ts @@ -129,6 +129,7 @@ init((launch, os) => { { path: '/i/groups', component: DeckColumn, props: route => ({ component: () => import('../common/views/pages/user-groups.vue').then(m => m.default) }) }, { path: '/i/groups/:groupId', component: DeckColumn, props: route => ({ component: () => import('../common/views/pages/user-group-editor.vue').then(m => m.default), groupId: route.params.groupId }) }, { path: '/i/follow-requests', component: DeckColumn, props: route => ({ component: () => import('../common/views/pages/follow-requests.vue').then(m => m.default) }) }, + { path: '/@:username/pages/:pageName', name: 'page', props: true, component: () => import('../common/views/deck/deck.page-column.vue').then(m => m.default) }, ]}] : [ { path: '/', name: 'index', component: MkIndex }, @@ -163,7 +164,7 @@ init((launch, os) => { { path: 'following', component: () => import('../common/views/pages/following.vue').then(m => m.default) }, { path: 'followers', component: () => import('../common/views/pages/followers.vue').then(m => m.default) }, ]}, - { path: '/@:user/pages/:page', component: UI, props: route => ({ component: () => import('../common/views/pages/page/page.vue').then(m => m.default), pageName: route.params.page, username: route.params.user }) }, + { path: '/@:user/pages/:page', component: UI, props: route => ({ component: () => import('../common/views/pages/page.vue').then(m => m.default), pageName: route.params.page, username: route.params.user }) }, { path: '/@:user/pages/:pageName/view-source', component: UI, props: route => ({ component: () => import('../common/views/pages/page-editor/page-editor.vue').then(m => m.default), initUser: route.params.user, initPageName: route.params.pageName }) }, { path: '/notes/:note', component: MkNote }, { path: '/authorize-follow', component: MkFollow }, diff --git a/src/client/app/mobile/views/components/notification.vue b/src/client/app/mobile/views/components/notification.vue index 9dae95c9b1..448d22cbe6 100644 --- a/src/client/app/mobile/views/components/notification.vue +++ b/src/client/app/mobile/views/components/notification.vue @@ -10,7 +10,7 @@ </header> <router-link class="note-ref" :to="notification.note | notePage" :title="getNoteSummary(notification.note)"> <fa icon="quote-left"/> - <mfm :text="getNoteSummary(notification.note)" :should-break="false" :plain-text="true" :custom-emojis="notification.note.emojis"/> + <mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="true" :custom-emojis="notification.note.emojis"/> <fa icon="quote-right"/> </router-link> </div> @@ -26,7 +26,7 @@ </header> <router-link class="note-ref" :to="notification.note | notePage" :title="getNoteSummary(notification.note.renote)"> <fa icon="quote-left"/> - <mfm :text="getNoteSummary(notification.note.renote)" :should-break="false" :plain-text="true" :custom-emojis="notification.note.renote.emojis"/> + <mfm :text="getNoteSummary(notification.note.renote)" :plain="true" :nowrap="true" :custom-emojis="notification.note.renote.emojis"/> <fa icon="quote-right"/> </router-link> </div> @@ -64,7 +64,7 @@ </header> <router-link class="note-ref" :to="notification.note | notePage" :title="getNoteSummary(notification.note)"> <fa icon="quote-left"/> - <mfm :text="getNoteSummary(notification.note)" :should-break="false" :plain-text="true" :custom-emojis="notification.note.emojis"/> + <mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="true" :custom-emojis="notification.note.emojis"/> <fa icon="quote-right"/> </router-link> </div> diff --git a/src/client/app/mobile/views/pages/settings.vue b/src/client/app/mobile/views/pages/settings.vue index da01ef6221..c24a56be7b 100644 --- a/src/client/app/mobile/views/pages/settings.vue +++ b/src/client/app/mobile/views/pages/settings.vue @@ -3,7 +3,7 @@ <template #header><span style="margin-right:4px;"><fa icon="cog"/></span>{{ $t('@.settings') }}</template> <main> <div class="signed-in-as" :class="{ shadow: $store.state.device.useShadow, round: $store.state.device.roundedCorners }"> - <mfm :text="$t('signed-in-as').replace('{}', name)" :should-break="false" :plain-text="true" :custom-emojis="$store.state.i.emojis"/> + <mfm :text="$t('signed-in-as').replace('{}', name)" :plain="true" :custom-emojis="$store.state.i.emojis"/> </div> <x-settings/> diff --git a/src/client/app/mobile/views/pages/user/home.vue b/src/client/app/mobile/views/pages/user/home.vue index 1d7b0a4e6d..316b2a12fe 100644 --- a/src/client/app/mobile/views/pages/user/home.vue +++ b/src/client/app/mobile/views/pages/user/home.vue @@ -1,5 +1,6 @@ <template> <div class="wojmldye"> + <x-page class="page" v-if="user.pinnedPage" :page="user.pinnedPage" :key="user.pinnedPage.id" :show-title="!user.pinnedPage.hideTitleWhenPinned"/> <mk-note-detail class="note" v-for="n in user.pinnedNotes" :key="n.id" :note="n" :compact="true"/> <ui-container :body-togglable="true"> <template #header><fa :icon="['far', 'comments']"/>{{ $t('recent-notes') }}</template> @@ -33,6 +34,7 @@ export default Vue.extend({ components: { XNotes, XPhotos, + XPage: () => import('../../../../common/views/components/page/page.vue').then(m => m.default), XActivity: () => import('../../../../common/views/components/activity.vue').then(m => m.default) }, props: ['user'], @@ -53,6 +55,12 @@ export default Vue.extend({ <style lang="stylus" scoped> .wojmldye + > .page + margin 0 0 8px 0 + + @media (min-width 500px) + margin 0 0 16px 0 + > .note margin 0 0 8px 0 diff --git a/src/client/app/mobile/views/pages/user/index.vue b/src/client/app/mobile/views/pages/user/index.vue index f8f4719a68..2e4998178f 100644 --- a/src/client/app/mobile/views/pages/user/index.vue +++ b/src/client/app/mobile/views/pages/user/index.vue @@ -28,7 +28,7 @@ <div class="fields" v-if="user.fields"> <dl class="field" v-for="(field, i) in user.fields" :key="i"> <dt class="name"> - <mfm :text="field.name" :should-break="false" :plain-text="true" :custom-emojis="user.emojis"/> + <mfm :text="field.name" :plain="true" :custom-emojis="user.emojis"/> </dt> <dd class="value"> <mfm :text="field.value" :author="user" :i="$store.state.i" :custom-emojis="user.emojis"/> diff --git a/src/client/app/store.ts b/src/client/app/store.ts index 852d2c393d..18137c1ca9 100644 --- a/src/client/app/store.ts +++ b/src/client/app/store.ts @@ -38,6 +38,9 @@ const defaultSettings = { homeProfiles: {}, mobileHomeProfiles: {}, deckProfiles: {}, + uploadFolder: null, + pastedFileName: 'yyyy-MM-dd HH-mm-ss [{{number}}]', + pasteDialog: false, }; const defaultDeviceSettings = { @@ -60,6 +63,7 @@ const defaultDeviceSettings = { soundVolume: 0.5, mediaVolume: 0.5, lang: null, + appTypeForce: 'auto', debug: false, lightmode: false, loadRawImages: false, diff --git a/src/db/postgre.ts b/src/db/postgre.ts index 94a19b06be..638d5720b7 100644 --- a/src/db/postgre.ts +++ b/src/db/postgre.ts @@ -150,11 +150,9 @@ export function initDb(justBorrow = false, sync = false, log = false) { options: { host: config.redis.host, port: config.redis.port, - options: { - password: config.redis.pass, - prefix: config.redis.prefix, - db: config.redis.db || 0 - } + password: config.redis.pass, + prefix: config.redis.prefix, + db: config.redis.db || 0 } } : false, logging: log, diff --git a/src/misc/format-time-string.ts b/src/misc/format-time-string.ts new file mode 100644 index 0000000000..caa31780ba --- /dev/null +++ b/src/misc/format-time-string.ts @@ -0,0 +1,50 @@ +const defaultLocaleStringFormats: {[index: string]: string} = { + 'weekday': 'narrow', + 'era': 'narrow', + 'year': 'numeric', + 'month': 'numeric', + 'day': 'numeric', + 'hour': 'numeric', + 'minute': 'numeric', + 'second': 'numeric', + 'timeZoneName': 'short' +}; + +function formatLocaleString(date: Date, format: string): string { + return format.replace(/\{\{(\w+)(:(\w+))?\}\}/g, (match: string, kind: string, unused?, option?: string) => { + if (['weekday', 'era', 'year', 'month', 'day', 'hour', 'minute', 'second', 'timeZoneName'].includes(kind)) { + return date.toLocaleString(window.navigator.language, {[kind]: option ? option : defaultLocaleStringFormats[kind]}); + } else { + return match; + } + }); +} + +function formatDateTimeString(date: Date, format: string): string { + return format + .replace(/yyyy/g, date.getFullYear().toString()) + .replace(/yy/g, date.getFullYear().toString().slice(-2)) + .replace(/MMMM/g, date.toLocaleString(window.navigator.language, { month: 'long'})) + .replace(/MMM/g, date.toLocaleString(window.navigator.language, { month: 'short'})) + .replace(/MM/g, (`0${date.getMonth() + 1}`).slice(-2)) + .replace(/M/g, (date.getMonth() + 1).toString()) + .replace(/dd/g, (`0${date.getDate()}`).slice(-2)) + .replace(/d/g, date.getDate().toString()) + .replace(/HH/g, (`0${date.getHours()}`).slice(-2)) + .replace(/H/g, date.getHours().toString()) + .replace(/hh/g, (`0${(date.getHours() % 12) || 12}`).slice(-2)) + .replace(/h/g, ((date.getHours() % 12) || 12).toString()) + .replace(/mm/g, (`0${date.getMinutes()}`).slice(-2)) + .replace(/m/g, date.getMinutes().toString()) + .replace(/ss/g, (`0${date.getSeconds()}`).slice(-2)) + .replace(/s/g, date.getSeconds().toString()) + .replace(/tt/g, date.getHours() >= 12 ? 'PM' : 'AM'); +} + +export function formatTimeString(date: Date, format: string): string { + return format.replace(/\[(([^\[]|\[\])*)\]|(([yMdHhmst])\4{0,3})/g, (match: string, localeformat?: string, unused?, datetimeformat?: string) => { + if (localeformat) return formatLocaleString(date, localeformat); + if (datetimeformat) return formatDateTimeString(date, datetimeformat); + return match; + }); +} diff --git a/src/models/entities/page.ts b/src/models/entities/page.ts index 05015ba175..2163f9997f 100644 --- a/src/models/entities/page.ts +++ b/src/models/entities/page.ts @@ -40,6 +40,11 @@ export class Page { @Column('boolean') public alignCenter: boolean; + @Column('boolean', { + default: false + }) + public hideTitleWhenPinned: boolean; + @Column('varchar', { length: 32, }) diff --git a/src/models/entities/user-profile.ts b/src/models/entities/user-profile.ts index 6f960f1b7b..61e80049c3 100644 --- a/src/models/entities/user-profile.ts +++ b/src/models/entities/user-profile.ts @@ -1,6 +1,7 @@ import { Entity, Column, Index, OneToOne, JoinColumn, PrimaryColumn } from 'typeorm'; import { id } from '../id'; import { User } from './user'; +import { Page } from './page'; @Entity() export class UserProfile { @@ -81,6 +82,11 @@ export class UserProfile { }) public securityKeysAvailable: boolean; + @Column('boolean', { + default: false, + }) + public usePasswordLessLogin: boolean; + @Column('varchar', { length: 128, nullable: true, comment: 'The password hash of the User. It will be null if the origin of the user is local.' @@ -113,6 +119,18 @@ export class UserProfile { }) public carefulBot: boolean; + @Column({ + ...id(), + nullable: true + }) + public pinnedPageId: Page['id'] | null; + + @OneToOne(type => Page, { + onDelete: 'SET NULL' + }) + @JoinColumn() + public pinnedPage: Page | null; + //#region Linking @Column('boolean', { default: false, diff --git a/src/models/repositories/page.ts b/src/models/repositories/page.ts index 33126274a1..1335ada73f 100644 --- a/src/models/repositories/page.ts +++ b/src/models/repositories/page.ts @@ -71,6 +71,7 @@ export class PageRepository extends Repository<Page> { title: page.title, name: page.name, summary: page.summary, + hideTitleWhenPinned: page.hideTitleWhenPinned, alignCenter: page.alignCenter, font: page.font, eyeCatchingImageId: page.eyeCatchingImageId, diff --git a/src/models/repositories/user.ts b/src/models/repositories/user.ts index cc89b674c5..4e85fd7b93 100644 --- a/src/models/repositories/user.ts +++ b/src/models/repositories/user.ts @@ -1,7 +1,7 @@ import $ from 'cafy'; import { EntityRepository, Repository, In } from 'typeorm'; import { User, ILocalUser, IRemoteUser } from '../entities/user'; -import { Emojis, Notes, NoteUnreads, FollowRequests, Notifications, MessagingMessages, UserNotePinings, Followings, Blockings, Mutings, UserProfiles, UserSecurityKeys, UserGroupJoinings } from '..'; +import { Emojis, Notes, NoteUnreads, FollowRequests, Notifications, MessagingMessages, UserNotePinings, Followings, Blockings, Mutings, UserProfiles, UserSecurityKeys, UserGroupJoinings, Pages } from '..'; import { ensure } from '../../prelude/ensure'; import config from '../../config'; import { SchemaType } from '../../misc/schema'; @@ -155,7 +155,10 @@ export class UserRepository extends Repository<User> { pinnedNotes: Notes.packMany(pins.map(pin => pin.noteId), meId, { detail: true }), + pinnedPageId: profile!.pinnedPageId, + pinnedPage: profile!.pinnedPageId ? Pages.pack(profile!.pinnedPageId, meId) : null, twoFactorEnabled: profile!.twoFactorEnabled, + usePasswordLessLogin: profile!.usePasswordLessLogin, securityKeys: profile!.twoFactorEnabled ? UserSecurityKeys.count({ userId: user.id @@ -208,7 +211,6 @@ export class UserRepository extends Repository<User> { select: ['id', 'name', 'lastUsed'] }) : [] - } : {}), ...(relation ? { diff --git a/src/server/api/endpoints/drive/files/create.ts b/src/server/api/endpoints/drive/files/create.ts index 664a2b87b2..61055c5d18 100644 --- a/src/server/api/endpoints/drive/files/create.ts +++ b/src/server/api/endpoints/drive/files/create.ts @@ -35,6 +35,14 @@ export const meta = { } }, + name: { + validator: $.optional.nullable.str, + default: null as any, + desc: { + 'ja-JP': 'ファイル名(拡張子があるなら含めて)' + } + }, + isSensitive: { validator: $.optional.either($.bool, $.str), default: false, @@ -72,7 +80,7 @@ export const meta = { export default define(meta, async (ps, user, app, file, cleanup) => { // Get 'name' parameter - let name = file.originalname; + let name = ps.name || file.originalname; if (name !== undefined && name !== null) { name = name.trim(); if (name.length === 0) { diff --git a/src/server/api/endpoints/i/2fa/password-less.ts b/src/server/api/endpoints/i/2fa/password-less.ts new file mode 100644 index 0000000000..19e75ca1c5 --- /dev/null +++ b/src/server/api/endpoints/i/2fa/password-less.ts @@ -0,0 +1,21 @@ +import $ from 'cafy'; +import define from '../../../define'; +import { UserProfiles } from '../../../../../models'; + +export const meta = { + requireCredential: true, + + secure: true, + + params: { + value: { + validator: $.boolean + } + } +}; + +export default define(meta, async (ps, user) => { + await UserProfiles.update(user.id, { + usePasswordLessLogin: ps.value + }); +}); diff --git a/src/server/api/endpoints/i/update.ts b/src/server/api/endpoints/i/update.ts index 10521d12d8..a454cdb940 100644 --- a/src/server/api/endpoints/i/update.ts +++ b/src/server/api/endpoints/i/update.ts @@ -10,7 +10,7 @@ import extractHashtags from '../../../../misc/extract-hashtags'; import * as langmap from 'langmap'; import { updateHashtag } from '../../../../services/update-hashtag'; import { ApiError } from '../../error'; -import { Users, DriveFiles, UserProfiles } from '../../../../models'; +import { Users, DriveFiles, UserProfiles, Pages } from '../../../../models'; import { User } from '../../../../models/entities/user'; import { UserProfile } from '../../../../models/entities/user-profile'; import { ensure } from '../../../../prelude/ensure'; @@ -125,6 +125,13 @@ export const meta = { 'ja-JP': 'アップロードするメディアをデフォルトで「閲覧注意」として設定するか' } }, + + pinnedPageId: { + validator: $.optional.nullable.type(ID), + desc: { + 'ja-JP': 'ピン留めするページID' + } + } }, errors: { @@ -150,7 +157,13 @@ export const meta = { message: 'The file specified as a banner is not an image.', code: 'BANNER_NOT_AN_IMAGE', id: '75aedb19-2afd-4e6d-87fc-67941256fa60' - } + }, + + noSuchPage: { + message: 'No such page.', + code: 'NO_SUCH_PAGE', + id: '8e01b590-7eb9-431b-a239-860e086c408e' + }, } }; @@ -203,6 +216,16 @@ export default define(meta, async (ps, user, app) => { } } + if (ps.pinnedPageId) { + const page = await Pages.findOne(ps.pinnedPageId); + + if (page == null || page.userId !== user.id) throw new ApiError(meta.errors.noSuchPage); + + profileUpdates.pinnedPageId = page.id; + } else if (ps.pinnedPageId === null) { + profileUpdates.pinnedPageId = null; + } + //#region emojis/tags let emojis = [] as string[]; diff --git a/src/server/api/endpoints/page-push.ts b/src/server/api/endpoints/page-push.ts new file mode 100644 index 0000000000..f5e1a4d1eb --- /dev/null +++ b/src/server/api/endpoints/page-push.ts @@ -0,0 +1,49 @@ +import $ from 'cafy'; +import define from '../define'; +import { ID } from '../../../misc/cafy-id'; +import { publishMainStream } from '../../../services/stream'; +import { Users, Pages } from '../../../models'; +import { ApiError } from '../error'; + +export const meta = { + requireCredential: true, + secure: true, + + params: { + pageId: { + validator: $.type(ID) + }, + + event: { + validator: $.str + }, + + var: { + validator: $.optional.nullable.any + } + }, + + errors: { + noSuchPage: { + message: 'No such page.', + code: 'NO_SUCH_PAGE', + id: '4a13ad31-6729-46b4-b9af-e86b265c2e74' + } + } +}; + +export default define(meta, async (ps, user) => { + const page = await Pages.findOne(ps.pageId); + if (page == null) { + throw new ApiError(meta.errors.noSuchPage); + } + + publishMainStream(user.id, 'pageEvent', { + pageId: ps.pageId, + event: ps.event, + var: ps.var, + user: await Users.pack(user, page.userId, { + detail: true + }) + }); +}); diff --git a/src/server/api/endpoints/pages/create.ts b/src/server/api/endpoints/pages/create.ts index ffe0d38ea6..a49a5d37b8 100644 --- a/src/server/api/endpoints/pages/create.ts +++ b/src/server/api/endpoints/pages/create.ts @@ -57,6 +57,11 @@ export const meta = { validator: $.optional.bool, default: false }, + + hideTitleWhenPinned: { + validator: $.optional.bool, + default: false + }, }, res: { @@ -100,6 +105,7 @@ export default define(meta, async (ps, user) => { userId: user.id, visibility: 'public', alignCenter: ps.alignCenter, + hideTitleWhenPinned: ps.hideTitleWhenPinned, font: ps.font })); diff --git a/src/server/api/endpoints/pages/update.ts b/src/server/api/endpoints/pages/update.ts index 8ee34fc3ba..9daf5e9cae 100644 --- a/src/server/api/endpoints/pages/update.ts +++ b/src/server/api/endpoints/pages/update.ts @@ -61,6 +61,10 @@ export const meta = { alignCenter: { validator: $.optional.bool, }, + + hideTitleWhenPinned: { + validator: $.optional.bool, + }, }, errors: { @@ -113,6 +117,7 @@ export default define(meta, async (ps, user) => { content: ps.content, variables: ps.variables, alignCenter: ps.alignCenter === undefined ? page.alignCenter : ps.alignCenter, + hideTitleWhenPinned: ps.hideTitleWhenPinned === undefined ? page.hideTitleWhenPinned : ps.hideTitleWhenPinned, font: ps.font === undefined ? page.font : ps.font, eyeCatchingImageId: ps.eyeCatchingImageId === null ? null diff --git a/src/server/api/private/signin.ts b/src/server/api/private/signin.ts index bc9346d088..eb267aa604 100644 --- a/src/server/api/private/signin.ts +++ b/src/server/api/private/signin.ts @@ -72,19 +72,25 @@ export default async (ctx: Koa.BaseContext) => { } } - if (!same) { - await fail(403, { - error: 'incorrect password' - }); - return; - } - if (!profile.twoFactorEnabled) { - signin(ctx, user); + if (same) { + signin(ctx, user); + } else { + await fail(403, { + error: 'incorrect password' + }); + } return; } if (token) { + if (!same) { + await fail(403, { + error: 'incorrect password' + }); + return; + } + const verified = (speakeasy as any).totp.verify({ secret: profile.twoFactorSecret, encoding: 'base32', @@ -101,6 +107,13 @@ export default async (ctx: Koa.BaseContext) => { return; } } else if (body.credentialId) { + if (!same && !profile.usePasswordLessLogin) { + await fail(403, { + error: 'incorrect password' + }); + return; + } + const clientDataJSON = Buffer.from(body.clientDataJSON, 'hex'); const clientData = JSON.parse(clientDataJSON.toString('utf-8')); const challenge = await AttestationChallenges.findOne({ @@ -163,6 +176,13 @@ export default async (ctx: Koa.BaseContext) => { return; } } else { + if (!same && !profile.usePasswordLessLogin) { + await fail(403, { + error: 'incorrect password' + }); + return; + } + const keys = await UserSecurityKeys.find({ userId: user.id }); @@ -201,5 +221,4 @@ export default async (ctx: Koa.BaseContext) => { } await fail(); - return; }; diff --git a/src/services/drive/add-file.ts b/src/services/drive/add-file.ts index 2f0d5d6265..52339a2f2a 100644 --- a/src/services/drive/add-file.ts +++ b/src/services/drive/add-file.ts @@ -71,7 +71,7 @@ async function save(file: DriveFile, path: string, name: string, type: string, h ]; if (alts.webpublic) { - webpublicKey = `${meta.objectStoragePrefix}/${uuid.v4()}.${alts.webpublic.ext}`; + webpublicKey = `${meta.objectStoragePrefix}/webpublic-${uuid.v4()}.${alts.webpublic.ext}`; webpublicUrl = `${ baseUrl }/${ webpublicKey }`; logger.info(`uploading webpublic: ${webpublicKey}`); @@ -79,7 +79,7 @@ async function save(file: DriveFile, path: string, name: string, type: string, h } if (alts.thumbnail) { - thumbnailKey = `${meta.objectStoragePrefix}/${uuid.v4()}.${alts.thumbnail.ext}`; + thumbnailKey = `${meta.objectStoragePrefix}/thumbnail-${uuid.v4()}.${alts.thumbnail.ext}`; thumbnailUrl = `${ baseUrl }/${ thumbnailKey }`; logger.info(`uploading thumbnail: ${thumbnailKey}`); @@ -104,8 +104,8 @@ async function save(file: DriveFile, path: string, name: string, type: string, h return await DriveFiles.save(file); } else { // use internal storage const accessKey = uuid.v4(); - const thumbnailAccessKey = uuid.v4(); - const webpublicAccessKey = uuid.v4(); + const thumbnailAccessKey = 'thumbnail-' + uuid.v4(); + const webpublicAccessKey = 'webpublic-' + uuid.v4(); const url = InternalStorage.saveFromPath(accessKey, path); |