diff options
Diffstat (limited to 'src')
47 files changed, 1048 insertions, 207 deletions
diff --git a/src/client/app/common/scripts/streaming/home.ts b/src/client/app/common/scripts/streaming/home.ts index 50bbb56896..f07d0289f6 100644 --- a/src/client/app/common/scripts/streaming/home.ts +++ b/src/client/app/common/scripts/streaming/home.ts @@ -20,7 +20,7 @@ export class HomeStream extends Stream { }, 1000 * 60); // 自分の情報が更新されたとき - this.on('i_updated', i => { + this.on('meUpdated', i => { if (os.debug) { console.log('I updated:', i); } diff --git a/src/client/app/desktop/views/components/follow-button.vue b/src/client/app/desktop/views/components/follow-button.vue index dae7604957..c7549c374e 100644 --- a/src/client/app/desktop/views/components/follow-button.vue +++ b/src/client/app/desktop/views/components/follow-button.vue @@ -1,19 +1,16 @@ <template> <button class="mk-follow-button" - :class="{ wait, follow: !user.isFollowing, unfollow: user.isFollowing, big: size == 'big' }" + :class="{ wait, active: u.isFollowing || u.hasPendingFollowRequestFromYou, big: size == 'big' }" @click="onClick" :disabled="wait" - :title="user.isFollowing ? '%i18n:@unfollow%' : '%i18n:@follow%'" > - <template v-if="!wait && user.isFollowing"> - <template v-if="size == 'compact'">%fa:minus%</template> - <template v-if="size == 'big'">%fa:minus%%i18n:@unfollow%</template> + <template v-if="!wait"> + <template v-if="u.hasPendingFollowRequestFromYou">%fa:hourglass-half%<template v-if="size == 'big'"> %i18n:@request-pending%</template></template> + <template v-else-if="u.isFollowing">%fa:minus%<template v-if="size == 'big'"> %i18n:@unfollow%</template></template> + <template v-else-if="!u.isFollowing && u.isLocked">%fa:plus%<template v-if="size == 'big'"> %i18n:@follow-request%</template></template> + <template v-else-if="!u.isFollowing && !u.isLocked">%fa:plus%<template v-if="size == 'big'"> %i18n:@follow%</template></template> </template> - <template v-if="!wait && !user.isFollowing"> - <template v-if="size == 'compact'">%fa:plus%</template> - <template v-if="size == 'big'">%fa:plus%%i18n:@follow%</template> - </template> - <template v-if="wait">%fa:spinner .pulse .fw%</template> + <template v-else>%fa:spinner .pulse .fw%</template> </button> </template> @@ -34,6 +31,7 @@ export default Vue.extend({ data() { return { + u: this.user, wait: false, connection: null, connectionId: null @@ -56,39 +54,44 @@ export default Vue.extend({ methods: { onFollow(user) { - if (user.id == this.user.id) { + if (user.id == this.u.id) { this.user.isFollowing = user.isFollowing; } }, onUnfollow(user) { - if (user.id == this.user.id) { + if (user.id == this.u.id) { this.user.isFollowing = user.isFollowing; } }, - onClick() { + async onClick() { this.wait = true; - if (this.user.isFollowing) { - (this as any).api('following/delete', { - userId: this.user.id - }).then(() => { - this.user.isFollowing = false; - }).catch(err => { - console.error(err); - }).then(() => { - this.wait = false; - }); - } else { - (this as any).api('following/create', { - userId: this.user.id - }).then(() => { - this.user.isFollowing = true; - }).catch(err => { - console.error(err); - }).then(() => { - this.wait = false; - }); + + try { + if (this.u.isFollowing) { + this.u = await (this as any).api('following/delete', { + userId: this.u.id + }); + } else { + if (this.u.isLocked && this.u.hasPendingFollowRequestFromYou) { + this.u = await (this as any).api('following/requests/cancel', { + userId: this.u.id + }); + } else if (this.u.isLocked) { + this.u = await (this as any).api('following/create', { + userId: this.u.id + }); + } else { + this.u = await (this as any).api('following/create', { + userId: this.user.id + }); + } + } + } catch (e) { + console.error(e); + } finally { + this.wait = false; } } } @@ -124,7 +127,7 @@ root(isDark) border 2px solid rgba($theme-color, 0.3) border-radius 8px - &.follow + &:not(.active) color isDark ? #fff : #888 background isDark ? linear-gradient(to bottom, #313543 0%, #282c37 100%) : linear-gradient(to bottom, #ffffff 0%, #f5f5f5 100%) border solid 1px isDark ? #1c2023 : #e2e2e2 @@ -137,7 +140,7 @@ root(isDark) background isDark ? #22262f : #ececec border-color isDark ? #151a1d : #dcdcdc - &.unfollow + &.active color $theme-color-foreground background linear-gradient(to bottom, lighten($theme-color, 25%) 0%, lighten($theme-color, 10%) 100%) border solid 1px lighten($theme-color, 15%) @@ -162,9 +165,6 @@ root(isDark) height 38px line-height 38px - i - margin-right 8px - .mk-follow-button[data-darkmode] root(true) diff --git a/src/client/app/desktop/views/components/notifications.vue b/src/client/app/desktop/views/components/notifications.vue index 5564dad623..f2247a782c 100644 --- a/src/client/app/desktop/views/components/notifications.vue +++ b/src/client/app/desktop/views/components/notifications.vue @@ -5,6 +5,7 @@ <template v-for="(notification, i) in _notifications"> <div class="notification" :class="notification.type" :key="notification.id"> <mk-time :time="notification.createdAt"/> + <template v-if="notification.type == 'reaction'"> <mk-avatar class="avatar" :user="notification.user"/> <div class="text"> @@ -17,6 +18,7 @@ </router-link> </div> </template> + <template v-if="notification.type == 'renote'"> <mk-avatar class="avatar" :user="notification.note.user"/> <div class="text"> @@ -28,6 +30,7 @@ </router-link> </div> </template> + <template v-if="notification.type == 'quote'"> <mk-avatar class="avatar" :user="notification.note.user"/> <div class="text"> @@ -37,6 +40,7 @@ <router-link class="note-preview" :to="notification.note | notePage">{{ getNoteSummary(notification.note) }}</router-link> </div> </template> + <template v-if="notification.type == 'follow'"> <mk-avatar class="avatar" :user="notification.user"/> <div class="text"> @@ -45,6 +49,16 @@ </p> </div> </template> + + <template v-if="notification.type == 'receiveFollowRequest'"> + <mk-avatar class="avatar" :user="notification.user"/> + <div class="text"> + <p>%fa:user-clock% + <router-link :to="notification.user | userPage" v-user-preview="notification.user.id">{{ notification.user | userName }}</router-link> + </p> + </div> + </template> + <template v-if="notification.type == 'reply'"> <mk-avatar class="avatar" :user="notification.note.user"/> <div class="text"> @@ -54,6 +68,7 @@ <router-link class="note-preview" :to="notification.note | notePage">{{ getNoteSummary(notification.note) }}</router-link> </div> </template> + <template v-if="notification.type == 'mention'"> <mk-avatar class="avatar" :user="notification.note.user"/> <div class="text"> @@ -63,6 +78,7 @@ <a class="note-preview" :href="notification.note | notePage">{{ getNoteSummary(notification.note) }}</a> </div> </template> + <template v-if="notification.type == 'poll_vote'"> <mk-avatar class="avatar" :user="notification.user"/> <div class="text"> @@ -73,6 +89,7 @@ </div> </template> </div> + <p class="date" v-if="i != notifications.length - 1 && notification._date != _notifications[i + 1]._date" :key="notification.id + '-time'"> <span>%fa:angle-up%{{ notification._datetext }}</span> <span>%fa:angle-down%{{ _notifications[i + 1]._datetext }}</span> @@ -251,6 +268,10 @@ root(isDark) .text p i color #53c7ce + &.receiveFollowRequest + .text p i + color #888 + &.reply, &.mention .text p i color #555 diff --git a/src/client/app/desktop/views/components/received-follow-requests-window.vue b/src/client/app/desktop/views/components/received-follow-requests-window.vue new file mode 100644 index 0000000000..fd37c0a6aa --- /dev/null +++ b/src/client/app/desktop/views/components/received-follow-requests-window.vue @@ -0,0 +1,72 @@ +<template> +<mk-window ref="window" is-modal width="450px" height="500px" @closed="$destroy"> + <span slot="header">%fa:envelope R% %i18n:@title%</span> + + <div data-id="c1136cec-1278-49b1-9ea7-412c1ef794f4" :data-darkmode="$store.state.device.darkmode"> + <div v-for="req in requests"> + <router-link :key="req.id" :to="req.follower | userPage">{{ req.follower | userName }}</router-link> + <span> + <a @click="accept(req.follower)">%i18n:@accept%</a>|<a @click="reject(req.follower)">%i18n:@reject%</a> + </span> + </div> + </div> +</mk-window> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + data() { + return { + fetching: true, + requests: [] + }; + }, + mounted() { + (this as any).api('following/requests/list').then(requests => { + this.fetching = false; + this.requests = requests; + }); + }, + methods: { + accept(user) { + (this as any).api('following/requests/accept', { userId: user.id }).then(() => { + this.requests = this.requests.filter(r => r.follower.id != user.id); + }); + }, + reject(user) { + (this as any).api('following/requests/reject', { userId: user.id }).then(() => { + this.requests = this.requests.filter(r => r.follower.id != user.id); + }); + }, + close() { + (this as any).$refs.window.close(); + } + } +}); +</script> + +<style lang="stylus" scoped> + +root(isDark) + padding 16px + + > button + margin-bottom 16px + + > div + display flex + padding 16px + border solid 1px isDark ? #1c2023 : #eee + border-radius 4px + + > span + margin 0 0 0 auto + +[data-id="c1136cec-1278-49b1-9ea7-412c1ef794f4"][data-darkmode] + root(true) + +[data-id="c1136cec-1278-49b1-9ea7-412c1ef794f4"]:not([data-darkmode]) + root(false) + +</style> diff --git a/src/client/app/desktop/views/components/settings.profile.vue b/src/client/app/desktop/views/components/settings.profile.vue index 9932cbf7db..0b3a25f389 100644 --- a/src/client/app/desktop/views/components/settings.profile.vue +++ b/src/client/app/desktop/views/components/settings.profile.vue @@ -23,7 +23,11 @@ </label> <button class="ui primary" @click="save">%i18n:@save%</button> <section> - <h2>その他</h2> + <h2>%i18n:@locked-account%</h2> + <mk-switch v-model="$store.state.i.isLocked" @change="onChangeIsLocked" text="%i18n:@is-locked%"/> + </section> + <section> + <h2>%i18n:@other%</h2> <mk-switch v-model="$store.state.i.isBot" @change="onChangeIsBot" text="%i18n:@is-bot%"/> <mk-switch v-model="$store.state.i.isCat" @change="onChangeIsCat" text="%i18n:@is-cat%"/> </section> @@ -62,6 +66,11 @@ export default Vue.extend({ (this as any).apis.notify('プロフィールを更新しました'); }); }, + onChangeIsLocked() { + (this as any).api('i/update', { + isLocked: this.$store.state.i.isLocked + }); + }, onChangeIsBot() { (this as any).api('i/update', { isBot: this.$store.state.i.isBot diff --git a/src/client/app/desktop/views/components/ui.header.account.vue b/src/client/app/desktop/views/components/ui.header.account.vue index 8d26691f84..4e0fc1cf1a 100644 --- a/src/client/app/desktop/views/components/ui.header.account.vue +++ b/src/client/app/desktop/views/components/ui.header.account.vue @@ -19,6 +19,9 @@ <li @click="list"> <p>%fa:list%<span>%i18n:@lists%</span>%fa:angle-right%</p> </li> + <li @click="followRequests" v-if="$store.state.i.isLocked"> + <p>%fa:envelope R%<span>%i18n:@follow-requests%<i v-if="$store.state.i.pendingReceivedFollowRequestsCount">{{ $store.state.i.pendingReceivedFollowRequestsCount }}</i></span>%fa:angle-right%</p> + </li> </ul> <ul> <li> @@ -46,6 +49,7 @@ <script lang="ts"> import Vue from 'vue'; import MkUserListsWindow from './user-lists-window.vue'; +import MkFollowRequestsWindow from './received-follow-requests-window.vue'; import MkSettingsWindow from './settings-window.vue'; import MkDriveWindow from './drive-window.vue'; import contains from '../../../common/scripts/contains'; @@ -91,6 +95,10 @@ export default Vue.extend({ this.$router.push(`i/lists/${ list.id }`); }); }, + followRequests() { + this.close(); + (this as any).os.new(MkFollowRequestsWindow); + }, settings() { this.close(); (this as any).os.new(MkSettingsWindow); @@ -225,6 +233,16 @@ root(isDark) > span:first-child padding-left 22px + > span:nth-child(2) + > i + margin-left 4px + padding 2px 8px + font-size 90% + font-style normal + background $theme-color + color $theme-color-foreground + border-radius 8px + > [data-fa]:first-child margin-right 6px width 16px diff --git a/src/client/app/desktop/views/components/user-lists-window.vue b/src/client/app/desktop/views/components/user-lists-window.vue index 454c725d20..109d1695d8 100644 --- a/src/client/app/desktop/views/components/user-lists-window.vue +++ b/src/client/app/desktop/views/components/user-lists-window.vue @@ -1,6 +1,6 @@ <template> <mk-window ref="window" is-modal width="450px" height="500px" @closed="$destroy"> - <span slot="header">%fa:list% リスト</span> + <span slot="header">%fa:list% %i18n:@title%</span> <div data-id="6e4caea3-d8f9-4ab7-96de-ab67fe8d5c82" :data-darkmode="$store.state.device.darkmode"> <button class="ui" @click="add">%i18n:@create-list%</button> diff --git a/src/client/app/mobile/script.ts b/src/client/app/mobile/script.ts index 607ff63711..300615ec58 100644 --- a/src/client/app/mobile/script.ts +++ b/src/client/app/mobile/script.ts @@ -32,6 +32,7 @@ import MkNotifications from './views/pages/notifications.vue'; import MkWidgets from './views/pages/widgets.vue'; import MkMessaging from './views/pages/messaging.vue'; import MkMessagingRoom from './views/pages/messaging-room.vue'; +import MkReceivedFollowRequests from './views/pages/received-follow-requests.vue'; import MkNote from './views/pages/note.vue'; import MkSearch from './views/pages/search.vue'; import MkFollowers from './views/pages/followers.vue'; @@ -78,6 +79,7 @@ init((launch) => { { path: '/i/favorites', name: 'favorites', component: MkFavorites }, { path: '/i/lists', name: 'user-lists', component: MkUserLists }, { path: '/i/lists/:list', name: 'user-list', component: MkUserList }, + { path: '/i/received-follow-requests', name: 'received-follow-requests', component: MkReceivedFollowRequests }, { path: '/i/widgets', name: 'widgets', component: MkWidgets }, { path: '/i/messaging', name: 'messaging', component: MkMessaging }, { path: '/i/messaging/:user', component: MkMessagingRoom }, diff --git a/src/client/app/mobile/views/components/follow-button.vue b/src/client/app/mobile/views/components/follow-button.vue index a6b5cf0556..d8441a20b9 100644 --- a/src/client/app/mobile/views/components/follow-button.vue +++ b/src/client/app/mobile/views/components/follow-button.vue @@ -1,13 +1,16 @@ <template> <button class="mk-follow-button" - :class="{ wait: wait, follow: !user.isFollowing, unfollow: user.isFollowing }" + :class="{ wait: wait, active: u.isFollowing || u.hasPendingFollowRequestFromYou }" @click="onClick" :disabled="wait" > - <template v-if="!wait && user.isFollowing">%fa:minus%</template> - <template v-if="!wait && !user.isFollowing">%fa:plus%</template> - <template v-if="wait">%fa:spinner .pulse .fw%</template> - {{ user.isFollowing ? '%i18n:@unfollow%' : '%i18n:@follow%' }} + <template v-if="!wait"> + <template v-if="u.hasPendingFollowRequestFromYou">%fa:hourglass-half% %i18n:@request-pending%</template> + <template v-else-if="u.isFollowing">%fa:minus% %i18n:@unfollow%</template> + <template v-else-if="!u.isFollowing && u.isLocked">%fa:plus% %i18n:@follow-request%</template> + <template v-else-if="!u.isFollowing && !u.isLocked">%fa:plus% %i18n:@follow%</template> + </template> + <template v-else>%fa:spinner .pulse .fw%</template> </button> </template> @@ -22,6 +25,7 @@ export default Vue.extend({ }, data() { return { + u: this.user, wait: false, connection: null, connectionId: null @@ -42,39 +46,44 @@ export default Vue.extend({ methods: { onFollow(user) { - if (user.id == this.user.id) { - this.user.isFollowing = user.isFollowing; + if (user.id == this.u.id) { + this.u.isFollowing = user.isFollowing; } }, onUnfollow(user) { - if (user.id == this.user.id) { - this.user.isFollowing = user.isFollowing; + if (user.id == this.u.id) { + this.u.isFollowing = user.isFollowing; } }, - onClick() { + async onClick() { this.wait = true; - if (this.user.isFollowing) { - (this as any).api('following/delete', { - userId: this.user.id - }).then(() => { - this.user.isFollowing = false; - }).catch(err => { - console.error(err); - }).then(() => { - this.wait = false; - }); - } else { - (this as any).api('following/create', { - userId: this.user.id - }).then(() => { - this.user.isFollowing = true; - }).catch(err => { - console.error(err); - }).then(() => { - this.wait = false; - }); + + try { + if (this.u.isFollowing) { + this.u = await (this as any).api('following/delete', { + userId: this.u.id + }); + } else { + if (this.u.isLocked && this.u.hasPendingFollowRequestFromYou) { + this.u = await (this as any).api('following/requests/cancel', { + userId: this.u.id + }); + } else if (this.u.isLocked) { + this.u = await (this as any).api('following/create', { + userId: this.u.id + }); + } else { + this.u = await (this as any).api('following/create', { + userId: this.user.id + }); + } + } + } catch (e) { + console.error(e); + } finally { + this.wait = false; } } } @@ -90,34 +99,38 @@ export default Vue.extend({ cursor pointer padding 0 16px margin 0 - height inherit - font-size 16px + min-width 150px + line-height 36px + font-size 14px + color $theme-color + background transparent outline none border solid 1px $theme-color - border-radius 4px + border-radius 36px - * - pointer-events none + &:hover + background rgba($theme-color, 0.1) - &.follow - color $theme-color - background transparent + &:active + background rgba($theme-color, 0.2) + + &.active + color $theme-color-foreground + background $theme-color &:hover - background rgba($theme-color, 0.1) + background lighten($theme-color, 10%) + border-color lighten($theme-color, 10%) &:active - background rgba($theme-color, 0.2) - - &.unfollow - color $theme-color-foreground - background $theme-color + background darken($theme-color, 10%) + border-color darken($theme-color, 10%) &.wait cursor wait !important opacity 0.7 - > [data-fa] - margin-right 4px + * + pointer-events none </style> diff --git a/src/client/app/mobile/views/components/notification-preview.vue b/src/client/app/mobile/views/components/notification-preview.vue index d39b2fbf9f..5e2306932b 100644 --- a/src/client/app/mobile/views/components/notification-preview.vue +++ b/src/client/app/mobile/views/components/notification-preview.vue @@ -1,7 +1,7 @@ <template> <div class="mk-notification-preview" :class="notification.type"> <template v-if="notification.type == 'reaction'"> - <img class="avatar" :src="`${notification.user.avatarUrl}?thumbnail&size=64`" alt="avatar"/> + <mk-avatar class="avatar" :user="notification.user"/> <div class="text"> <p><mk-reaction-icon :reaction="notification.reaction"/>{{ notification.user | userName }}</p> <p class="note-ref">%fa:quote-left%{{ getNoteSummary(notification.note) }}%fa:quote-right%</p> @@ -9,7 +9,7 @@ </template> <template v-if="notification.type == 'renote'"> - <img class="avatar" :src="`${notification.note.user.avatarUrl}?thumbnail&size=64`" alt="avatar"/> + <mk-avatar class="avatar" :user="notification.note.user"/> <div class="text"> <p>%fa:retweet%{{ notification.note.user | userName }}</p> <p class="note-ref">%fa:quote-left%{{ getNoteSummary(notification.note.renote) }}%fa:quote-right%</p> @@ -17,7 +17,7 @@ </template> <template v-if="notification.type == 'quote'"> - <img class="avatar" :src="`${notification.note.user.avatarUrl}?thumbnail&size=64`" alt="avatar"/> + <mk-avatar class="avatar" :user="notification.note.user"/> <div class="text"> <p>%fa:quote-left%{{ notification.note.user | userName }}</p> <p class="note-preview">{{ getNoteSummary(notification.note) }}</p> @@ -25,14 +25,21 @@ </template> <template v-if="notification.type == 'follow'"> - <img class="avatar" :src="`${notification.user.avatarUrl}?thumbnail&size=64`" alt="avatar"/> + <mk-avatar class="avatar" :user="notification.user"/> <div class="text"> <p>%fa:user-plus%{{ notification.user | userName }}</p> </div> </template> + <template v-if="notification.type == 'receiveFollowRequest'"> + <mk-avatar class="avatar" :user="notification.user"/> + <div class="text"> + <p>%fa:user-clock%{{ notification.user | userName }}</p> + </div> + </template> + <template v-if="notification.type == 'reply'"> - <img class="avatar" :src="`${notification.note.user.avatarUrl}?thumbnail&size=64`" alt="avatar"/> + <mk-avatar class="avatar" :user="notification.note.user"/> <div class="text"> <p>%fa:reply%{{ notification.note.user | userName }}</p> <p class="note-preview">{{ getNoteSummary(notification.note) }}</p> @@ -40,7 +47,7 @@ </template> <template v-if="notification.type == 'mention'"> - <img class="avatar" :src="`${notification.note.user.avatarUrl}?thumbnail&size=64`" alt="avatar"/> + <mk-avatar class="avatar" :user="notification.note.user"/> <div class="text"> <p>%fa:at%{{ notification.note.user | userName }}</p> <p class="note-preview">{{ getNoteSummary(notification.note) }}</p> @@ -48,7 +55,7 @@ </template> <template v-if="notification.type == 'poll_vote'"> - <img class="avatar" :src="`${notification.user.avatarUrl}?thumbnail&size=64`" alt="avatar"/> + <mk-avatar class="avatar" :user="notification.user"/> <div class="text"> <p>%fa:chart-pie%{{ notification.user | userName }}</p> <p class="note-ref">%fa:quote-left%{{ getNoteSummary(notification.note) }}%fa:quote-right%</p> @@ -83,16 +90,14 @@ export default Vue.extend({ display block clear both - img + > .avatar display block float left - min-width 36px - min-height 36px - max-width 36px - max-height 36px + width 36px + height 36px border-radius 6px - .text + > .text float right width calc(100% - 36px) padding-left 8px @@ -120,6 +125,10 @@ export default Vue.extend({ .text p i color #53c7ce + &.receiveFollowRequest + .text p i + color #888 + &.reply, &.mention .text p i color #fff diff --git a/src/client/app/mobile/views/components/notification.vue b/src/client/app/mobile/views/components/notification.vue index c1b37563ce..9228950209 100644 --- a/src/client/app/mobile/views/components/notification.vue +++ b/src/client/app/mobile/views/components/notification.vue @@ -40,6 +40,17 @@ </div> </div> + <div class="notification followRequest" v-if="notification.type == 'receiveFollowRequest'"> + <mk-avatar class="avatar" :user="notification.user"/> + <div> + <header> + %fa:user-clock% + <router-link :to="notification.user | userPage">{{ notification.user | userName }}</router-link> + <mk-time :time="notification.createdAt"/> + </header> + </div> + </div> + <div class="notification poll_vote" v-if="notification.type == 'poll_vote'"> <mk-avatar class="avatar" :user="notification.user"/> <div> @@ -156,6 +167,10 @@ root(isDark) > div > header i color #53c7ce + &.receiveFollowRequest + > div > header i + color #888 + .mk-notification[data-darkmode] root(true) diff --git a/src/client/app/mobile/views/components/ui.nav.vue b/src/client/app/mobile/views/components/ui.nav.vue index 11a8f7ab97..80f60e4232 100644 --- a/src/client/app/mobile/views/components/ui.nav.vue +++ b/src/client/app/mobile/views/components/ui.nav.vue @@ -18,6 +18,7 @@ <li><router-link to="/" :data-active="$route.name == 'index'">%fa:home%%i18n:@timeline%%fa:angle-right%</router-link></li> <li><router-link to="/i/notifications" :data-active="$route.name == 'notifications'">%fa:R bell%%i18n:@notifications%<template v-if="hasUnreadNotification">%fa:circle%</template>%fa:angle-right%</router-link></li> <li><router-link to="/i/messaging" :data-active="$route.name == 'messaging'">%fa:R comments%%i18n:@messaging%<template v-if="hasUnreadMessagingMessage">%fa:circle%</template>%fa:angle-right%</router-link></li> + <li v-if="$store.getters.isSignedIn && $store.state.i.isLocked"><router-link to="/i/received-follow-requests" :data-active="$route.name == 'received-follow-requests'">%fa:R envelope%%i18n:@follow-requests%<template v-if="$store.getters.isSignedIn && $store.state.i.pendingReceivedFollowRequestsCount">%fa:circle%</template>%fa:angle-right%</router-link></li> <li><router-link to="/othello" :data-active="$route.name == 'othello'">%fa:gamepad%%i18n:@game%<template v-if="hasGameInvitation">%fa:circle%</template>%fa:angle-right%</router-link></li> </ul> <ul> diff --git a/src/client/app/mobile/views/pages/received-follow-requests.vue b/src/client/app/mobile/views/pages/received-follow-requests.vue new file mode 100644 index 0000000000..bf26a84ff9 --- /dev/null +++ b/src/client/app/mobile/views/pages/received-follow-requests.vue @@ -0,0 +1,78 @@ +<template> +<mk-ui> + <span slot="header">%fa:envelope R%%i18n:@title%</span> + + <main> + <div v-for="req in requests"> + <router-link :key="req.id" :to="req.follower | userPage">{{ req.follower | userName }}</router-link> + <span> + <a @click="accept(req.follower)">%i18n:@accept%</a>|<a @click="reject(req.follower)">%i18n:@reject%</a> + </span> + </div> + </main> +</mk-ui> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import Progress from '../../../common/scripts/loading'; + +export default Vue.extend({ + data() { + return { + fetching: true, + requests: [] + }; + }, + mounted() { + document.title = 'Misskey | %i18n:@title%'; + + Progress.start(); + + (this as any).api('following/requests/list').then(requests => { + this.fetching = false; + this.requests = requests; + + Progress.done(); + }); + }, + methods: { + accept(user) { + (this as any).api('following/requests/accept', { userId: user.id }).then(() => { + this.requests = this.requests.filter(r => r.follower.id != user.id); + }); + }, + reject(user) { + (this as any).api('following/requests/reject', { userId: user.id }).then(() => { + this.requests = this.requests.filter(r => r.follower.id != user.id); + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +main + width 100% + max-width 680px + margin 0 auto + padding 8px + + @media (min-width 500px) + padding 16px + + @media (min-width 600px) + padding 32px + + > div + display flex + padding 16px + border solid 1px isDark ? #1c2023 : #eee + border-radius 4px + + > span + margin 0 0 0 auto + +</style> diff --git a/src/client/app/mobile/views/pages/user.vue b/src/client/app/mobile/views/pages/user.vue index b3b820650c..3d37015906 100644 --- a/src/client/app/mobile/views/pages/user.vue +++ b/src/client/app/mobile/views/pages/user.vue @@ -184,7 +184,6 @@ root(isDark) > .mk-follow-button float right - height 40px > .title margin 8px 0 diff --git a/src/models/follow-request.ts b/src/models/follow-request.ts new file mode 100644 index 0000000000..eea5d1c535 --- /dev/null +++ b/src/models/follow-request.ts @@ -0,0 +1,87 @@ +import * as mongo from 'mongodb'; +import * as deepcopy from 'deepcopy'; +import db from '../db/mongodb'; +import { pack as packUser } from './user'; + +const FollowRequest = db.get<IFollowRequest>('followRequests'); +FollowRequest.createIndex(['followerId', 'followeeId'], { unique: true }); +export default FollowRequest; + +export type IFollowRequest = { + _id: mongo.ObjectID; + createdAt: Date; + followeeId: mongo.ObjectID; + followerId: mongo.ObjectID; + + // 非正規化 + _followee: { + host: string; + inbox?: string; + }, + _follower: { + host: string; + inbox?: string; + } +}; + +/** + * FollowRequestを物理削除します + */ +export async function deleteFollowRequest(followRequest: string | mongo.ObjectID | IFollowRequest) { + let f: IFollowRequest; + + // Populate + if (mongo.ObjectID.prototype.isPrototypeOf(followRequest)) { + f = await FollowRequest.findOne({ + _id: followRequest + }); + } else if (typeof followRequest === 'string') { + f = await FollowRequest.findOne({ + _id: new mongo.ObjectID(followRequest) + }); + } else { + f = followRequest as IFollowRequest; + } + + if (f == null) return; + + // このFollowingを削除 + await FollowRequest.remove({ + _id: f._id + }); +} + +/** + * Pack a request for API response + */ +export const pack = ( + request: any, + me?: any +) => new Promise<any>(async (resolve, reject) => { + let _request: any; + + // Populate the request if 'request' is ID + if (mongo.ObjectID.prototype.isPrototypeOf(request)) { + _request = await FollowRequest.findOne({ + _id: request + }); + } else if (typeof request === 'string') { + _request = await FollowRequest.findOne({ + _id: new mongo.ObjectID(request) + }); + } else { + _request = deepcopy(request); + } + + // Rename _id to id + _request.id = _request._id; + delete _request._id; + + // Populate follower + _request.follower = await packUser(_request.followerId, me); + + // Populate followee + _request.followee = await packUser(_request.followeeId, me); + + resolve(_request); +}); diff --git a/src/models/notification.ts b/src/models/notification.ts index c4cf1e4efd..875c6952b5 100644 --- a/src/models/notification.ts +++ b/src/models/notification.ts @@ -111,6 +111,7 @@ export const pack = (notification: any) => new Promise<any>(async (resolve, reje switch (_notification.type) { case 'follow': + case 'receiveFollowRequest': // nope break; case 'mention': diff --git a/src/models/user.ts b/src/models/user.ts index 11eafe05ea..0e06512dae 100644 --- a/src/models/user.ts +++ b/src/models/user.ts @@ -22,6 +22,7 @@ import FollowedLog, { deleteFollowedLog } from './followed-log'; import SwSubscription, { deleteSwSubscription } from './sw-subscription'; import Notification, { deleteNotification } from './notification'; import UserList, { deleteUserList } from './user-list'; +import FollowRequest, { deleteFollowRequest } from './follow-request'; const User = db.get<IUser>('users'); @@ -50,7 +51,22 @@ type IUserBase = { data: any; description: string; pinnedNoteId: mongo.ObjectID; + + /** + * 凍結されているか否か + */ isSuspended: boolean; + + /** + * 鍵アカウントか否か + */ + isLocked: boolean; + + /** + * このアカウントに届いているフォローリクエストの数 + */ + pendingReceivedFollowRequestsCount: number; + host: string; }; @@ -240,6 +256,16 @@ export async function deleteUser(user: string | mongo.ObjectID | IUser) { await Following.find({ followeeId: u._id }) ).map(x => deleteFollowing(x))); + // このユーザーのFollowRequestをすべて削除 + await Promise.all(( + await FollowRequest.find({ followerId: u._id }) + ).map(x => deleteFollowRequest(x))); + + // このユーザーへのFollowRequestをすべて削除 + await Promise.all(( + await FollowRequest.find({ followeeId: u._id }) + ).map(x => deleteFollowRequest(x))); + // このユーザーのFollowingLogをすべて削除 await Promise.all(( await FollowingLog.find({ userId: u._id }) @@ -395,7 +421,7 @@ export const pack = ( } if (meId && !meId.equals(_user.id)) { - const [following1, following2, mute] = await Promise.all([ + const [following1, following2, followReq1, followReq2, mute] = await Promise.all([ Following.findOne({ followerId: meId, followeeId: _user.id @@ -404,6 +430,14 @@ export const pack = ( followerId: _user.id, followeeId: meId }), + _user.isLocked ? FollowRequest.findOne({ + followerId: meId, + followeeId: _user.id + }) : Promise.resolve(null), + FollowRequest.findOne({ + followerId: _user.id, + followeeId: meId + }), Mute.findOne({ muterId: meId, muteeId: _user.id @@ -414,6 +448,9 @@ export const pack = ( _user.isFollowing = following1 !== null; _user.isStalking = following1 && following1.stalk; + _user.hasPendingFollowRequestFromYou = followReq1 !== null; + _user.hasPendingFollowRequestToYou = followReq2 !== null; + // Whether the user is followed _user.isFollowed = following2 !== null; diff --git a/src/remote/activitypub/kernel/accept/follow.ts b/src/remote/activitypub/kernel/accept/follow.ts new file mode 100644 index 0000000000..0f414ba321 --- /dev/null +++ b/src/remote/activitypub/kernel/accept/follow.ts @@ -0,0 +1,27 @@ +import * as mongo from 'mongodb'; +import User, { IRemoteUser } from '../../../../models/user'; +import config from '../../../../config'; +import accept from '../../../../services/following/requests/accept'; +import { IFollow } from '../../type'; + +export default async (actor: IRemoteUser, activity: IFollow): Promise<void> => { + const id = typeof activity.object == 'string' ? activity.object : activity.object.id; + + if (!id.startsWith(config.url + '/')) { + return null; + } + + const follower = await User.findOne({ + _id: new mongo.ObjectID(id.split('/').pop()) + }); + + if (follower === null) { + throw new Error('follower not found'); + } + + if (follower.host != null) { + throw new Error('フォローリクエストしたユーザーはローカルユーザーではありません'); + } + + await accept(actor, follower); +}; diff --git a/src/remote/activitypub/kernel/accept/index.ts b/src/remote/activitypub/kernel/accept/index.ts new file mode 100644 index 0000000000..2f9d646d14 --- /dev/null +++ b/src/remote/activitypub/kernel/accept/index.ts @@ -0,0 +1,35 @@ +import * as debug from 'debug'; + +import Resolver from '../../resolver'; +import { IRemoteUser } from '../../../../models/user'; +import acceptFollow from './follow'; +import { IAccept } from '../../type'; + +const log = debug('misskey:activitypub'); + +export default async (actor: IRemoteUser, activity: IAccept): Promise<void> => { + const uri = activity.id || activity; + + log(`Accept: ${uri}`); + + const resolver = new Resolver(); + + let object; + + try { + object = await resolver.resolve(activity.object); + } catch (e) { + log(`Resolution failed: ${e}`); + throw e; + } + + switch (object.type) { + case 'Follow': + acceptFollow(actor, object); + break; + + default: + console.warn(`Unknown accept type: ${object.type}`); + break; + } +}; diff --git a/src/remote/activitypub/kernel/follow.ts b/src/remote/activitypub/kernel/follow.ts index 7e31eb32ea..464f8582b7 100644 --- a/src/remote/activitypub/kernel/follow.ts +++ b/src/remote/activitypub/kernel/follow.ts @@ -23,5 +23,5 @@ export default async (actor: IRemoteUser, activity: IFollow): Promise<void> => { throw new Error('フォローしようとしているユーザーはローカルユーザーではありません'); } - await follow(actor, followee, activity); + await follow(actor, followee); }; diff --git a/src/remote/activitypub/kernel/index.ts b/src/remote/activitypub/kernel/index.ts index 15ea9494ae..752a9bd2e2 100644 --- a/src/remote/activitypub/kernel/index.ts +++ b/src/remote/activitypub/kernel/index.ts @@ -6,6 +6,8 @@ import follow from './follow'; import undo from './undo'; import like from './like'; import announce from './announce'; +import accept from './accept'; +import reject from './reject'; const self = async (actor: IRemoteUser, activity: Object): Promise<void> => { switch (activity.type) { @@ -22,7 +24,11 @@ const self = async (actor: IRemoteUser, activity: Object): Promise<void> => { break; case 'Accept': - // noop + await accept(actor, activity); + break; + + case 'Reject': + await reject(actor, activity); break; case 'Announce': diff --git a/src/remote/activitypub/kernel/reject/follow.ts b/src/remote/activitypub/kernel/reject/follow.ts new file mode 100644 index 0000000000..c139865d0e --- /dev/null +++ b/src/remote/activitypub/kernel/reject/follow.ts @@ -0,0 +1,27 @@ +import * as mongo from 'mongodb'; +import User, { IRemoteUser } from '../../../../models/user'; +import config from '../../../../config'; +import reject from '../../../../services/following/requests/reject'; +import { IFollow } from '../../type'; + +export default async (actor: IRemoteUser, activity: IFollow): Promise<void> => { + const id = typeof activity.object == 'string' ? activity.object : activity.object.id; + + if (!id.startsWith(config.url + '/')) { + return null; + } + + const follower = await User.findOne({ + _id: new mongo.ObjectID(id.split('/').pop()) + }); + + if (follower === null) { + throw new Error('follower not found'); + } + + if (follower.host != null) { + throw new Error('フォローリクエストしたユーザーはローカルユーザーではありません'); + } + + await reject(actor, follower); +}; diff --git a/src/remote/activitypub/kernel/reject/index.ts b/src/remote/activitypub/kernel/reject/index.ts new file mode 100644 index 0000000000..a82c3fd61e --- /dev/null +++ b/src/remote/activitypub/kernel/reject/index.ts @@ -0,0 +1,35 @@ +import * as debug from 'debug'; + +import Resolver from '../../resolver'; +import { IRemoteUser } from '../../../../models/user'; +import rejectFollow from './follow'; +import { IReject } from '../../type'; + +const log = debug('misskey:activitypub'); + +export default async (actor: IRemoteUser, activity: IReject): Promise<void> => { + const uri = activity.id || activity; + + log(`Reject: ${uri}`); + + const resolver = new Resolver(); + + let object; + + try { + object = await resolver.resolve(activity.object); + } catch (e) { + log(`Resolution failed: ${e}`); + throw e; + } + + switch (object.type) { + case 'Follow': + rejectFollow(actor, object); + break; + + default: + console.warn(`Unknown reject type: ${object.type}`); + break; + } +}; diff --git a/src/remote/activitypub/kernel/undo/follow.ts b/src/remote/activitypub/kernel/undo/follow.ts index c0b10c1898..7377984616 100644 --- a/src/remote/activitypub/kernel/undo/follow.ts +++ b/src/remote/activitypub/kernel/undo/follow.ts @@ -2,7 +2,9 @@ import * as mongo from 'mongodb'; import User, { IRemoteUser } from '../../../../models/user'; import config from '../../../../config'; import unfollow from '../../../../services/following/delete'; +import cancelRequest from '../../../../services/following/requests/cancel'; import { IFollow } from '../../type'; +import FollowRequest from '../../../../models/follow-request'; export default async (actor: IRemoteUser, activity: IFollow): Promise<void> => { const id = typeof activity.object == 'string' ? activity.object : activity.object.id; @@ -23,5 +25,14 @@ export default async (actor: IRemoteUser, activity: IFollow): Promise<void> => { throw new Error('フォロー解除しようとしているユーザーはローカルユーザーではありません'); } - await unfollow(actor, followee, activity); + const req = await FollowRequest.findOne({ + followerId: actor._id, + followeeId: followee._id + }); + + if (req) { + await cancelRequest(actor, followee); + } else { + await unfollow(actor, followee); + } }; diff --git a/src/remote/activitypub/models/person.ts b/src/remote/activitypub/models/person.ts index 33280f3d89..b720c445c6 100644 --- a/src/remote/activitypub/models/person.ts +++ b/src/remote/activitypub/models/person.ts @@ -93,6 +93,7 @@ export async function createPerson(value: any, resolver?: Resolver): Promise<IUs notesCount, name: person.name, driveCapacity: 1024 * 1024 * 8, // 8MiB + isLocked: person.manuallyApprovesFollowers, username: person.preferredUsername, usernameLower: person.preferredUsername.toLowerCase(), host, diff --git a/src/remote/activitypub/renderer/follow.ts b/src/remote/activitypub/renderer/follow.ts index bf8eeff06b..522422bcff 100644 --- a/src/remote/activitypub/renderer/follow.ts +++ b/src/remote/activitypub/renderer/follow.ts @@ -1,8 +1,8 @@ import config from '../../../config'; -import { IRemoteUser, ILocalUser } from '../../../models/user'; +import { IUser, isLocalUser } from '../../../models/user'; -export default (follower: ILocalUser, followee: IRemoteUser) => ({ +export default (follower: IUser, followee: IUser) => ({ type: 'Follow', - actor: `${config.url}/users/${follower._id}`, - object: followee.uri + actor: isLocalUser(follower) ? `${config.url}/users/${follower._id}` : follower.uri, + object: isLocalUser(followee) ? `${config.url}/users/${followee._id}` : followee.uri }); diff --git a/src/remote/activitypub/renderer/person.ts b/src/remote/activitypub/renderer/person.ts index 424305f8d3..b2ac885f46 100644 --- a/src/remote/activitypub/renderer/person.ts +++ b/src/remote/activitypub/renderer/person.ts @@ -1,8 +1,9 @@ import renderImage from './image'; import renderKey from './key'; import config from '../../../config'; +import { ILocalUser } from '../../../models/user'; -export default user => { +export default (user: ILocalUser) => { const id = `${config.url}/users/${user._id}`; return { @@ -17,6 +18,7 @@ export default user => { summary: user.description, icon: user.avatarId && renderImage({ _id: user.avatarId }), image: user.bannerId && renderImage({ _id: user.bannerId }), + manuallyApprovesFollowers: user.isLocked, publicKey: renderKey(user) }; }; diff --git a/src/remote/activitypub/renderer/reject.ts b/src/remote/activitypub/renderer/reject.ts new file mode 100644 index 0000000000..29c998a6b4 --- /dev/null +++ b/src/remote/activitypub/renderer/reject.ts @@ -0,0 +1,4 @@ +export default object => ({ + type: 'Reject', + object +}); diff --git a/src/remote/activitypub/type.ts b/src/remote/activitypub/type.ts index ca38ec2227..13e1969b4e 100644 --- a/src/remote/activitypub/type.ts +++ b/src/remote/activitypub/type.ts @@ -45,6 +45,7 @@ export interface IPerson extends IObject { type: 'Person'; name: string; preferredUsername: string; + manuallyApprovesFollowers: boolean; inbox: string; publicKey: any; followers: any; @@ -82,6 +83,10 @@ export interface IAccept extends IActivity { type: 'Accept'; } +export interface IReject extends IActivity { + type: 'Reject'; +} + export interface ILike extends IActivity { type: 'Like'; _misskey_reaction: string; @@ -99,5 +104,6 @@ export type Object = IUndo | IFollow | IAccept | + IReject | ILike | IAnnounce; diff --git a/src/server/activitypub.ts b/src/server/activitypub.ts index 3c07a3e2f2..c846e28c07 100644 --- a/src/server/activitypub.ts +++ b/src/server/activitypub.ts @@ -7,7 +7,7 @@ const httpSignature = require('http-signature'); import { createHttp } from '../queue'; import pack from '../remote/activitypub/renderer'; import Note from '../models/note'; -import User, { isLocalUser } from '../models/user'; +import User, { isLocalUser, ILocalUser } from '../models/user'; import renderNote from '../remote/activitypub/renderer/note'; import renderKey from '../remote/activitypub/renderer/key'; import renderPerson from '../remote/activitypub/renderer/person'; @@ -69,7 +69,10 @@ router.get('/notes/:note', async (ctx, next) => { router.get('/users/:user/outbox', async ctx => { const userId = new mongo.ObjectID(ctx.params.user); - const user = await User.findOne({ _id: userId }); + const user = await User.findOne({ + _id: userId, + host: null + }); if (user === null) { ctx.status = 404; @@ -91,7 +94,10 @@ router.get('/users/:user/outbox', async ctx => { router.get('/users/:user/publickey', async ctx => { const userId = new mongo.ObjectID(ctx.params.user); - const user = await User.findOne({ _id: userId }); + const user = await User.findOne({ + _id: userId, + host: null + }); if (user === null) { ctx.status = 404; @@ -109,14 +115,17 @@ router.get('/users/:user/publickey', async ctx => { router.get('/users/:user', async ctx => { const userId = new mongo.ObjectID(ctx.params.user); - const user = await User.findOne({ _id: userId }); + const user = await User.findOne({ + _id: userId, + host: null + }); if (user === null) { ctx.status = 404; return; } - ctx.body = pack(renderPerson(user)); + ctx.body = pack(renderPerson(user as ILocalUser)); }); // follow form diff --git a/src/server/api/endpoints.ts b/src/server/api/endpoints.ts index 196f3adebc..e9392d236b 100644 --- a/src/server/api/endpoints.ts +++ b/src/server/api/endpoints.ts @@ -449,6 +449,26 @@ const endpoints: Endpoint[] = [ kind: 'following-write' }, { + name: 'following/requests/accept', + withCredential: true, + kind: 'following-write' + }, + { + name: 'following/requests/reject', + withCredential: true, + kind: 'following-write' + }, + { + name: 'following/requests/cancel', + withCredential: true, + kind: 'following-write' + }, + { + name: 'following/requests/list', + withCredential: true, + kind: 'following-read' + }, + { name: 'following/stalk', withCredential: true, limit: { diff --git a/src/server/api/endpoints/following/create.ts b/src/server/api/endpoints/following/create.ts index 766a8c03d0..48205232e6 100644 --- a/src/server/api/endpoints/following/create.ts +++ b/src/server/api/endpoints/following/create.ts @@ -2,7 +2,7 @@ * Module dependencies */ import $ from 'cafy'; import ID from '../../../../cafy-id'; -import User from '../../../../models/user'; +import User, { pack } from '../../../../models/user'; import Following from '../../../../models/following'; import create from '../../../../services/following/create'; @@ -49,5 +49,5 @@ module.exports = (params, user) => new Promise(async (res, rej) => { create(follower, followee); // Send response - res(); + res(await pack(followee, user)); }); diff --git a/src/server/api/endpoints/following/delete.ts b/src/server/api/endpoints/following/delete.ts index 396b19a6f6..f4030c247a 100644 --- a/src/server/api/endpoints/following/delete.ts +++ b/src/server/api/endpoints/following/delete.ts @@ -2,7 +2,7 @@ * Module dependencies */ import $ from 'cafy'; import ID from '../../../../cafy-id'; -import User from '../../../../models/user'; +import User, { pack } from '../../../../models/user'; import Following from '../../../../models/following'; import deleteFollowing from '../../../../services/following/delete'; @@ -49,5 +49,5 @@ module.exports = (params, user) => new Promise(async (res, rej) => { deleteFollowing(follower, followee); // Send response - res(); + res(await pack(followee, user)); }); diff --git a/src/server/api/endpoints/following/requests/accept.ts b/src/server/api/endpoints/following/requests/accept.ts new file mode 100644 index 0000000000..705d3b161a --- /dev/null +++ b/src/server/api/endpoints/following/requests/accept.ts @@ -0,0 +1,26 @@ +import $ from 'cafy'; import ID from '../../../../../cafy-id'; +import acceptFollowRequest from '../../../../../services/following/requests/accept'; +import User from '../../../../../models/user'; + +/** + * Accept a follow request + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + // Get 'userId' parameter + const [followerId, followerIdErr] = $.type(ID).get(params.userId); + if (followerIdErr) return rej('invalid userId param'); + + // Fetch follower + const follower = await User.findOne({ + _id: followerId + }); + + if (follower === null) { + return rej('follower not found'); + } + + await acceptFollowRequest(user, follower); + + // Send response + res(); +}); diff --git a/src/server/api/endpoints/following/requests/cancel.ts b/src/server/api/endpoints/following/requests/cancel.ts new file mode 100644 index 0000000000..388a54890b --- /dev/null +++ b/src/server/api/endpoints/following/requests/cancel.ts @@ -0,0 +1,26 @@ +import $ from 'cafy'; import ID from '../../../../../cafy-id'; +import cancelFollowRequest from '../../../../../services/following/requests/cancel'; +import User, { pack } from '../../../../../models/user'; + +/** + * Cancel a follow request + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + // Get 'userId' parameter + const [followeeId, followeeIdErr] = $.type(ID).get(params.userId); + if (followeeIdErr) return rej('invalid userId param'); + + // Fetch followee + const followee = await User.findOne({ + _id: followeeId + }); + + if (followee === null) { + return rej('followee not found'); + } + + await cancelFollowRequest(followee, user); + + // Send response + res(await pack(followee._id, user)); +}); diff --git a/src/server/api/endpoints/following/requests/list.ts b/src/server/api/endpoints/following/requests/list.ts new file mode 100644 index 0000000000..e8364335d1 --- /dev/null +++ b/src/server/api/endpoints/following/requests/list.ts @@ -0,0 +1,14 @@ +//import $ from 'cafy'; import ID from '../../../../../cafy-id'; +import FollowRequest, { pack } from '../../../../../models/follow-request'; + +/** + * Get all pending received follow requests + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + const reqs = await FollowRequest.find({ + followeeId: user._id + }); + + // Send response + res(await Promise.all(reqs.map(req => pack(req)))); +}); diff --git a/src/server/api/endpoints/following/requests/reject.ts b/src/server/api/endpoints/following/requests/reject.ts new file mode 100644 index 0000000000..1cfb562b55 --- /dev/null +++ b/src/server/api/endpoints/following/requests/reject.ts @@ -0,0 +1,26 @@ +import $ from 'cafy'; import ID from '../../../../../cafy-id'; +import rejectFollowRequest from '../../../../../services/following/requests/reject'; +import User from '../../../../../models/user'; + +/** + * Reject a follow request + */ +module.exports = (params, user) => new Promise(async (res, rej) => { + // Get 'userId' parameter + const [followerId, followerIdErr] = $.type(ID).get(params.userId); + if (followerIdErr) return rej('invalid userId param'); + + // Fetch follower + const follower = await User.findOne({ + _id: followerId + }); + + if (follower === null) { + return rej('follower not found'); + } + + await rejectFollowRequest(user, follower); + + // Send response + res(); +}); diff --git a/src/server/api/endpoints/i/update.ts b/src/server/api/endpoints/i/update.ts index 6e0c5b8515..b94f073d2c 100644 --- a/src/server/api/endpoints/i/update.ts +++ b/src/server/api/endpoints/i/update.ts @@ -5,6 +5,7 @@ import $ from 'cafy'; import ID from '../../../../cafy-id'; import User, { isValidName, isValidDescription, isValidLocation, isValidBirthday, pack } from '../../../../models/user'; import event from '../../../../publishers/stream'; import DriveFile from '../../../../models/drive-file'; +import acceptAllFollowRequests from '../../../../services/following/requests/accept-all'; /** * Update myself @@ -12,50 +13,57 @@ import DriveFile from '../../../../models/drive-file'; module.exports = async (params, user, app) => new Promise(async (res, rej) => { const isSecure = user != null && app == null; + const updates = {} as any; + // Get 'name' parameter const [name, nameErr] = $.str.optional().nullable().pipe(isValidName).get(params.name); if (nameErr) return rej('invalid name param'); - if (name) user.name = name; + if (name) updates.name = name; // Get 'description' parameter const [description, descriptionErr] = $.str.optional().nullable().pipe(isValidDescription).get(params.description); if (descriptionErr) return rej('invalid description param'); - if (description !== undefined) user.description = description; + if (description !== undefined) updates.description = description; // Get 'location' parameter const [location, locationErr] = $.str.optional().nullable().pipe(isValidLocation).get(params.location); if (locationErr) return rej('invalid location param'); - if (location !== undefined) user.profile.location = location; + if (location !== undefined) updates['profile.location'] = location; // Get 'birthday' parameter const [birthday, birthdayErr] = $.str.optional().nullable().pipe(isValidBirthday).get(params.birthday); if (birthdayErr) return rej('invalid birthday param'); - if (birthday !== undefined) user.profile.birthday = birthday; + if (birthday !== undefined) updates['profile.birthday'] = birthday; // Get 'avatarId' parameter - const [avatarId, avatarIdErr] = $.type(ID).optional().get(params.avatarId); + const [avatarId, avatarIdErr] = $.type(ID).optional().nullable().get(params.avatarId); if (avatarIdErr) return rej('invalid avatarId param'); - if (avatarId) user.avatarId = avatarId; + if (avatarId !== undefined) updates.avatarId = avatarId; // Get 'bannerId' parameter - const [bannerId, bannerIdErr] = $.type(ID).optional().get(params.bannerId); + const [bannerId, bannerIdErr] = $.type(ID).optional().nullable().get(params.bannerId); if (bannerIdErr) return rej('invalid bannerId param'); - if (bannerId) user.bannerId = bannerId; + if (bannerId !== undefined) updates.bannerId = bannerId; + + // Get 'isLocked' parameter + const [isLocked, isLockedErr] = $.bool.optional().get(params.isLocked); + if (isLockedErr) return rej('invalid isLocked param'); + if (isLocked != null) updates.isLocked = isLocked; // Get 'isBot' parameter const [isBot, isBotErr] = $.bool.optional().get(params.isBot); if (isBotErr) return rej('invalid isBot param'); - if (isBot != null) user.isBot = isBot; + if (isBot != null) updates.isBot = isBot; // Get 'isCat' parameter const [isCat, isCatErr] = $.bool.optional().get(params.isCat); if (isCatErr) return rej('invalid isCat param'); - if (isCat != null) user.isCat = isCat; + if (isCat != null) updates.isCat = isCat; // Get 'autoWatch' parameter const [autoWatch, autoWatchErr] = $.bool.optional().get(params.autoWatch); if (autoWatchErr) return rej('invalid autoWatch param'); - if (autoWatch != null) user.settings.autoWatch = autoWatch; + if (autoWatch != null) updates['settings.autoWatch'] = autoWatch; if (avatarId) { const avatar = await DriveFile.findOne({ @@ -63,7 +71,7 @@ module.exports = async (params, user, app) => new Promise(async (res, rej) => { }); if (avatar != null && avatar.metadata.properties.avgColor) { - user.avatarColor = avatar.metadata.properties.avgColor; + updates.avatarColor = avatar.metadata.properties.avgColor; } } @@ -73,27 +81,16 @@ module.exports = async (params, user, app) => new Promise(async (res, rej) => { }); if (banner != null && banner.metadata.properties.avgColor) { - user.bannerColor = banner.metadata.properties.avgColor; + updates.bannerColor = banner.metadata.properties.avgColor; } } await User.update(user._id, { - $set: { - name: user.name, - description: user.description, - avatarId: user.avatarId, - avatarColor: user.avatarColor, - bannerId: user.bannerId, - bannerColor: user.bannerColor, - profile: user.profile, - isBot: user.isBot, - isCat: user.isCat, - settings: user.settings - } + $set: updates }); // Serialize - const iObj = await pack(user, user, { + const iObj = await pack(user._id, user, { detail: true, includeSecrets: isSecure }); @@ -101,6 +98,11 @@ module.exports = async (params, user, app) => new Promise(async (res, rej) => { // Send response res(iObj); - // Publish i updated event - event(user._id, 'i_updated', iObj); + // Publish meUpdated event + event(user._id, 'meUpdated', iObj); + + // 鍵垢を解除したとき、溜まっていたフォローリクエストがあるならすべて承認 + if (user.isLocked && isLocked === false) { + acceptAllFollowRequests(user); + } }); diff --git a/src/server/api/endpoints/users/recommendation.ts b/src/server/api/endpoints/users/recommendation.ts index 620ae17ca2..23821a552f 100644 --- a/src/server/api/endpoints/users/recommendation.ts +++ b/src/server/api/endpoints/users/recommendation.ts @@ -36,6 +36,7 @@ module.exports = (params, me) => new Promise(async (res, rej) => { _id: { $nin: followingIds.concat(mutedUserIds) }, + isLocked: false, $or: [{ lastUsedAt: { $gte: new Date(Date.now() - ms('7days')) diff --git a/src/server/api/service/twitter.ts b/src/server/api/service/twitter.ts index 284ae7ee22..8c35509cce 100644 --- a/src/server/api/service/twitter.ts +++ b/src/server/api/service/twitter.ts @@ -49,7 +49,7 @@ router.get('/disconnect/twitter', async ctx => { ctx.body = `Twitterの連携を解除しました :v:`; // Publish i updated event - event(user._id, 'i_updated', await pack(user, user, { + event(user._id, 'meUpdated', await pack(user, user, { detail: true, includeSecrets: true })); @@ -174,7 +174,7 @@ if (config.twitter == null) { ctx.body = `Twitter: @${result.screenName} を、Misskey: @${user.username} に接続しました!`; // Publish i updated event - event(user._id, 'i_updated', await pack(user, user, { + event(user._id, 'meUpdated', await pack(user, user, { detail: true, includeSecrets: true })); diff --git a/src/services/following/create.ts b/src/services/following/create.ts index 3424c55dae..03db72c332 100644 --- a/src/services/following/create.ts +++ b/src/services/following/create.ts @@ -8,72 +8,76 @@ import pack from '../../remote/activitypub/renderer'; import renderFollow from '../../remote/activitypub/renderer/follow'; import renderAccept from '../../remote/activitypub/renderer/accept'; import { deliver } from '../../queue'; +import createFollowRequest from './requests/create'; -export default async function(follower: IUser, followee: IUser, activity?) { - const following = await Following.insert({ - createdAt: new Date(), - followerId: follower._id, - followeeId: followee._id, - stalk: true, +export default async function(follower: IUser, followee: IUser) { + if (followee.isLocked) { + await createFollowRequest(follower, followee); + } else { + const following = await Following.insert({ + createdAt: new Date(), + followerId: follower._id, + followeeId: followee._id, - // 非正規化 - _follower: { - host: follower.host, - inbox: isRemoteUser(follower) ? follower.inbox : undefined - }, - _followee: { - host: followee.host, - inbox: isRemoteUser(followee) ? followee.inbox : undefined - } - }); + // 非正規化 + _follower: { + host: follower.host, + inbox: isRemoteUser(follower) ? follower.inbox : undefined + }, + _followee: { + host: followee.host, + inbox: isRemoteUser(followee) ? followee.inbox : undefined + } + }); - //#region Increment following count - User.update({ _id: follower._id }, { - $inc: { - followingCount: 1 - } - }); + //#region Increment following count + User.update({ _id: follower._id }, { + $inc: { + followingCount: 1 + } + }); - FollowingLog.insert({ - createdAt: following.createdAt, - userId: follower._id, - count: follower.followingCount + 1 - }); - //#endregion + FollowingLog.insert({ + createdAt: following.createdAt, + userId: follower._id, + count: follower.followingCount + 1 + }); + //#endregion - //#region Increment followers count - User.update({ _id: followee._id }, { - $inc: { - followersCount: 1 - } - }); - FollowedLog.insert({ - createdAt: following.createdAt, - userId: followee._id, - count: followee.followersCount + 1 - }); - //#endregion + //#region Increment followers count + User.update({ _id: followee._id }, { + $inc: { + followersCount: 1 + } + }); + FollowedLog.insert({ + createdAt: following.createdAt, + userId: followee._id, + count: followee.followersCount + 1 + }); + //#endregion - // Publish follow event - if (isLocalUser(follower)) { - packUser(followee, follower).then(packed => event(follower._id, 'follow', packed)); - } + // Publish follow event + if (isLocalUser(follower)) { + packUser(followee, follower).then(packed => event(follower._id, 'follow', packed)); + } - // Publish followed event - if (isLocalUser(followee)) { - packUser(follower, followee).then(packed => event(followee._id, 'followed', packed)), + // Publish followed event + if (isLocalUser(followee)) { + packUser(follower, followee).then(packed => event(followee._id, 'followed', packed)), - // 通知を作成 - notify(followee._id, follower._id, 'follow'); - } + // 通知を作成 + notify(followee._id, follower._id, 'follow'); + } - if (isLocalUser(follower) && isRemoteUser(followee)) { - const content = pack(renderFollow(follower, followee)); - deliver(follower, content, followee.inbox); - } + if (isLocalUser(follower) && isRemoteUser(followee)) { + const content = pack(renderFollow(follower, followee)); + deliver(follower, content, followee.inbox); + } - if (isRemoteUser(follower) && isLocalUser(followee)) { - const content = pack(renderAccept(activity)); - deliver(followee, content, follower.inbox); + if (isRemoteUser(follower) && isLocalUser(followee)) { + const content = pack(renderAccept(renderFollow(follower, followee))); + deliver(followee, content, follower.inbox); + } } } diff --git a/src/services/following/delete.ts b/src/services/following/delete.ts index c0c99fbed5..4fc5d42476 100644 --- a/src/services/following/delete.ts +++ b/src/services/following/delete.ts @@ -8,7 +8,7 @@ import renderFollow from '../../remote/activitypub/renderer/follow'; import renderUndo from '../../remote/activitypub/renderer/undo'; import { deliver } from '../../queue'; -export default async function(follower: IUser, followee: IUser, activity?) { +export default async function(follower: IUser, followee: IUser) { const following = await Following.findOne({ followerId: follower._id, followeeId: followee._id diff --git a/src/services/following/requests/accept-all.ts b/src/services/following/requests/accept-all.ts new file mode 100644 index 0000000000..9708d9e658 --- /dev/null +++ b/src/services/following/requests/accept-all.ts @@ -0,0 +1,24 @@ +import User, { IUser } from "../../../models/user"; +import FollowRequest from "../../../models/follow-request"; +import accept from './accept'; + +/** + * 指定したユーザー宛てのフォローリクエストをすべて承認 + * @param user ユーザー + */ +export default async function(user: IUser) { + const requests = await FollowRequest.find({ + followeeId: user._id + }); + + requests.forEach(async request => { + const follower = await User.findOne({ _id: request.followerId }); + accept(user, follower); + }); + + User.update({ _id: user._id }, { + $set: { + pendingReceivedFollowRequestsCount: 0 + } + }); +} diff --git a/src/services/following/requests/accept.ts b/src/services/following/requests/accept.ts new file mode 100644 index 0000000000..1b89cdb981 --- /dev/null +++ b/src/services/following/requests/accept.ts @@ -0,0 +1,70 @@ +import User, { IUser, isRemoteUser, ILocalUser, pack as packUser } from "../../../models/user"; +import FollowRequest from "../../../models/follow-request"; +import pack from '../../../remote/activitypub/renderer'; +import renderFollow from '../../../remote/activitypub/renderer/follow'; +import renderAccept from '../../../remote/activitypub/renderer/accept'; +import { deliver } from '../../../queue'; +import Following from "../../../models/following"; +import FollowingLog from "../../../models/following-log"; +import FollowedLog from "../../../models/followed-log"; +import event from '../../../publishers/stream'; + +export default async function(followee: IUser, follower: IUser) { + const following = await Following.insert({ + createdAt: new Date(), + followerId: follower._id, + followeeId: followee._id, + + // 非正規化 + _follower: { + host: follower.host, + inbox: isRemoteUser(follower) ? follower.inbox : undefined + }, + _followee: { + host: followee.host, + inbox: isRemoteUser(followee) ? followee.inbox : undefined + } + }); + + if (isRemoteUser(follower)) { + const content = pack(renderAccept(renderFollow(follower, followee))); + deliver(followee as ILocalUser, content, follower.inbox); + } + + await FollowRequest.remove({ + followeeId: followee._id, + followerId: follower._id + }); + + //#region Increment following count + await User.update({ _id: follower._id }, { + $inc: { + followingCount: 1 + } + }); + + FollowingLog.insert({ + createdAt: following.createdAt, + userId: follower._id, + count: follower.followingCount + 1 + }); + //#endregion + + //#region Increment followers count + await User.update({ _id: followee._id }, { + $inc: { + followersCount: 1 + } + }); + + FollowedLog.insert({ + createdAt: following.createdAt, + userId: followee._id, + count: followee.followersCount + 1 + }); + //#endregion + + packUser(followee, followee, { + detail: true + }).then(packed => event(followee._id, 'meUpdated', packed)); +} diff --git a/src/services/following/requests/cancel.ts b/src/services/following/requests/cancel.ts new file mode 100644 index 0000000000..50c95de0eb --- /dev/null +++ b/src/services/following/requests/cancel.ts @@ -0,0 +1,29 @@ +import User, { IUser, isRemoteUser, ILocalUser, pack as packUser } from "../../../models/user"; +import FollowRequest from "../../../models/follow-request"; +import pack from '../../../remote/activitypub/renderer'; +import renderFollow from '../../../remote/activitypub/renderer/follow'; +import renderUndo from '../../../remote/activitypub/renderer/undo'; +import { deliver } from '../../../queue'; +import event from '../../../publishers/stream'; + +export default async function(followee: IUser, follower: IUser) { + if (isRemoteUser(followee)) { + const content = pack(renderUndo(renderFollow(follower, followee))); + deliver(follower as ILocalUser, content, followee.inbox); + } + + await FollowRequest.remove({ + followeeId: followee._id, + followerId: follower._id + }); + + await User.update({ _id: followee._id }, { + $inc: { + pendingReceivedFollowRequestsCount: -1 + } + }); + + packUser(followee, followee, { + detail: true + }).then(packed => event(followee._id, 'meUpdated', packed)); +} diff --git a/src/services/following/requests/create.ts b/src/services/following/requests/create.ts new file mode 100644 index 0000000000..fea82b57d8 --- /dev/null +++ b/src/services/following/requests/create.ts @@ -0,0 +1,50 @@ +import User, { isLocalUser, isRemoteUser, pack as packUser, IUser } from '../../../models/user'; +import event from '../../../publishers/stream'; +import notify from '../../../publishers/notify'; +import pack from '../../../remote/activitypub/renderer'; +import renderFollow from '../../../remote/activitypub/renderer/follow'; +import { deliver } from '../../../queue'; +import FollowRequest from '../../../models/follow-request'; + +export default async function(follower: IUser, followee: IUser) { + if (!followee.isLocked) throw '対象のアカウントは鍵アカウントではありません'; + + await FollowRequest.insert({ + createdAt: new Date(), + followerId: follower._id, + followeeId: followee._id, + + // 非正規化 + _follower: { + host: follower.host, + inbox: isRemoteUser(follower) ? follower.inbox : undefined + }, + _followee: { + host: followee.host, + inbox: isRemoteUser(followee) ? followee.inbox : undefined + } + }); + + await User.update({ _id: followee._id }, { + $inc: { + pendingReceivedFollowRequestsCount: 1 + } + }); + + // Publish receiveRequest event + if (isLocalUser(followee)) { + packUser(follower, followee).then(packed => event(followee._id, 'receiveFollowRequest', packed)); + + packUser(followee, followee, { + detail: true + }).then(packed => event(followee._id, 'meUpdated', packed)); + + // 通知を作成 + notify(followee._id, follower._id, 'receiveFollowRequest'); + } + + if (isLocalUser(follower) && isRemoteUser(followee)) { + const content = pack(renderFollow(follower, followee)); + deliver(follower, content, followee.inbox); + } +} diff --git a/src/services/following/requests/reject.ts b/src/services/following/requests/reject.ts new file mode 100644 index 0000000000..acd419d0ee --- /dev/null +++ b/src/services/following/requests/reject.ts @@ -0,0 +1,24 @@ +import User, { IUser, isRemoteUser, ILocalUser } from "../../../models/user"; +import FollowRequest from "../../../models/follow-request"; +import pack from '../../../remote/activitypub/renderer'; +import renderFollow from '../../../remote/activitypub/renderer/follow'; +import renderReject from '../../../remote/activitypub/renderer/reject'; +import { deliver } from '../../../queue'; + +export default async function(followee: IUser, follower: IUser) { + if (isRemoteUser(follower)) { + const content = pack(renderReject(renderFollow(follower, followee))); + deliver(followee as ILocalUser, content, follower.inbox); + } + + await FollowRequest.remove({ + followeeId: followee._id, + followerId: follower._id + }); + + User.update({ _id: followee._id }, { + $inc: { + pendingReceivedFollowRequestsCount: -1 + } + }); +} |