diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index dccc8df126..b540546ce3 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -62,6 +62,8 @@ common: messaging: "トーク" deck: "デッキ" explore: "みつける" + following: "フォロー中" + followers: "フォロワー" weekday-short: sunday: "日" @@ -1656,10 +1658,6 @@ mobile/views/components/user-timeline.vue: no-notes: "このユーザーは投稿していないようです。" no-notes-with-media: "メディア付き投稿はありません。" -mobile/views/components/users-list.vue: - all: "すべて" - known: "知り合い" - mobile/views/pages/favorites.vue: title: "お気に入り" diff --git a/src/client/app/common/views/components/user-list.vue b/src/client/app/common/views/components/user-list.vue index 9fcb80f8ad..5d851002d2 100644 --- a/src/client/app/common/views/components/user-list.vue +++ b/src/client/app/common/views/components/user-list.vue @@ -12,7 +12,7 @@ <router-link class="name" :to="user | userPage" v-user-preview="user.id"><mk-user-name :user="user"/></router-link> <p class="username">@{{ user | acct }}</p> </div> - <div class="description" v-if="user.description"> + <div class="description" v-if="user.description" :title="user.description"> <mfm :text="user.description" :author="user" :i="$store.state.i" :custom-emojis="user.emojis" :should-break="false"/> </div> </div> @@ -137,5 +137,6 @@ export default Vue.extend({ overflow hidden text-overflow ellipsis opacity 0.7 + font-size 14px </style> diff --git a/src/client/app/common/views/pages/followers.vue b/src/client/app/common/views/pages/followers.vue new file mode 100644 index 0000000000..94d9c9b13c --- /dev/null +++ b/src/client/app/common/views/pages/followers.vue @@ -0,0 +1,30 @@ +<template> +<div> + <mk-user-list :make-promise="makePromise">{{ $t('@.followers') }}</mk-user-list> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import parseAcct from '../../../../../misc/acct/parse'; +import i18n from '../../../i18n'; + +export default Vue.extend({ + i18n: i18n(''), + + data() { + return { + makePromise: cursor => this.$root.api('users/followers', { + ...parseAcct(this.$route.params.user), + limit: 30, + cursor: cursor ? cursor : undefined + }).then(x => { + return { + users: x.users, + cursor: x.next + }; + }), + }; + }, +}); +</script> diff --git a/src/client/app/common/views/pages/following.vue b/src/client/app/common/views/pages/following.vue new file mode 100644 index 0000000000..39739fa3da --- /dev/null +++ b/src/client/app/common/views/pages/following.vue @@ -0,0 +1,27 @@ +<template> +<div> + <mk-user-list :make-promise="makePromise">{{ $t('@.following') }}</mk-user-list> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import parseAcct from '../../../../../misc/acct/parse'; + +export default Vue.extend({ + data() { + return { + makePromise: cursor => this.$root.api('users/following', { + ...parseAcct(this.$route.params.user), + limit: 30, + cursor: cursor ? cursor : undefined + }).then(x => { + return { + users: x.users, + cursor: x.next + }; + }), + }; + }, +}); +</script> diff --git a/src/client/app/desktop/script.ts b/src/client/app/desktop/script.ts index 1ec7de0cc5..4c5b29d1f4 100644 --- a/src/client/app/desktop/script.ts +++ b/src/client/app/desktop/script.ts @@ -130,7 +130,11 @@ init(async (launch, os) => { routes: [ os.store.getters.isSignedIn && os.store.state.device.deckMode ? { path: '/', name: 'index', component: MkDeck, children: [ - { path: '/@:user', name: 'user', component: () => import('./views/deck/deck.user-column.vue').then(m => m.default) }, + { path: '/@:user', name: 'user', component: () => import('./views/deck/deck.user-column.vue').then(m => m.default), children: [ + { path: '', name: 'user', component: () => import('./views/deck/deck.user-column.home.vue').then(m => m.default) }, + { 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: '/notes/:note', name: 'note', component: () => import('./views/deck/deck.note-column.vue').then(m => m.default) }, { path: '/search', component: () => import('./views/deck/deck.search-column.vue').then(m => m.default) }, { path: '/tags/:tag', name: 'tag', component: () => import('./views/deck/deck.hashtag-column.vue').then(m => m.default) }, @@ -140,13 +144,17 @@ init(async (launch, os) => { ]} : { path: '/', component: MkHome, children: [ { path: '', name: 'index', component: MkHomeTimeline }, - { path: '/@:user', name: 'user', component: () => import('./views/home/user/user.vue').then(m => m.default) }, + { path: '/@:user', component: () => import('./views/home/user/index.vue').then(m => m.default), children: [ + { path: '', name: 'user', component: () => import('./views/home/user/user.home.vue').then(m => m.default) }, + { 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: '/notes/:note', name: 'note', component: () => import('./views/home/note.vue').then(m => m.default) }, { path: '/search', component: () => import('./views/home/search.vue').then(m => m.default) }, { path: '/tags/:tag', name: 'tag', component: () => import('./views/home/tag.vue').then(m => m.default) }, { path: '/featured', component: () => import('./views/home/featured.vue').then(m => m.default) }, { path: '/explore', component: () => import('../common/views/pages/explore.vue').then(m => m.default) }, - { path: '/i/favorites', component: () => import('./views/home/favorites.vue').then(m => m.default) } + { path: '/i/favorites', component: () => import('./views/home/favorites.vue').then(m => m.default) }, ]}, { path: '/i/messaging/:user', component: MkMessagingRoom }, { path: '/i/drive', component: MkDrive }, @@ -155,8 +163,6 @@ init(async (launch, os) => { { path: '/selectdrive', component: MkSelectDrive }, { path: '/share', component: MkShare }, { path: '/games/reversi/:game?', component: MkReversi }, - { path: '/@:user/following', name: 'userFollowing', component: MkUserFollowingOrFollowers }, - { path: '/@:user/followers', name: 'userFollowers', component: MkUserFollowingOrFollowers }, { path: '/authorize-follow', component: MkFollow }, { path: '/deck', redirect: '/' }, { path: '*', component: MkNotFound } diff --git a/src/client/app/desktop/views/deck/deck.user-column.home.vue b/src/client/app/desktop/views/deck/deck.user-column.home.vue new file mode 100644 index 0000000000..966c5bdb1b --- /dev/null +++ b/src/client/app/desktop/views/deck/deck.user-column.home.vue @@ -0,0 +1,244 @@ +<template> +<div> + <ui-container v-if="user.pinnedNotes && user.pinnedNotes.length > 0" :body-togglable="true"> + <span slot="header"><fa icon="thumbtack"/> {{ $t('pinned-notes') }}</span> + <div> + <x-note v-for="n in user.pinnedNotes" :key="n.id" :note="n" :mini="true"/> + </div> + </ui-container> + <ui-container v-if="images.length > 0" :body-togglable="true"> + <span slot="header"><fa :icon="['far', 'images']"/> {{ $t('images') }}</span> + <div class="sainvnaq"> + <router-link v-for="image in images" + :style="`background-image: url(${image.thumbnailUrl})`" + :key="`${image.id}:${image._note.id}`" + :to="image._note | notePage" + :title="`${image.name}\n${(new Date(image.createdAt)).toLocaleString()}`" + ></router-link> + </div> + </ui-container> + <ui-container :body-togglable="true"> + <span slot="header"><fa :icon="['far', 'chart-bar']"/> {{ $t('activity') }}</span> + <div> + <div ref="chart"></div> + </div> + </ui-container> + <ui-container> + <span slot="header"><fa :icon="['far', 'comment-alt']"/> {{ $t('timeline') }}</span> + <div> + <x-notes ref="timeline" :more="existMore ? fetchMoreNotes : null"/> + </div> + </ui-container> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import i18n from '../../../i18n'; +import parseAcct from '../../../../../misc/acct/parse'; +import XNotes from './deck.notes.vue'; +import XNote from '../components/note.vue'; +import { concat } from '../../../../../prelude/array'; +import ApexCharts from 'apexcharts'; + +const fetchLimit = 10; + +export default Vue.extend({ + i18n: i18n('deck/deck.user-column.vue'), + components: { + XNotes, + XNote + }, + + props: { + user: { + type: Object, + required: true + } + }, + + data() { + return { + existMore: false, + moreFetching: false, + withFiles: false, + images: [], + }; + }, + + created() { + this.fetch(); + }, + + methods: { + fetch() { + this.$nextTick(() => { + (this.$refs.timeline as any).init(() => this.initTl()); + }); + + const image = [ + 'image/jpeg', + 'image/png', + 'image/gif' + ]; + + this.$root.api('users/notes', { + userId: this.user.id, + fileType: image, + excludeNsfw: !this.$store.state.device.alwaysShowNsfw, + limit: 9, + untilDate: new Date().getTime() + 1000 * 86400 * 365 + }).then(notes => { + for (const note of notes) { + for (const file of note.files) { + file._note = note; + } + } + const files = concat(notes.map((n: any): any[] => n.files)); + this.images = files.filter(f => image.includes(f.type)).slice(0, 9); + }); + + this.$root.api('charts/user/notes', { + userId: this.user.id, + span: 'day', + limit: 21 + }).then(stats => { + const normal = []; + const reply = []; + const renote = []; + + const now = new Date(); + const y = now.getFullYear(); + const m = now.getMonth(); + const d = now.getDate(); + + for (let i = 0; i < 21; i++) { + const x = new Date(y, m, d - i); + normal.push([ + x, + stats.diffs.normal[i] + ]); + reply.push([ + x, + stats.diffs.reply[i] + ]); + renote.push([ + x, + stats.diffs.renote[i] + ]); + } + + const chart = new ApexCharts(this.$refs.chart, { + chart: { + type: 'bar', + stacked: true, + height: 100, + sparkline: { + enabled: true + }, + }, + plotOptions: { + bar: { + columnWidth: '90%' + } + }, + grid: { + clipMarkers: false, + padding: { + top: 16, + right: 16, + bottom: 16, + left: 16 + } + }, + tooltip: { + shared: true, + intersect: false + }, + series: [{ + name: 'Normal', + data: normal + }, { + name: 'Reply', + data: reply + }, { + name: 'Renote', + data: renote + }], + xaxis: { + type: 'datetime', + crosshairs: { + width: 1, + opacity: 1 + } + } + }); + + chart.render(); + }); + }, + + initTl() { + return new Promise((res, rej) => { + this.$root.api('users/notes', { + userId: this.user.id, + limit: fetchLimit + 1, + untilDate: new Date().getTime() + 1000 * 86400 * 365, + withFiles: this.withFiles, + includeMyRenotes: this.$store.state.settings.showMyRenotes, + includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes, + includeLocalRenotes: this.$store.state.settings.showLocalRenotes + }).then(notes => { + if (notes.length == fetchLimit + 1) { + notes.pop(); + this.existMore = true; + } + res(notes); + }, rej); + }); + }, + + fetchMoreNotes() { + this.moreFetching = true; + + const promise = this.$root.api('users/notes', { + userId: this.user.id, + limit: fetchLimit + 1, + untilDate: new Date((this.$refs.timeline as any).tail().createdAt).getTime(), + withFiles: this.withFiles, + includeMyRenotes: this.$store.state.settings.showMyRenotes, + includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes, + includeLocalRenotes: this.$store.state.settings.showLocalRenotes + }); + + promise.then(notes => { + if (notes.length == fetchLimit + 1) { + notes.pop(); + } else { + this.existMore = false; + } + for (const n of notes) (this.$refs.timeline as any).append(n); + this.moreFetching = false; + }); + + return promise; + } + } +}); +</script> + +<style lang="stylus" scoped> +.sainvnaq + display grid + grid-template-columns 1fr 1fr 1fr + gap 8px + padding 16px + + > * + height 70px + background-position center center + background-size cover + background-clip content-box + border-radius 4px + +</style> diff --git a/src/client/app/desktop/views/deck/deck.user-column.vue b/src/client/app/desktop/views/deck/deck.user-column.vue index 16a7aa5b35..d6618c5716 100644 --- a/src/client/app/desktop/views/deck/deck.user-column.vue +++ b/src/client/app/desktop/views/deck/deck.user-column.vue @@ -39,8 +39,10 @@ </div> <div class="counts"> <div> - <b>{{ user.notesCount | number }}</b> - <span>{{ $t('posts') }}</span> + <router-link :to="user | userPage()"> + <b>{{ user.notesCount | number }}</b> + <span>{{ $t('posts') }}</span> + </router-link> </div> <div> <router-link :to="user | userPage('following')"> @@ -56,35 +58,7 @@ </div> </div> </div> - <ui-container v-if="user.pinnedNotes && user.pinnedNotes.length > 0" :body-togglable="true"> - <span slot="header"><fa icon="thumbtack"/> {{ $t('pinned-notes') }}</span> - <div> - <x-note v-for="n in user.pinnedNotes" :key="n.id" :note="n" :mini="true"/> - </div> - </ui-container> - <ui-container v-if="images.length > 0" :body-togglable="true"> - <span slot="header"><fa :icon="['far', 'images']"/> {{ $t('images') }}</span> - <div class="sainvnaq"> - <router-link v-for="image in images" - :style="`background-image: url(${image.thumbnailUrl})`" - :key="`${image.id}:${image._note.id}`" - :to="image._note | notePage" - :title="`${image.name}\n${(new Date(image.createdAt)).toLocaleString()}`" - ></router-link> - </div> - </ui-container> - <ui-container :body-togglable="true"> - <span slot="header"><fa :icon="['far', 'chart-bar']"/> {{ $t('activity') }}</span> - <div> - <div ref="chart"></div> - </div> - </ui-container> - <ui-container> - <span slot="header"><fa :icon="['far', 'comment-alt']"/> {{ $t('timeline') }}</span> - <div> - <x-notes ref="timeline" :more="existMore ? fetchMoreNotes : null"/> - </div> - </ui-container> + <router-view :user="user"></router-view> </div> </x-column> </template> @@ -94,30 +68,18 @@ import Vue from 'vue'; import i18n from '../../../i18n'; import parseAcct from '../../../../../misc/acct/parse'; import XColumn from './deck.column.vue'; -import XNotes from './deck.notes.vue'; -import XNote from '../components/note.vue'; import XUserMenu from '../../../common/views/components/user-menu.vue'; -import { concat } from '../../../../../prelude/array'; -import ApexCharts from 'apexcharts'; - -const fetchLimit = 10; export default Vue.extend({ i18n: i18n('deck/deck.user-column.vue'), components: { XColumn, - XNotes, - XNote }, data() { return { user: null, fetching: true, - existMore: false, - moreFetching: false, - withFiles: false, - images: [], }; }, @@ -146,160 +108,9 @@ export default Vue.extend({ this.$root.api('users/show', parseAcct(this.$route.params.user)).then(user => { this.user = user; this.fetching = false; - - this.$nextTick(() => { - (this.$refs.timeline as any).init(() => this.initTl()); - }); - - const image = [ - 'image/jpeg', - 'image/png', - 'image/gif' - ]; - - this.$root.api('users/notes', { - userId: this.user.id, - fileType: image, - excludeNsfw: !this.$store.state.device.alwaysShowNsfw, - limit: 9, - untilDate: new Date().getTime() + 1000 * 86400 * 365 - }).then(notes => { - for (const note of notes) { - for (const file of note.files) { - file._note = note; - } - } - const files = concat(notes.map((n: any): any[] => n.files)); - this.images = files.filter(f => image.includes(f.type)).slice(0, 9); - }); - - this.$root.api('charts/user/notes', { - userId: this.user.id, - span: 'day', - limit: 21 - }).then(stats => { - const normal = []; - const reply = []; - const renote = []; - - const now = new Date(); - const y = now.getFullYear(); - const m = now.getMonth(); - const d = now.getDate(); - - for (let i = 0; i < 21; i++) { - const x = new Date(y, m, d - i); - normal.push([ - x, - stats.diffs.normal[i] - ]); - reply.push([ - x, - stats.diffs.reply[i] - ]); - renote.push([ - x, - stats.diffs.renote[i] - ]); - } - - const chart = new ApexCharts(this.$refs.chart, { - chart: { - type: 'bar', - stacked: true, - height: 100, - sparkline: { - enabled: true - }, - }, - plotOptions: { - bar: { - columnWidth: '90%' - } - }, - grid: { - clipMarkers: false, - padding: { - top: 16, - right: 16, - bottom: 16, - left: 16 - } - }, - tooltip: { - shared: true, - intersect: false - }, - series: [{ - name: 'Normal', - data: normal - }, { - name: 'Reply', - data: reply - }, { - name: 'Renote', - data: renote - }], - xaxis: { - type: 'datetime', - crosshairs: { - width: 1, - opacity: 1 - } - } - }); - - chart.render(); - }); }); }, - initTl() { - return new Promise((res, rej) => { - this.$root.api('users/notes', { - userId: this.user.id, - limit: fetchLimit + 1, - untilDate: new Date().getTime() + 1000 * 86400 * 365, - withFiles: this.withFiles, - includeMyRenotes: this.$store.state.settings.showMyRenotes, - includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes, - includeLocalRenotes: this.$store.state.settings.showLocalRenotes - }).then(notes => { - if (notes.length == fetchLimit + 1) { - notes.pop(); - this.existMore = true; - } - res(notes); - }, rej); - }); - }, - - fetchMoreNotes() { - this.moreFetching = true; - - const promise = this.$root.api('users/notes', { - userId: this.user.id, - limit: fetchLimit + 1, - untilDate: new Date((this.$refs.timeline as any).tail().createdAt).getTime(), - withFiles: this.withFiles, - includeMyRenotes: this.$store.state.settings.showMyRenotes, - includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes, - includeLocalRenotes: this.$store.state.settings.showLocalRenotes - }); - - promise.then(notes => { - if (notes.length == fetchLimit + 1) { - notes.pop(); - } else { - this.existMore = false; - } - for (const n of notes) (this.$refs.timeline as any).append(n); - this.moreFetching = false; - }); - - return promise; - }, - menu() { this.$root.new(XUserMenu, { source: this.$refs.menu, @@ -439,34 +250,13 @@ export default Vue.extend({ > a color var(--text) - >>> b - display block - font-size 110% + > b + display block + font-size 110% - >>> span - display block - font-size 80% - opacity 0.7 - - .sainvnaq - display grid - grid-template-columns 1fr 1fr 1fr - gap 8px - padding 16px - - > * - height 70px - background-position center center - background-size cover - background-clip content-box - border-radius 4px - - > .activity - > div - background var(--face) - - > .tl - > div - background var(--face) + > span + display block + font-size 80% + opacity 0.7 </style> diff --git a/src/client/app/desktop/views/home/user/user.vue b/src/client/app/desktop/views/home/user/index.vue similarity index 54% rename from src/client/app/desktop/views/home/user/user.vue rename to src/client/app/desktop/views/home/user/index.vue index 6a827f4beb..24abeadd6a 100644 --- a/src/client/app/desktop/views/home/user/user.vue +++ b/src/client/app/desktop/views/home/user/index.vue @@ -1,22 +1,10 @@ <template> -<div class="xygkxeaeontfaokvqmiblezmhvhostak" v-if="!fetching"> +<div class="omechnps" v-if="!fetching"> <div class="is-suspended" v-if="user.isSuspended"><fa icon="exclamation-triangle"/> {{ $t('@.user-suspended') }}</div> <div class="is-remote" v-if="user.host != null"><fa icon="exclamation-triangle"/> {{ $t('@.is-remote-user') }}<a :href="user.url || user.uri" target="_blank">{{ $t('@.view-on-remote') }}</a></div> <div class="main"> - <x-header :user="user"/> - <x-integrations :user="user" v-if="user.twitter || user.github || user.discord"/> - <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"> - <ui-container :body-togglable="true"> - <template slot="header"><fa icon="chart-bar"/>{{ $t('activity') }}</template> - <x-activity :user="user" :limit="35" style="padding: 16px;"/> - </ui-container> - </div> - <x-photos :user="user"/> - <x-friends :user="user"/> - <x-followers-you-know v-if="$store.getters.isSignedIn && $store.state.i.id != user.id" :user="user"/> - <x-timeline class="timeline" ref="tl" :user="user"/> + <x-header class="header" :user="user"/> + <router-view :user="user"></router-view> </div> </div> </template> @@ -27,23 +15,11 @@ import i18n from '../../../../i18n'; import parseAcct from '../../../../../../misc/acct/parse'; import Progress from '../../../../common/scripts/loading'; import XHeader from './user.header.vue'; -import XTimeline from './user.timeline.vue'; -import XPhotos from './user.photos.vue'; -import XFollowersYouKnow from './user.followers-you-know.vue'; -import XFriends from './user.friends.vue'; -import XIntegrations from './user.integrations.vue'; -import XActivity from '../../../../common/views/components/activity.vue'; export default Vue.extend({ i18n: i18n(), components: { - XHeader, - XTimeline, - XPhotos, - XFollowersYouKnow, - XFriends, - XIntegrations, - XActivity + XHeader }, data() { return { @@ -76,7 +52,7 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> -.xygkxeaeontfaokvqmiblezmhvhostak +.omechnps width 100% margin 0 auto @@ -100,10 +76,7 @@ export default Vue.extend({ font-weight bold > .main - > * + > .header margin-bottom 16px - > .timeline - box-shadow var(--shadow) - </style> 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 05d3674996..debfb24393 100644 --- a/src/client/app/desktop/views/home/user/user.header.vue +++ b/src/client/app/desktop/views/home/user/user.header.vue @@ -40,7 +40,7 @@ <span class="birthday" v-if="user.host === null && user.profile.birthday"><fa icon="birthday-cake"/> {{ user.profile.birthday.replace('-', $t('year')).replace('-', $t('month')) + $t('day') }} ({{ $t('years-old', { age }) }})</span> </div> <div class="status"> - <span class="notes-count"><b>{{ user.notesCount | number }}</b>{{ $t('posts') }}</span> + <router-link :to="user | userPage()" class="notes-count"><b>{{ user.notesCount | number }}</b>{{ $t('posts') }}</router-link> <router-link :to="user | userPage('following')" class="following clickable"><b>{{ user.followingCount | number }}</b>{{ $t('following') }}</router-link> <router-link :to="user | userPage('followers')" class="followers clickable"><b>{{ user.followersCount | number }}</b>{{ $t('followers') }}</router-link> </div> diff --git a/src/client/app/desktop/views/home/user/user.home.vue b/src/client/app/desktop/views/home/user/user.home.vue new file mode 100644 index 0000000000..b4426ac755 --- /dev/null +++ b/src/client/app/desktop/views/home/user/user.home.vue @@ -0,0 +1,63 @@ +<template> +<div class="lnctpgve"> + <x-integrations :user="user" v-if="user.twitter || user.github || user.discord"/> + <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"> + <ui-container :body-togglable="true"> + <template slot="header"><fa icon="chart-bar"/>{{ $t('activity') }}</template> + <x-activity :user="user" :limit="35" style="padding: 16px;"/> + </ui-container> + </div> + <x-photos :user="user"/> + <x-friends :user="user"/> + <x-followers-you-know v-if="$store.getters.isSignedIn && $store.state.i.id != user.id" :user="user"/> + <x-timeline class="timeline" ref="tl" :user="user"/> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import i18n from '../../../../i18n'; +import parseAcct from '../../../../../../misc/acct/parse'; +import Progress from '../../../../common/scripts/loading'; +import XTimeline from './user.timeline.vue'; +import XPhotos from './user.photos.vue'; +import XFollowersYouKnow from './user.followers-you-know.vue'; +import XFriends from './user.friends.vue'; +import XIntegrations from './user.integrations.vue'; +import XActivity from '../../../../common/views/components/activity.vue'; + +export default Vue.extend({ + i18n: i18n(), + components: { + XTimeline, + XPhotos, + XFollowersYouKnow, + XFriends, + XIntegrations, + XActivity + }, + props: { + user: { + type: Object, + required: true + } + }, + methods: { + warp(date) { + (this.$refs.tl as any).warp(date); + } + } +}); +</script> + +<style lang="stylus" scoped> +.lnctpgve + > * + margin-bottom 16px + + > .timeline + box-shadow var(--shadow) + +</style> diff --git a/src/client/app/desktop/views/pages/user-following-or-followers.vue b/src/client/app/desktop/views/pages/user-following-or-followers.vue deleted file mode 100644 index fd842cbcd4..0000000000 --- a/src/client/app/desktop/views/pages/user-following-or-followers.vue +++ /dev/null @@ -1,120 +0,0 @@ -<template> -<mk-ui> - <div class="yyyocnobkvdlnyapyauyopbskldsnipz" v-if="!fetching"> - <header> - <mk-avatar class="avatar" :user="user"/> - <i18n :path="isFollowing ? 'following' : 'followers'" tag="p"> - <router-link :to="user | userPage" place="user"> - <mk-user-name :user="user"/> - </router-link> - </i18n> - </header> - <div class="users"> - <mk-user-card v-for="user in users" :user="user" :key="user.id"/> - </div> - <div class="more" v-if="next"> - <ui-button inline @click="fetchMore">{{ $t('@.load-more') }}</ui-button> - </div> - </div> -</mk-ui> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; -import parseAcct from '../../../../../misc/acct/parse'; -import Progress from '../../../common/scripts/loading'; - -const limit = 16; - -export default Vue.extend({ - i18n: i18n('desktop/views/pages/user-following-or-followers.vue'), - - data() { - return { - fetching: true, - user: null, - users: [], - next: undefined - }; - }, - computed: { - isFollowing(): boolean { - return this.$route.name == 'userFollowing'; - }, - endpoint(): string { - return this.isFollowing ? 'users/following' : 'users/followers'; - } - }, - watch: { - $route: 'fetch' - }, - created() { - this.fetch(); - }, - methods: { - fetch() { - this.fetching = true; - Progress.start(); - this.$root.api('users/show', parseAcct(this.$route.params.user)).then(user => { - this.user = user; - this.$root.api(this.endpoint, { - userId: this.user.id, - iknow: false, - limit: limit - }).then(x => { - this.users = x.users; - this.next = x.next; - this.fetching = false; - Progress.done(); - }); - }); - }, - - fetchMore() { - this.$root.api(this.endpoint, { - userId: this.user.id, - iknow: false, - limit: limit, - cursor: this.next - }).then(x => { - this.users = this.users.concat(x.users); - this.next = x.next; - }); - } - } -}); -</script> - -<style lang="stylus" scoped> -.yyyocnobkvdlnyapyauyopbskldsnipz - width 100% - max-width 1280px - padding 32px - margin 0 auto - - > header - display flex - align-items center - margin 0 0 16px 0 - color var(--text) - - > .avatar - width 64px - height 64px - - > p - margin 0 16px - font-size 24px - font-weight bold - - > .users - display grid - grid-template-columns 1fr 1fr 1fr 1fr - gap 16px - - > .more - margin 32px 16px 16px 16px - text-align center - -</style> diff --git a/src/client/app/mobile/script.ts b/src/client/app/mobile/script.ts index 1feff3d5eb..ad37ba70ab 100644 --- a/src/client/app/mobile/script.ts +++ b/src/client/app/mobile/script.ts @@ -21,8 +21,6 @@ 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'; -import MkFollowing from './views/pages/following.vue'; import MkFavorites from './views/pages/favorites.vue'; import MkUserLists from './views/pages/user-lists.vue'; import MkUserList from './views/pages/user-list.vue'; @@ -137,9 +135,11 @@ init((launch) => { { path: '/explore', name: 'explore', component: () => import('./views/pages/explore.vue').then(m => m.default) }, { path: '/share', component: MkShare }, { path: '/games/reversi/:game?', name: 'reversi', component: MkReversi }, - { path: '/@:user', component: () => import('./views/pages/user.vue').then(m => m.default) }, - { path: '/@:user/followers', component: MkFollowers }, - { path: '/@:user/following', component: MkFollowing }, + { path: '/@:user', component: () => import('./views/pages/user/index.vue').then(m => m.default), children: [ + { path: '', name: 'user', component: () => import('./views/pages/user/home.vue').then(m => m.default) }, + { 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: '/notes/:note', component: MkNote }, { path: '/authorize-follow', component: MkFollow }, { path: '*', component: MkNotFound } diff --git a/src/client/app/mobile/views/components/index.ts b/src/client/app/mobile/views/components/index.ts index 94bc8d23fd..864098640b 100644 --- a/src/client/app/mobile/views/components/index.ts +++ b/src/client/app/mobile/views/components/index.ts @@ -13,7 +13,6 @@ import friendsMaker from './friends-maker.vue'; import notification from './notification.vue'; import notifications from './notifications.vue'; import notificationPreview from './notification-preview.vue'; -import usersList from './users-list.vue'; import userPreview from './user-preview.vue'; import userTimeline from './user-timeline.vue'; import userListTimeline from './user-list-timeline.vue'; @@ -33,7 +32,6 @@ Vue.component('mk-friends-maker', friendsMaker); Vue.component('mk-notification', notification); Vue.component('mk-notifications', notifications); Vue.component('mk-notification-preview', notificationPreview); -Vue.component('mk-users-list', usersList); Vue.component('mk-user-preview', userPreview); Vue.component('mk-user-timeline', userTimeline); Vue.component('mk-user-list-timeline', userListTimeline); diff --git a/src/client/app/mobile/views/components/users-list.vue b/src/client/app/mobile/views/components/users-list.vue deleted file mode 100644 index 0c5c934dcf..0000000000 --- a/src/client/app/mobile/views/components/users-list.vue +++ /dev/null @@ -1,135 +0,0 @@ -<template> -<div class="mk-users-list"> - <nav> - <span :data-active="mode == 'all'" @click="mode = 'all'">{{ $t('all') }}<span>{{ count }}</span></span> - <span v-if="$store.getters.isSignedIn && youKnowCount" :data-active="mode == 'iknow'" @click="mode = 'iknow'">{{ $t('known') }}<span>{{ youKnowCount }}</span></span> - </nav> - <div class="users" v-if="!fetching && users.length != 0"> - <mk-user-preview v-for="u in users" :user="u" :key="u.id"/> - </div> - <ui-button class="more" v-if="!fetching && next != null" @click="more" :disabled="moreFetching"> - <span v-if="!moreFetching">{{ $t('@.load-more') }}</span> - <span v-if="moreFetching">{{ $t('@.loading') }}<mk-ellipsis/></span> - </ui-button> - <p class="no" v-if="!fetching && users.length == 0"> - <slot></slot> - </p> - <p class="fetching" v-if="fetching"><fa icon="spinner" pulse fixed-width/>{{ $t('@.loading') }}<mk-ellipsis/></p> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; -export default Vue.extend({ - i18n: i18n('mobile/views/components/users-list.vue'), - props: ['fetch', 'count', 'youKnowCount'], - data() { - return { - limit: 30, - mode: 'all', - fetching: true, - moreFetching: false, - users: [], - next: null - }; - }, - watch: { - mode() { - this._fetch(); - } - }, - mounted() { - this._fetch(() => { - this.$emit('loaded'); - }); - }, - methods: { - _fetch(cb?) { - this.fetching = true; - this.fetch(this.mode == 'iknow', this.limit, null, obj => { - this.users = obj.users; - this.next = obj.next; - this.fetching = false; - if (cb) cb(); - }); - }, - more() { - this.moreFetching = true; - this.fetch(this.mode == 'iknow', this.limit, this.next, obj => { - this.moreFetching = false; - this.users = this.users.concat(obj.users); - this.next = obj.next; - }); - } - } -}); -</script> - -<style lang="stylus" scoped> - - -.mk-users-list - - > nav - display flex - justify-content center - margin 0 auto - max-width 600px - border-bottom solid 1px rgba(#000, 0.2) - - > span - display block - flex 1 1 - text-align center - line-height 52px - font-size 14px - color #657786 - border-bottom solid 2px transparent - - &[data-active] - font-weight bold - color var(--primary) - border-color var(--primary) - - > span - display inline-block - margin-left 4px - padding 2px 5px - font-size 12px - line-height 1 - color #fff - background rgba(#000, 0.3) - border-radius 20px - - > .users - margin 8px auto - max-width 500px - width calc(100% - 16px) - background #fff - border-radius 8px - box-shadow 0 0 0 1px rgba(#000, 0.2) - - @media (min-width 500px) - margin 16px auto - width calc(100% - 32px) - - > * - border-bottom solid 1px rgba(#000, 0.05) - - > .no - margin 0 - padding 16px - text-align center - color var(--text) - - > .fetching - margin 0 - padding 16px - text-align center - color var(--text) - - > [data-icon] - margin-right 4px - -</style> diff --git a/src/client/app/mobile/views/pages/followers.vue b/src/client/app/mobile/views/pages/followers.vue deleted file mode 100644 index f5ac8ef195..0000000000 --- a/src/client/app/mobile/views/pages/followers.vue +++ /dev/null @@ -1,70 +0,0 @@ -<template> -<mk-ui> - <template slot="header" v-if="!fetching"> - <img :src="user.avatarUrl" alt=""> - <mfm :text="$t('followers-of', { name })" :should-break="false" :plain-text="true" :custom-emojis="user.emojis"/> - </template> - <mk-users-list - v-if="!fetching" - :fetch="fetchUsers" - :count="user.followersCount" - :you-know-count="user.followersYouKnowCount" - @loaded="onLoaded" - > - %i18n:@no-users% - </mk-users-list> -</mk-ui> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; -import Progress from '../../../common/scripts/loading'; -import parseAcct from '../../../../../misc/acct/parse'; -import getUserName from '../../../../../misc/get-user-name'; - -export default Vue.extend({ - i18n: i18n('mobile/views/pages/followers.vue'), - data() { - return { - fetching: true, - user: null - }; - }, - computed: { - name() { - return getUserName(this.user); - } - }, - watch: { - $route: 'fetch' - }, - created() { - this.fetch(); - }, - methods: { - fetch() { - Progress.start(); - this.fetching = true; - - this.$root.api('users/show', parseAcct(this.$route.params.user)).then(user => { - this.user = user; - this.fetching = false; - - document.title = `${this.$t('followers-of').replace('{}', this.name)} | ${this.$root.instanceName}`; - }); - }, - onLoaded() { - Progress.done(); - }, - fetchUsers(iknow, limit, cursor, cb) { - this.$root.api('users/followers', { - userId: this.user.id, - iknow: iknow, - limit: limit, - cursor: cursor ? cursor : undefined - }).then(cb); - } - } -}); -</script> diff --git a/src/client/app/mobile/views/pages/following.vue b/src/client/app/mobile/views/pages/following.vue deleted file mode 100644 index d603532498..0000000000 --- a/src/client/app/mobile/views/pages/following.vue +++ /dev/null @@ -1,69 +0,0 @@ -<template> -<mk-ui> - <template slot="header" v-if="!fetching"> - <img :src="user.avatarUrl" alt=""> - <mfm :text="$t('following-of', { name })" :should-break="false" :plain-text="true" :custom-emojis="user.emojis"/> - </template> - <mk-users-list - v-if="!fetching" - :fetch="fetchUsers" - :count="user.followingCount" - :you-know-count="user.followingYouKnowCount" - @loaded="onLoaded" - > - %i18n:@no-users% - </mk-users-list> -</mk-ui> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; -import Progress from '../../../common/scripts/loading'; -import parseAcct from '../../../../../misc/acct/parse'; - -export default Vue.extend({ - i18n: i18n('mobile/views/pages/following.vue'), - data() { - return { - fetching: true, - user: null - }; - }, - computed: { - name(): string { - return Vue.filter('userName')(this.user); - } - }, - watch: { - $route: 'fetch' - }, - created() { - this.fetch(); - }, - methods: { - fetch() { - Progress.start(); - this.fetching = true; - - this.$root.api('users/show', parseAcct(this.$route.params.user)).then(user => { - this.user = user; - this.fetching = false; - - document.title = `${this.$t('followers-of').replace('{}', this.name)} | ${this.$root.instanceName}`; - }); - }, - onLoaded() { - Progress.done(); - }, - fetchUsers(iknow, limit, cursor, cb) { - this.$root.api('users/following', { - userId: this.user.id, - iknow: iknow, - limit: limit, - cursor: cursor ? cursor : undefined - }).then(cb); - } - } -}); -</script> diff --git a/src/client/app/mobile/views/pages/user.vue b/src/client/app/mobile/views/pages/user/index.vue similarity index 88% rename from src/client/app/mobile/views/pages/user.vue rename to src/client/app/mobile/views/pages/user/index.vue index 5d15a9718a..48b65624ef 100644 --- a/src/client/app/mobile/views/pages/user.vue +++ b/src/client/app/mobile/views/pages/user/index.vue @@ -43,22 +43,22 @@ </p> </div> <div class="status"> - <a> + <router-link :to="user | userPage()"> <b>{{ user.notesCount | number }}</b> <i>{{ $t('notes') }}</i> - </a> - <a :href="user | userPage('following')"> + </router-link> + <router-link :to="user | userPage('following')"> <b>{{ user.followingCount | number }}</b> <i>{{ $t('following') }}</i> - </a> - <a :href="user | userPage('followers')"> + </router-link> + <router-link :to="user | userPage('followers')"> <b>{{ user.followersCount | number }}</b> <i>{{ $t('followers') }}</i> - </a> + </router-link> </div> </div> </header> - <nav> + <nav v-if="$route.name == 'user'"> <div class="nav-container"> <a :data-active="page == 'home'" @click="page = 'home'"><fa icon="home"/> {{ $t('overview') }}</a> <a :data-active="page == 'notes'" @click="page = 'notes'"><fa :icon="['far', 'comment-alt']"/> {{ $t('timeline') }}</a> @@ -66,9 +66,12 @@ </div> </nav> <div class="body"> - <x-home v-if="page == 'home'" :user="user"/> - <mk-user-timeline v-if="page == 'notes'" :user="user" key="tl"/> - <mk-user-timeline v-if="page == 'media'" :user="user" :with-media="true" key="media"/> + <template v-if="$route.name == 'user'"> + <x-home v-if="page == 'home'" :user="user"/> + <mk-user-timeline v-if="page == 'notes'" :user="user" key="tl"/> + <mk-user-timeline v-if="page == 'media'" :user="user" :with-media="true" key="media"/> + </template> + <router-view :user="user"></router-view> </div> </main> </mk-ui> @@ -76,13 +79,13 @@ <script lang="ts"> import Vue from 'vue'; -import i18n from '../../../i18n'; +import i18n from '../../../../i18n'; import * as age from 's-age'; -import parseAcct from '../../../../../misc/acct/parse'; -import Progress from '../../../common/scripts/loading'; -import XUserMenu from '../../../common/views/components/user-menu.vue'; -import XHome from './user/home.vue'; -import { getStaticImageUrl } from '../../../common/scripts/get-static-image-url'; +import parseAcct from '../../../../../../misc/acct/parse'; +import Progress from '../../../../common/scripts/loading'; +import XUserMenu from '../../../../common/views/components/user-menu.vue'; +import XHome from './home.vue'; +import { getStaticImageUrl } from '../../../../common/scripts/get-static-image-url'; export default Vue.extend({ i18n: i18n('mobile/views/pages/user.vue'), @@ -93,7 +96,7 @@ export default Vue.extend({ return { fetching: true, user: null, - page: 'home' + page: this.$route.name == 'user' ? 'home' : null }; }, computed: { diff --git a/src/server/api/endpoints/users/followers.ts b/src/server/api/endpoints/users/followers.ts index 2a39da4064..2f7f1af6a5 100644 --- a/src/server/api/endpoints/users/followers.ts +++ b/src/server/api/endpoints/users/followers.ts @@ -16,7 +16,7 @@ export const meta = { params: { userId: { - validator: $.type(ID), + validator: $.optional.type(ID), transform: transform, desc: { 'ja-JP': '対象のユーザーのID', @@ -24,6 +24,14 @@ export const meta = { } }, + username: { + validator: $.optional.str + }, + + host: { + validator: $.optional.nullable.str + }, + limit: { validator: $.optional.num.range(1, 100), default: 10 @@ -43,14 +51,11 @@ export const meta = { }; export default define(meta, (ps, me) => new Promise(async (res, rej) => { - // Lookup user - const user = await User.findOne({ - _id: ps.userId - }, { - fields: { - _id: true - } - }); + const q: any = ps.userId != null + ? { _id: ps.userId } + : { usernameLower: ps.username.toLowerCase(), host: ps.host }; + + const user = await User.findOne(q); if (user === null) { return rej('user not found'); diff --git a/src/server/api/endpoints/users/following.ts b/src/server/api/endpoints/users/following.ts index 4ccc13f633..1485a63f24 100644 --- a/src/server/api/endpoints/users/following.ts +++ b/src/server/api/endpoints/users/following.ts @@ -16,7 +16,7 @@ export const meta = { params: { userId: { - validator: $.type(ID), + validator: $.optional.type(ID), transform: transform, desc: { 'ja-JP': '対象のユーザーのID', @@ -24,6 +24,14 @@ export const meta = { } }, + username: { + validator: $.optional.str + }, + + host: { + validator: $.optional.nullable.str + }, + limit: { validator: $.optional.num.range(1, 100), default: 10 @@ -43,14 +51,11 @@ export const meta = { }; export default define(meta, (ps, me) => new Promise(async (res, rej) => { - // Lookup user - const user = await User.findOne({ - _id: ps.userId - }, { - fields: { - _id: true - } - }); + const q: any = ps.userId != null + ? { _id: ps.userId } + : { usernameLower: ps.username.toLowerCase(), host: ps.host }; + + const user = await User.findOne(q); if (user === null) { return rej('user not found');