This commit is contained in:
usbharu 2024-08-29 14:02:16 +09:00
parent 992cc18c62
commit 0463ad6b69
Signed by: usbharu
GPG Key ID: 8CB1087135660B8D
13 changed files with 283 additions and 53 deletions

View File

@ -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,
)
}
}

View File

@ -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)

View File

@ -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<GetUserTimeline, PaginationList<PostDetail, PostId>>(transaction, logger) {
override suspend fun internalExecute(
command: GetUserTimeline,
principal: Principal
): PaginationList<PostDetail, PostId> {
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)
}
}

View File

@ -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<PostId>, principal: Principal): List<PostDetail> {
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<Media>): 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)
}
}

View File

@ -211,17 +211,38 @@ class ExposedPostRepository(
visibilityList: List<Visibility>,
of: Page?
): PaginationList<Post, PostId> {
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
)
}

View File

@ -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)

View File

@ -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"
}

View File

@ -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<PostId>, principal: Principal): List<PostDetail>
}

View File

@ -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}

View File

@ -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}
post.repost-by=Repost by {0}
user-by-id.followersCount={0} Followers
user-by-id.followingCount={0} Following
user-by-id.postsCount={0} Posts

View File

@ -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}

View File

@ -7,8 +7,31 @@
<body>
<noscript>
<div>
<img th:src="user.icon">
<img height="150px" th:src="${user.iconUrl}" width="150px">
<img height="150px" th:src="${user.bannerURL}" width="600px">
</div>
<div>
<th:block th:if="${user.locked}">
<h2 th:text="${user.screenName} + '(private)'"></h2>
</th:block>
<th:block th:if="!${user.locked}">
<h2 th:text="${user.screenName}"></h2>
</th:block>
<p th:text="${user.name} + '@' + ${user.host}"></p>
</div>
<div>
<p th:text="${user.description}"></p>
</div>
<div>
<p th:if="user.postsCount != null" th:text="#{user-by-id.postsCount(${user.postsCount})}">0 Posts</p>
<p th:if="user.followingCount != null" th:text="#{user-by-id.followingCount(${user.followingCount})}">0
Following</p>
<p th:if="user.followersCount != null" th:text="#{user-by-id.followersCount(${user.followersCount})}">0
Followers</p>
</div>
<div th:replace="~{fragments-timeline :: simple-timline(${userTimeline},'/users/'+${user.name}+'@'+${user.host})}"></div>
</noscript>
</body>
</html>

View File

@ -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) {