diff --git a/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/application/actor/ActorDetail.kt b/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/application/actor/ActorDetail.kt index 3398d719..5a5eff44 100644 --- a/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/application/actor/ActorDetail.kt +++ b/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/application/actor/ActorDetail.kt @@ -15,7 +15,7 @@ data class ActorDetail( val iconUrl: URI?, val bannerURL: URI?, val followingCount: Int?, - val followersCount: Int? + val followersCount: Int?, ) { companion object { fun of(actor: Actor, iconUrl: URI?, bannerURL: URI?): ActorDetail { @@ -31,7 +31,7 @@ data class ActorDetail( iconUrl = iconUrl, bannerURL = bannerURL, followingCount = actor.followingCount?.relationshipCount, - followersCount = actor.followersCount?.relationshipCount + followersCount = actor.followersCount?.relationshipCount, ) } } diff --git a/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/application/timeline/GetUserTimeline.kt b/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/application/timeline/GetUserTimeline.kt new file mode 100644 index 00000000..8ebc7d15 --- /dev/null +++ b/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/application/timeline/GetUserTimeline.kt @@ -0,0 +1,5 @@ +package dev.usbharu.hideout.core.application.timeline + +import dev.usbharu.hideout.core.domain.model.support.page.Page + +data class GetUserTimeline(val id: Long, val page: Page) diff --git a/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/application/timeline/GetUserTimelineApplicationService.kt b/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/application/timeline/GetUserTimelineApplicationService.kt new file mode 100644 index 00000000..0019a2d8 --- /dev/null +++ b/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/application/timeline/GetUserTimelineApplicationService.kt @@ -0,0 +1,49 @@ +package dev.usbharu.hideout.core.application.timeline + +import dev.usbharu.hideout.core.application.post.PostDetail +import dev.usbharu.hideout.core.application.shared.AbstractApplicationService +import dev.usbharu.hideout.core.application.shared.Transaction +import dev.usbharu.hideout.core.domain.model.actor.ActorId +import dev.usbharu.hideout.core.domain.model.post.PostId +import dev.usbharu.hideout.core.domain.model.post.PostRepository +import dev.usbharu.hideout.core.domain.model.post.Visibility +import dev.usbharu.hideout.core.domain.model.support.page.PaginationList +import dev.usbharu.hideout.core.domain.model.support.principal.Principal +import dev.usbharu.hideout.core.query.usertimeline.UserTimelineQueryService +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service + +@Service +class GetUserTimelineApplicationService( + private val userTimelineQueryService: UserTimelineQueryService, + private val postRepository: PostRepository, + transaction: Transaction +) : + AbstractApplicationService>(transaction, logger) { + override suspend fun internalExecute( + command: GetUserTimeline, + principal: Principal + ): PaginationList { + val postList = postRepository.findByActorIdAndVisibilityInList( + ActorId(command.id), + listOf(Visibility.PUBLIC, Visibility.UNLISTED, Visibility.FOLLOWERS), + command.page + ) + + val postIdList = + postList.mapNotNull { it.repostId } + postList.mapNotNull { it.replyId } + postList.map { it.id } + + val postDetailMap = userTimelineQueryService.findByIdAll(postIdList, principal).associateBy { it.id } + + return PaginationList(postList.mapNotNull { + postDetailMap[it.id.id]?.copy( + repost = postDetailMap[it.repostId?.id], + reply = postDetailMap[it.replyId?.id] + ) + }, postList.next, postList.prev) + } + + companion object { + private val logger = LoggerFactory.getLogger(GetUserTimelineApplicationService::class.java) + } +} \ No newline at end of file diff --git a/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/exposedquery/ExposedUserTimelineQueryService.kt b/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/exposedquery/ExposedUserTimelineQueryService.kt new file mode 100644 index 00000000..922abdca --- /dev/null +++ b/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/exposedquery/ExposedUserTimelineQueryService.kt @@ -0,0 +1,110 @@ +package dev.usbharu.hideout.core.infrastructure.exposedquery + +import dev.usbharu.hideout.core.application.post.ActorDetail +import dev.usbharu.hideout.core.application.post.MediaDetail +import dev.usbharu.hideout.core.application.post.PostDetail +import dev.usbharu.hideout.core.domain.model.post.PostId +import dev.usbharu.hideout.core.domain.model.post.Visibility +import dev.usbharu.hideout.core.domain.model.support.principal.Principal +import dev.usbharu.hideout.core.infrastructure.exposedrepository.* +import dev.usbharu.hideout.core.query.usertimeline.UserTimelineQueryService +import org.jetbrains.exposed.sql.* +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Repository +import java.net.URI + +@Repository +class ExposedUserTimelineQueryService : UserTimelineQueryService, AbstractRepository() { + + protected fun authorizedQuery(principal: Principal? = null): QueryAlias { + if (principal == null) { + return Posts + .selectAll() + .where { + Posts.visibility eq Visibility.PUBLIC.name or (Posts.visibility eq Visibility.UNLISTED.name) + }.alias("authorized_table") + } + + val relationshipsAlias = Relationships.alias("inverse_relationships") + + return Posts + .leftJoin(PostsVisibleActors) + .leftJoin(Relationships, onColumn = { Posts.actorId }, otherColumn = { actorId }) + .leftJoin( + relationshipsAlias, + onColumn = { Posts.actorId }, + otherColumn = { relationshipsAlias[Relationships.targetActorId] } + ) + .select(Posts.columns) + .where { + Posts.visibility eq Visibility.PUBLIC.name or + (Posts.visibility eq Visibility.UNLISTED.name) or + (Posts.visibility eq Visibility.DIRECT.name and (PostsVisibleActors.actorId eq principal.actorId.id)) or + (Posts.visibility eq Visibility.FOLLOWERS.name and (Relationships.blocking eq false and (relationshipsAlias[Relationships.following] eq true))) or + (Posts.actorId eq principal.actorId.id) + } + .alias("authorized_table") + } + + override suspend fun findByIdAll(idList: List, principal: Principal): List { + val authorizedQuery = authorizedQuery(principal) + + val iconMedia = Media.alias("ICON_MEDIA") + + return authorizedQuery + .leftJoin(PostsVisibleActors, { authorizedQuery[Posts.id] }, { PostsVisibleActors.postId }) + .leftJoin(Actors, { authorizedQuery[Posts.actorId] }, { Actors.id }) + .leftJoin(iconMedia, { Actors.icon }, { iconMedia[Media.id] }) + .leftJoin(PostsMedia, { authorizedQuery[Posts.id] }, { PostsMedia.postId }) + .leftJoin(Media, { PostsMedia.mediaId }, { Media.id }) + .selectAll() + .where { authorizedQuery[Posts.id] inList idList.map { it.id } } + .groupBy { it[authorizedQuery[Posts.id]] } + .map { it.value } + .map { + toPostDetail(it.first(), authorizedQuery, iconMedia).copy( + mediaDetailList = it.mapNotNull { resultRow -> + resultRow.toMediaOrNull()?.let { it1 -> MediaDetail.of(it1) } + } + ) + } + } + + private fun toPostDetail(it: ResultRow, authorizedQuery: QueryAlias, iconMedia: Alias): PostDetail { + return PostDetail( + it[authorizedQuery[Posts.id]], + ActorDetail( + actorId = it[authorizedQuery[Posts.actorId]], + instanceId = it[Actors.instance], + name = it[Actors.name], + domain = it[Actors.domain], + screenName = it[Actors.screenName], + url = URI.create(it[Actors.url]), + locked = it[Actors.locked], + icon = it.getOrNull(iconMedia[Media.url])?.let { URI.create(it) } + ), + overview = it[authorizedQuery[Posts.overview]], + text = it[authorizedQuery[Posts.text]], + content = it[authorizedQuery[Posts.content]], + createdAt = it[authorizedQuery[Posts.createdAt]], + visibility = Visibility.valueOf(it[authorizedQuery[Posts.visibility]]), + pureRepost = false, + url = URI.create(it[authorizedQuery[Posts.url]]), + apId = URI.create(it[authorizedQuery[Posts.apId]]), + repost = null, + reply = null, + sensitive = it[authorizedQuery[Posts.sensitive]], + deleted = it[authorizedQuery[Posts.deleted]], + mediaDetailList = emptyList(), + moveTo = null + ) + } + + override val logger: Logger + get() = Companion.logger + + companion object { + private val logger = LoggerFactory.getLogger(ExposedUserTimelineQueryService::class.java) + } +} \ No newline at end of file diff --git a/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/exposedrepository/ExposedPostRepository.kt b/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/exposedrepository/ExposedPostRepository.kt index 8dc2184a..9408d01a 100644 --- a/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/exposedrepository/ExposedPostRepository.kt +++ b/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/exposedrepository/ExposedPostRepository.kt @@ -211,17 +211,38 @@ class ExposedPostRepository( visibilityList: List, of: Page? ): PaginationList { + val postList = query { + val query = Posts + .selectAll() + .where { + Posts.actorId eq actorId.id and (visibility inList visibilityList.map { it.name }) + } + + if (of?.minId != null) { + query.orderBy(Posts.createdAt, SortOrder.ASC) + of.minId?.let { query.andWhere { Posts.id greater it } } + of.maxId?.let { query.andWhere { Posts.id less it } } + } else { + query.orderBy(Posts.createdAt, SortOrder.DESC) + of?.sinceId?.let { query.andWhere { Posts.id greater it } } + of?.maxId?.let { query.andWhere { Posts.id less it } } + } + + of?.limit?.let { query.limit(it) } + + query.let(postQueryMapper::map) + } + + val posts = if (of?.minId != null) { + postList.reversed() + } else { + postList + } + return PaginationList( - query { - Posts - .selectAll() - .where { - Posts.actorId eq actorId.id and (visibility inList visibilityList.map { it.name }) - } - .let(postQueryMapper::map) - }, - null, - null + posts, + posts.lastOrNull()?.id, + posts.firstOrNull()?.id ) } diff --git a/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/exposedrepository/MediaRepositoryImpl.kt b/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/exposedrepository/MediaRepositoryImpl.kt index d5bd5f2a..0f145fbe 100644 --- a/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/exposedrepository/MediaRepositoryImpl.kt +++ b/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/exposedrepository/MediaRepositoryImpl.kt @@ -108,6 +108,23 @@ fun ResultRow.toMedia(): EntityMedia { ) } +fun ResultRow.toMediaOrNull(): EntityMedia? { + val fileType = FileType.valueOf(this.getOrNull(Media.type) ?: return null) + val mimeType = this.getOrNull(Media.mimeType) ?: return null + return EntityMedia( + id = MediaId(this.getOrNull(Media.id) ?: return null), + name = MediaName(this.getOrNull(Media.name) ?: return null), + url = URI.create(this.getOrNull(Media.url) ?: return null), + remoteUrl = this[Media.remoteUrl]?.let { URI.create(it) }, + thumbnailUrl = this[Media.thumbnailUrl]?.let { URI.create(it) }, + type = FileType.valueOf(this[Media.type]), + blurHash = this[Media.blurhash]?.let { MediaBlurHash(it) }, + mimeType = MimeType(mimeType.substringBefore("/"), mimeType.substringAfter("/"), fileType), + description = this[Media.description]?.let { MediaDescription(it) }, + actorId = ActorId(this[Media.actorId]) + ) +} + object Media : Table("media") { val id = long("id") val name = varchar("name", 255) diff --git a/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/interfaces/web/user/UserController.kt b/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/interfaces/web/user/UserController.kt index d07a79ea..a9a8979e 100644 --- a/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/interfaces/web/user/UserController.kt +++ b/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/interfaces/web/user/UserController.kt @@ -3,28 +3,48 @@ package dev.usbharu.hideout.core.interfaces.web.user import dev.usbharu.hideout.core.application.actor.GetActorDetail import dev.usbharu.hideout.core.application.actor.GetActorDetailApplicationService import dev.usbharu.hideout.core.application.instance.GetLocalInstanceApplicationService +import dev.usbharu.hideout.core.application.timeline.GetUserTimeline +import dev.usbharu.hideout.core.application.timeline.GetUserTimelineApplicationService import dev.usbharu.hideout.core.domain.model.support.acct.Acct +import dev.usbharu.hideout.core.domain.model.support.page.Page import dev.usbharu.hideout.core.domain.model.support.principal.Anonymous import dev.usbharu.hideout.core.infrastructure.springframework.SpringSecurityFormLoginPrincipalContextHolder import org.springframework.stereotype.Controller import org.springframework.ui.Model import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.RequestParam @Controller class UserController( private val getLocalInstanceApplicationService: GetLocalInstanceApplicationService, private val getUserDetailApplicationService: GetActorDetailApplicationService, private val springSecurityFormLoginPrincipalContextHolder: SpringSecurityFormLoginPrincipalContextHolder, + private val getUserTimelineApplicationService: GetUserTimelineApplicationService ) { @GetMapping("/users/{name}") - suspend fun userById(@PathVariable name: String, model: Model): String { + suspend fun userById( + @PathVariable name: String, + @RequestParam minId: Long?, + @RequestParam maxId: Long?, + @RequestParam sinceId: Long?, + model: Model + ): String { val principal = springSecurityFormLoginPrincipalContextHolder.getPrincipal() model.addAttribute("instance", getLocalInstanceApplicationService.execute(Unit, Anonymous)) + val actorDetail = getUserDetailApplicationService.execute(GetActorDetail(Acct.of(name)), principal) model.addAttribute( "user", - getUserDetailApplicationService.execute(GetActorDetail(Acct.of(name)), principal) + actorDetail + ) + model.addAttribute( + "userTimeline", getUserTimelineApplicationService.execute( + GetUserTimeline( + actorDetail.id, + Page.of(maxId, sinceId, minId, 20) + ), principal + ) ) return "userById" } diff --git a/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/query/usertimeline/UserTimelineQueryService.kt b/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/query/usertimeline/UserTimelineQueryService.kt new file mode 100644 index 00000000..129cc190 --- /dev/null +++ b/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/query/usertimeline/UserTimelineQueryService.kt @@ -0,0 +1,12 @@ +package dev.usbharu.hideout.core.query.usertimeline + +import dev.usbharu.hideout.core.application.post.PostDetail +import dev.usbharu.hideout.core.domain.model.post.PostId +import dev.usbharu.hideout.core.domain.model.support.principal.Principal + +interface UserTimelineQueryService { + /** + * replyやrepost等はnullになります + */ + suspend fun findByIdAll(idList: List, principal: Principal): List +} \ No newline at end of file diff --git a/hideout-core/src/main/resources/messages/hideout-web-messages.properties b/hideout-core/src/main/resources/messages/hideout-web-messages.properties index 6423f1c9..2b25be8d 100644 --- a/hideout-core/src/main/resources/messages/hideout-web-messages.properties +++ b/hideout-core/src/main/resources/messages/hideout-web-messages.properties @@ -22,4 +22,7 @@ post-form.new-posts-form-label=\u4ECA\u306A\u306B\u3057\u3066\u308B? post-form.new-posts-submit=\u6295\u7A3F\u3059\u308B post.repost=\u30EA\u30DD\u30B9\u30C8 post.repost-by={0}\u304C\u30EA\u30DD\u30B9\u30C8 +user-by-id.followersCount={0} \u30D5\u30A9\u30ED\u30EF\u30FC +user-by-id.followingCount={0} \u30D5\u30A9\u30ED\u30FC\u4E2D +user-by-id.postsCount={0} \u6295\u7A3F user-by-id.title={0} \u3055\u3093 - {1} \ No newline at end of file diff --git a/hideout-core/src/main/resources/messages/hideout-web-messages_en_US.properties b/hideout-core/src/main/resources/messages/hideout-web-messages_en_US.properties index f85b5d8a..e679799d 100644 --- a/hideout-core/src/main/resources/messages/hideout-web-messages_en_US.properties +++ b/hideout-core/src/main/resources/messages/hideout-web-messages_en_US.properties @@ -20,4 +20,7 @@ post-form.new-posts-cw-title=Add content warning post-form.new-posts-form-label=What's on your mind? post-form.new-posts-submit=Submit! post.repost=Repost -post.repost-by=Repost by {0} \ No newline at end of file +post.repost-by=Repost by {0} +user-by-id.followersCount={0} Followers +user-by-id.followingCount={0} Following +user-by-id.postsCount={0} Posts \ No newline at end of file diff --git a/hideout-core/src/main/resources/messages/hideout-web-messages_ja_JP.properties b/hideout-core/src/main/resources/messages/hideout-web-messages_ja_JP.properties index 6423f1c9..2b25be8d 100644 --- a/hideout-core/src/main/resources/messages/hideout-web-messages_ja_JP.properties +++ b/hideout-core/src/main/resources/messages/hideout-web-messages_ja_JP.properties @@ -22,4 +22,7 @@ post-form.new-posts-form-label=\u4ECA\u306A\u306B\u3057\u3066\u308B? post-form.new-posts-submit=\u6295\u7A3F\u3059\u308B post.repost=\u30EA\u30DD\u30B9\u30C8 post.repost-by={0}\u304C\u30EA\u30DD\u30B9\u30C8 +user-by-id.followersCount={0} \u30D5\u30A9\u30ED\u30EF\u30FC +user-by-id.followingCount={0} \u30D5\u30A9\u30ED\u30FC\u4E2D +user-by-id.postsCount={0} \u6295\u7A3F user-by-id.title={0} \u3055\u3093 - {1} \ No newline at end of file diff --git a/hideout-core/src/main/resources/templates/userById.html b/hideout-core/src/main/resources/templates/userById.html index ded4a2a7..88fa9727 100644 --- a/hideout-core/src/main/resources/templates/userById.html +++ b/hideout-core/src/main/resources/templates/userById.html @@ -7,8 +7,31 @@ \ No newline at end of file diff --git a/hideout-mastodon/src/main/kotlin/dev/usbharu/hideout/mastodon/infrastructure/exposedquery/ExposedStatusQueryService.kt b/hideout-mastodon/src/main/kotlin/dev/usbharu/hideout/mastodon/infrastructure/exposedquery/ExposedStatusQueryService.kt index 452a5a5d..d80009f1 100644 --- a/hideout-mastodon/src/main/kotlin/dev/usbharu/hideout/mastodon/infrastructure/exposedquery/ExposedStatusQueryService.kt +++ b/hideout-mastodon/src/main/kotlin/dev/usbharu/hideout/mastodon/infrastructure/exposedquery/ExposedStatusQueryService.kt @@ -16,9 +16,8 @@ package dev.usbharu.hideout.mastodon.infrastructure.exposedquery -import dev.usbharu.hideout.core.domain.model.actor.ActorId import dev.usbharu.hideout.core.domain.model.emoji.CustomEmoji -import dev.usbharu.hideout.core.domain.model.media.* +import dev.usbharu.hideout.core.domain.model.media.FileType import dev.usbharu.hideout.core.domain.model.post.Visibility import dev.usbharu.hideout.core.domain.model.support.principal.Principal import dev.usbharu.hideout.core.infrastructure.exposedrepository.* @@ -30,7 +29,6 @@ import dev.usbharu.hideout.mastodon.query.StatusQuery import dev.usbharu.hideout.mastodon.query.StatusQueryService import org.jetbrains.exposed.sql.* import org.springframework.stereotype.Repository -import java.net.URI import dev.usbharu.hideout.core.domain.model.media.Media as EntityMedia import dev.usbharu.hideout.mastodon.interfaces.api.generated.model.CustomEmoji as MastodonEmoji @@ -274,40 +272,6 @@ private fun toStatus(it: ResultRow, queryAlias: QueryAlias, inReplyToAlias: Alia editedAt = null ) -fun ResultRow.toMedia(): EntityMedia { - val fileType = FileType.valueOf(this[Media.type]) - val mimeType = this[Media.mimeType] - return EntityMedia( - id = MediaId(this[Media.id]), - name = MediaName(this[Media.name]), - url = URI.create(this[Media.url]), - remoteUrl = this[Media.remoteUrl]?.let { URI.create(it) }, - thumbnailUrl = this[Media.thumbnailUrl]?.let { URI.create(it) }, - type = fileType, - blurHash = this[Media.blurhash]?.let { MediaBlurHash(it) }, - mimeType = MimeType(mimeType.substringBefore("/"), mimeType.substringAfter("/"), fileType), - description = this[Media.description]?.let { MediaDescription(it) }, - actorId = ActorId(this[Media.actorId]) - ) -} - -fun ResultRow.toMediaOrNull(): EntityMedia? { - val fileType = FileType.valueOf(this.getOrNull(Media.type) ?: return null) - val mimeType = this.getOrNull(Media.mimeType) ?: return null - return EntityMedia( - id = MediaId(this.getOrNull(Media.id) ?: return null), - name = MediaName(this.getOrNull(Media.name) ?: return null), - url = URI.create(this.getOrNull(Media.url) ?: return null), - remoteUrl = this[Media.remoteUrl]?.let { URI.create(it) }, - thumbnailUrl = this[Media.thumbnailUrl]?.let { URI.create(it) }, - type = FileType.valueOf(this[Media.type]), - blurHash = this[Media.blurhash]?.let { MediaBlurHash(it) }, - mimeType = MimeType(mimeType.substringBefore("/"), mimeType.substringAfter("/"), fileType), - description = this[Media.description]?.let { MediaDescription(it) }, - actorId = ActorId(this[Media.actorId]) - ) -} - fun EntityMedia.toMediaAttachments(): MediaAttachment = MediaAttachment( id = id.id.toString(), type = when (type) {