feat: タイムラインを読めるように

This commit is contained in:
usbharu 2024-08-15 00:02:26 +09:00
parent 60d71e1eb2
commit 88a61ba97f
Signed by: usbharu
GPG Key ID: 6556747BF94EEBC8
13 changed files with 224 additions and 48 deletions

View File

@ -1,14 +1,12 @@
package dev.usbharu.hideout.core.application.post
import dev.usbharu.hideout.core.domain.model.actor.Actor
import dev.usbharu.hideout.core.domain.model.instance.Instance
import dev.usbharu.hideout.core.domain.model.media.Media
import java.net.URI
data class ActorDetail(
val actorId: Long,
val instanceId: Long,
val instanceName: String,
val name: String,
val domain: String,
val screenName: String,
@ -17,11 +15,10 @@ data class ActorDetail(
val icon: URI?,
) {
companion object {
fun of(actor: Actor, instance: Instance, iconMedia: Media?): ActorDetail {
fun of(actor: Actor, iconMedia: Media?): ActorDetail {
return ActorDetail(
actor.id.id,
actor.instance.instanceId,
instance.name.name,
actor.name.name,
actor.domain.domain,
actor.screenName.screenName,

View File

@ -5,8 +5,6 @@ import dev.usbharu.hideout.core.application.shared.AbstractApplicationService
import dev.usbharu.hideout.core.application.shared.Transaction
import dev.usbharu.hideout.core.domain.model.actor.Actor
import dev.usbharu.hideout.core.domain.model.actor.ActorRepository
import dev.usbharu.hideout.core.domain.model.instance.Instance
import dev.usbharu.hideout.core.domain.model.instance.InstanceRepository
import dev.usbharu.hideout.core.domain.model.media.Media
import dev.usbharu.hideout.core.domain.model.media.MediaRepository
import dev.usbharu.hideout.core.domain.model.post.PostId
@ -21,7 +19,6 @@ class GetPostDetailApplicationService(
transaction: Transaction,
private val postRepository: PostRepository,
private val actorRepository: ActorRepository,
private val instanceRepository: InstanceRepository,
private val mediaRepository: MediaRepository,
private val iPostReadAccessControl: IPostReadAccessControl
) : AbstractApplicationService<GetPostDetail, PostDetail>(
@ -36,8 +33,6 @@ class GetPostDetailApplicationService(
}
val actor =
actorRepository.findById(post.actorId) ?: throw InternalServerException("Actor ${post.actorId} not found.")
val instance = instanceRepository.findById(post.instanceId)
?: throw InternalServerException("Instance ${post.instanceId} not found.")
val iconMedia = actor.icon?.let { mediaRepository.findById(it) }
@ -46,19 +41,17 @@ class GetPostDetailApplicationService(
return PostDetail.of(
post,
actor,
instance,
iconMedia,
mediaList,
post.replyId?.let { fetchChild(it, actor, instance, iconMedia, principal) },
post.repostId?.let { fetchChild(it, actor, instance, iconMedia, principal) },
post.moveTo?.let { fetchChild(it, actor, instance, iconMedia, principal) },
post.replyId?.let { fetchChild(it, actor, iconMedia, principal) },
post.repostId?.let { fetchChild(it, actor, iconMedia, principal) },
post.moveTo?.let { fetchChild(it, actor, iconMedia, principal) },
)
}
private suspend fun fetchChild(
postId: PostId,
actor: Actor,
instance: Instance,
iconMedia: Media?,
principal: Principal
): PostDetail? {
@ -68,21 +61,16 @@ class GetPostDetailApplicationService(
return null
}
val (first, second: Instance, third) = if (actor.id != post.actorId) {
Triple(
actorRepository.findById(post.actorId) ?: return null,
instanceRepository.findById(actor.instance) ?: return null,
actor.icon?.let { mediaRepository.findById(it) }
)
val (first, third) = if (actor.id != post.actorId) {
(actorRepository.findById(post.actorId) ?: return null) to actor.icon?.let { mediaRepository.findById(it) }
} else {
Triple(actor, instance, iconMedia)
actor to iconMedia
}
val mediaList = mediaRepository.findByIds(post.mediaIds)
return PostDetail.of(
post,
first,
second,
third,
mediaList
)

View File

@ -1,7 +1,6 @@
package dev.usbharu.hideout.core.application.post
import dev.usbharu.hideout.core.domain.model.actor.Actor
import dev.usbharu.hideout.core.domain.model.instance.Instance
import dev.usbharu.hideout.core.domain.model.media.Media
import dev.usbharu.hideout.core.domain.model.post.Post
import dev.usbharu.hideout.core.domain.model.post.Visibility
@ -30,7 +29,6 @@ data class PostDetail(
fun of(
post: Post,
actor: Actor,
instance: Instance,
iconMedia: Media?,
mediaList: List<Media>,
reply: PostDetail? = null,
@ -39,7 +37,7 @@ data class PostDetail(
): PostDetail {
return PostDetail(
id = post.id.id,
actor = ActorDetail.of(actor, instance, iconMedia),
actor = ActorDetail.of(actor, iconMedia),
overview = post.overview?.overview,
text = post.text,
content = post.content.content,

View File

@ -0,0 +1,11 @@
package dev.usbharu.hideout.core.application.timeline
import dev.usbharu.hideout.core.domain.model.support.page.Page
data class ReadTimeline(
val timelineId: Long,
val mediaOnly: Boolean,
val localOnly: Boolean,
val remoteOnly: Boolean,
val page: Page
)

View File

@ -0,0 +1,47 @@
package dev.usbharu.hideout.core.application.timeline
import dev.usbharu.hideout.core.application.shared.AbstractApplicationService
import dev.usbharu.hideout.core.application.shared.Transaction
import dev.usbharu.hideout.core.domain.model.post.PostId
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.domain.model.support.timelineobjectdetail.TimelineObjectDetail
import dev.usbharu.hideout.core.domain.model.timeline.TimelineId
import dev.usbharu.hideout.core.domain.model.timeline.TimelineRepository
import dev.usbharu.hideout.core.external.timeline.ReadTimelineOption
import dev.usbharu.hideout.core.external.timeline.TimelineStore
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
@Service
class ReadTimelineApplicationService(
private val timelineStore: TimelineStore,
private val timelineRepository: TimelineRepository,
transaction: Transaction
) :
AbstractApplicationService<ReadTimeline, PaginationList<TimelineObjectDetail, PostId>>(transaction, logger) {
override suspend fun internalExecute(
command: ReadTimeline,
principal: Principal
): PaginationList<TimelineObjectDetail, PostId> {
val findById = timelineRepository.findById(TimelineId(command.timelineId))
?: throw IllegalArgumentException("Timeline ${command.timelineId} not found.")
val readTimelineOption = ReadTimelineOption(
command.mediaOnly,
command.localOnly,
command.remoteOnly
)
return timelineStore.readTimeline(
findById,
readTimelineOption,
command.page,
principal,
)
}
companion object {
private val logger = LoggerFactory.getLogger(ReadTimelineApplicationService::class.java)
}
}

View File

@ -22,6 +22,18 @@ interface RelationshipRepository {
suspend fun save(relationship: Relationship): Relationship
suspend fun delete(relationship: Relationship)
suspend fun findByActorIdAndTargetId(actorId: ActorId, targetId: ActorId): Relationship?
suspend fun findByActorIdsAndTargetIdAndBlocking(
actorIds: List<ActorId>,
targetId: ActorId,
blocking: Boolean
): List<Relationship>
suspend fun findByActorIdAndTargetIdsAndFollowing(
actorId: ActorId,
targetIds: List<ActorId>,
following: Boolean
): List<Relationship>
suspend fun findByTargetId(
targetId: ActorId,
option: FindRelationshipOption? = null,

View File

@ -1,6 +1,7 @@
package dev.usbharu.hideout.core.domain.model.support.timelineobjectdetail
import dev.usbharu.hideout.core.domain.model.actor.Actor
import dev.usbharu.hideout.core.domain.model.media.Media
import dev.usbharu.hideout.core.domain.model.post.Post
import dev.usbharu.hideout.core.domain.model.post.PostId
import dev.usbharu.hideout.core.domain.model.timelineobject.TimelineObject
@ -14,11 +15,17 @@ data class TimelineObjectDetail(
val postId: PostId,
val timelineUserDetail: UserDetail,
val post: Post,
val postMedias: List<Media>,
val postActor: Actor,
val postActorIconMedia: Media?,
val replyPost: Post?,
val replyPostMedias: List<Media>?,
val replyPostActor: Actor?,
val replyPostActorIconMedia: Media?,
val repostPost: Post?,
val repostPostMedias: List<Media>?,
val repostPostActor: Actor?,
val repostPostActorIconMedia: Media?,
val isPureRepost: Boolean,
val lastUpdateAt: Instant,
val hasMediaInRepost: Boolean,
@ -29,27 +36,39 @@ data class TimelineObjectDetail(
timelineObject: TimelineObject,
timelineUserDetail: UserDetail,
post: Post,
postMedias: List<Media>,
postActor: Actor,
postActorIconMedia: Media?,
replyPost: Post?,
replyPostMedias: List<Media>?,
replyPostActor: Actor?,
replyPostActorIconMedia: Media?,
repostPost: Post?,
repostPostMedias: List<Media>?,
repostPostActor: Actor?,
repostPostActorIconMedia: Media?,
warnFilter: List<TimelineObjectWarnFilter>
): TimelineObjectDetail {
return TimelineObjectDetail(
timelineObject.id,
post.id,
timelineUserDetail,
post,
postActor,
replyPost,
replyPostActor,
repostPost,
repostPostActor,
timelineObject.isPureRepost,
timelineObject.lastUpdatedAt,
timelineObject.hasMediaInRepost,
warnFilter
id = timelineObject.id,
postId = post.id,
timelineUserDetail = timelineUserDetail,
post = post,
postMedias = postMedias,
postActor = postActor,
postActorIconMedia = postActorIconMedia,
replyPost = replyPost,
replyPostMedias = replyPostMedias,
replyPostActor = replyPostActor,
replyPostActorIconMedia = replyPostActorIconMedia,
repostPost = repostPost,
repostPostMedias = repostPostMedias,
repostPostActor = repostPostActor,
repostPostActorIconMedia = repostPostActorIconMedia,
isPureRepost = timelineObject.isPureRepost,
lastUpdateAt = timelineObject.lastUpdatedAt,
hasMediaInRepost = timelineObject.hasMediaInRepost,
warnFilter = warnFilter
)
}
}

View File

@ -10,6 +10,7 @@ import org.springframework.stereotype.Component
interface IPostReadAccessControl {
suspend fun isAllow(post: Post, principal: Principal): Boolean
suspend fun areAllows(postList: List<Post>, principal: Principal): List<Post>
}
@Component
@ -22,9 +23,9 @@ class DefaultPostReadAccessControl(private val relationshipRepository: Relations
}
val relationship = (
relationshipRepository.findByActorIdAndTargetId(post.actorId, principal.actorId)
?: Relationship.default(post.actorId, principal.actorId)
)
relationshipRepository.findByActorIdAndTargetId(post.actorId, principal.actorId)
?: Relationship.default(post.actorId, principal.actorId)
)
// ブロックされてたら見れない
if (relationship.blocking) {
@ -57,4 +58,47 @@ class DefaultPostReadAccessControl(private val relationshipRepository: Relations
// その他の場合は見れない
return false
}
override suspend fun areAllows(postList: List<Post>, principal: Principal): List<Post> {
val actorIds = postList.map { it.actorId }
val relationshipList =
relationshipRepository.findByActorIdsAndTargetIdAndBlocking(actorIds, principal.actorId, true)
.map { it.actorId }
val inverseRelationshipList =
relationshipRepository.findByActorIdAndTargetIdsAndFollowing(principal.actorId, actorIds, true)
.map { it.actorId }
fun internalAllow(post: Post): Boolean {
// ポスト主は無条件で見れる
if (post.actorId == principal.actorId) {
return true
}
if (relationshipList.contains(post.actorId)) {
return false
}
if (post.visibility == Visibility.PUBLIC || post.visibility == Visibility.UNLISTED) {
return true
}
if (principal is Anonymous) {
return false
}
if (post.visibility == Visibility.DIRECT && post.visibleActors.contains(principal.actorId)) {
return true
}
if (post.visibility == Visibility.FOLLOWERS && inverseRelationshipList.contains(principal.actorId)) {
return true
}
return false
}
return postList
.filter {
internalAllow(it)
}
}
}

View File

@ -4,6 +4,7 @@ import dev.usbharu.hideout.core.domain.model.post.Post
import dev.usbharu.hideout.core.domain.model.post.PostId
import dev.usbharu.hideout.core.domain.model.support.page.Page
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.domain.model.support.timelineobjectdetail.TimelineObjectDetail
import dev.usbharu.hideout.core.domain.model.timeline.Timeline
import dev.usbharu.hideout.core.domain.model.timelinerelationship.TimelineRelationship
@ -22,6 +23,7 @@ interface TimelineStore {
suspend fun readTimeline(
timeline: Timeline,
option: ReadTimelineOption? = null,
page: Page? = null
page: Page? = null,
principal: Principal
): PaginationList<TimelineObjectDetail, PostId>
}

View File

@ -67,6 +67,26 @@ class ExposedRelationshipRepository(override val domainEventPublisher: DomainEve
}.singleOrNull()?.toRelationships()
}
override suspend fun findByActorIdsAndTargetIdAndBlocking(
actorIds: List<ActorId>,
targetId: ActorId,
blocking: Boolean
): List<Relationship> = query {
Relationships.selectAll().where {
Relationships.actorId inList actorIds.map { it.id } and (Relationships.targetActorId eq targetId.id)
}.map { it.toRelationships() }
}
override suspend fun findByActorIdAndTargetIdsAndFollowing(
actorId: ActorId,
targetIds: List<ActorId>,
following: Boolean
): List<Relationship> = query {
Relationships.selectAll().where {
Relationships.actorId eq actorId.id and (Relationships.targetActorId inList targetIds.map { it.id })
}.map { it.toRelationships() }
}
override suspend fun findByTargetId(
targetId: ActorId,
option: FindRelationshipOption?,

View File

@ -4,11 +4,14 @@ import dev.usbharu.hideout.core.domain.model.actor.Actor
import dev.usbharu.hideout.core.domain.model.actor.ActorId
import dev.usbharu.hideout.core.domain.model.filter.Filter
import dev.usbharu.hideout.core.domain.model.filter.FilteredPost
import dev.usbharu.hideout.core.domain.model.media.Media
import dev.usbharu.hideout.core.domain.model.media.MediaId
import dev.usbharu.hideout.core.domain.model.post.Post
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.page.Page
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.domain.model.support.timelineobjectdetail.TimelineObjectDetail
import dev.usbharu.hideout.core.domain.model.timeline.Timeline
import dev.usbharu.hideout.core.domain.model.timeline.TimelineId
@ -105,7 +108,7 @@ abstract class AbstractTimelineStore(private val idGenerateService: IdGenerateSe
protected abstract suspend fun getPostsByTimelineRelationshipList(timelineRelationshipList: List<TimelineRelationship>): List<Post>
protected abstract suspend fun getPostsByPostId(postIds: List<PostId>): List<Post>
protected abstract suspend fun getPostsByPostId(postIds: List<PostId>, principal: Principal): List<Post>
protected abstract suspend fun getTimelineObject(
timelineId: TimelineId,
@ -205,7 +208,8 @@ abstract class AbstractTimelineStore(private val idGenerateService: IdGenerateSe
override suspend fun readTimeline(
timeline: Timeline,
option: ReadTimelineOption?,
page: Page?
page: Page?,
principal: Principal
): PaginationList<TimelineObjectDetail, PostId> {
val timelineObjectList = getTimelineObject(timeline.id, option, page)
val lastUpdatedAt = timelineObjectList.minBy { it.lastUpdatedAt }.lastUpdatedAt
@ -216,7 +220,8 @@ abstract class AbstractTimelineStore(private val idGenerateService: IdGenerateSe
getPostsByPostId(
timelineObjectList.map {
it.postId
} + timelineObjectList.mapNotNull { it.repostId } + timelineObjectList.mapNotNull { it.replyId }
} + timelineObjectList.mapNotNull { it.repostId } + timelineObjectList.mapNotNull { it.replyId },
principal
)
val userDetails = getUserDetails(timelineObjectList.map { it.userDetailId })
@ -232,24 +237,35 @@ abstract class AbstractTimelineStore(private val idGenerateService: IdGenerateSe
post.id to applyFilters(post, newerFilters)
}
val mediaMap = getMedias(posts.flatMap { it.mediaIds } + actors.mapNotNull { it.value.icon })
return PaginationList(
timelineObjectList.mapNotNull<TimelineObject, TimelineObjectDetail> {
val timelineUserDetail = userDetails[it.userDetailId] ?: return@mapNotNull null
val actor = actors[it.postActorId] ?: return@mapNotNull null
val post = postMap[it.postId] ?: return@mapNotNull null
val postMedias = post.post.mediaIds.mapNotNull { mediaId -> mediaMap[mediaId] }
val reply = postMap[it.replyId]
val replyMedias = reply?.post?.mediaIds?.mapNotNull { mediaId -> mediaMap[mediaId] }
val replyActor = actors[it.replyActorId]
val repost = postMap[it.repostId]
val repostMedias = repost?.post?.mediaIds?.mapNotNull { mediaId -> mediaMap[mediaId] }
val repostActor = actors[it.repostActorId]
TimelineObjectDetail.of(
timelineObject = it,
timelineUserDetail = timelineUserDetail,
post = post.post,
postMedias = postMedias,
postActor = actor,
postActorIconMedia = mediaMap[actor.icon],
replyPost = reply?.post,
replyPostMedias = replyMedias,
replyPostActor = replyActor,
replyPostActorIconMedia = mediaMap[replyActor?.icon],
repostPost = repost?.post,
repostPostMedias = repostMedias,
repostPostActor = repostActor,
repostPostActorIconMedia = mediaMap[repostActor?.icon],
warnFilter = it.warnFilters + post.filterResults.map {
TimelineObjectWarnFilter(
it.filter.id,
@ -265,5 +281,7 @@ abstract class AbstractTimelineStore(private val idGenerateService: IdGenerateSe
protected abstract suspend fun getActors(actorIds: List<ActorId>): Map<ActorId, Actor>
protected abstract suspend fun getMedias(mediaIds: List<MediaId>): Map<MediaId, Media>
protected abstract suspend fun getUserDetails(userDetailIdList: List<UserDetailId>): Map<UserDetailId, UserDetail>
}

View File

@ -8,12 +8,16 @@ import dev.usbharu.hideout.core.domain.model.filter.Filter
import dev.usbharu.hideout.core.domain.model.filter.FilterContext
import dev.usbharu.hideout.core.domain.model.filter.FilterRepository
import dev.usbharu.hideout.core.domain.model.filter.FilteredPost
import dev.usbharu.hideout.core.domain.model.media.Media
import dev.usbharu.hideout.core.domain.model.media.MediaId
import dev.usbharu.hideout.core.domain.model.media.MediaRepository
import dev.usbharu.hideout.core.domain.model.post.Post
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.Page
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.domain.model.timeline.Timeline
import dev.usbharu.hideout.core.domain.model.timeline.TimelineId
import dev.usbharu.hideout.core.domain.model.timeline.TimelineRepository
@ -24,6 +28,7 @@ import dev.usbharu.hideout.core.domain.model.userdetails.UserDetail
import dev.usbharu.hideout.core.domain.model.userdetails.UserDetailId
import dev.usbharu.hideout.core.domain.model.userdetails.UserDetailRepository
import dev.usbharu.hideout.core.domain.service.filter.FilterDomainService
import dev.usbharu.hideout.core.domain.service.post.IPostReadAccessControl
import dev.usbharu.hideout.core.domain.shared.id.IdGenerateService
import dev.usbharu.hideout.core.external.timeline.ReadTimelineOption
import org.springframework.stereotype.Component
@ -40,7 +45,9 @@ open class DefaultTimelineStore(
private val defaultTimelineStoreConfig: DefaultTimelineStoreConfig,
private val internalTimelineObjectRepository: InternalTimelineObjectRepository,
private val userDetailRepository: UserDetailRepository,
private val actorRepository: ActorRepository
private val actorRepository: ActorRepository,
private val mediaRepository: MediaRepository,
private val postIPostReadAccessControl: IPostReadAccessControl
) : AbstractTimelineStore(idGenerateService) {
override suspend fun getTimelines(actorId: ActorId): List<Timeline> {
return timelineRepository.findByIds(
@ -99,8 +106,9 @@ open class DefaultTimelineStore(
return timelineRelationshipList.flatMap { getActorPost(it.actorId, visibilities(it)) }
}
override suspend fun getPostsByPostId(postIds: List<PostId>): List<Post> {
return postRepository.findAllById(postIds)
override suspend fun getPostsByPostId(postIds: List<PostId>, principal: Principal): List<Post> {
val findAllById = postRepository.findAllById(postIds)
return postIPostReadAccessControl.areAllows(findAllById, principal)
}
override suspend fun getTimelineObject(
@ -131,6 +139,10 @@ open class DefaultTimelineStore(
return actorRepository.findAllById(actorIds).associateBy { it.id }
}
override suspend fun getMedias(mediaIds: List<MediaId>): Map<MediaId, Media> {
return mediaRepository.findByIds(mediaIds).associateBy { it.id }
}
override suspend fun getUserDetails(userDetailIdList: List<UserDetailId>): Map<UserDetailId, UserDetail> {
return userDetailRepository.findAllById(userDetailIdList).associateBy { it.id }
}

View File

@ -3,6 +3,7 @@ package dev.usbharu.hideout.core.infrastructure.timeline
import dev.usbharu.hideout.core.config.DefaultTimelineStoreConfig
import dev.usbharu.hideout.core.domain.model.actor.ActorRepository
import dev.usbharu.hideout.core.domain.model.filter.*
import dev.usbharu.hideout.core.domain.model.media.MediaRepository
import dev.usbharu.hideout.core.domain.model.post.PostRepository
import dev.usbharu.hideout.core.domain.model.post.TestPostFactory
import dev.usbharu.hideout.core.domain.model.post.Visibility
@ -16,6 +17,7 @@ import dev.usbharu.hideout.core.domain.model.timelinerelationship.Visible
import dev.usbharu.hideout.core.domain.model.userdetails.UserDetailId
import dev.usbharu.hideout.core.domain.model.userdetails.UserDetailRepository
import dev.usbharu.hideout.core.domain.service.filter.FilterDomainService
import dev.usbharu.hideout.core.domain.service.post.IPostReadAccessControl
import dev.usbharu.hideout.core.infrastructure.other.TwitterSnowflakeIdGenerateService
import kotlinx.coroutines.test.runTest
import org.assertj.core.api.Assertions.assertThat
@ -56,6 +58,12 @@ class DefaultTimelineStoreTest {
@Mock
lateinit var actorRepository: ActorRepository
@Mock
lateinit var mediaRepository: MediaRepository
@Mock
lateinit var iPostReadAccessControl: IPostReadAccessControl
@Spy
val defaultTimelineStoreConfig = DefaultTimelineStoreConfig(500)