Merge pull request #599 from usbharu/mastodon-timeline-api

feat: Mastodonでホームタイムラインを読めるように
This commit is contained in:
usbharu 2024-09-09 16:33:59 +09:00 committed by GitHub
commit 2e0f0c77bf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 242 additions and 50 deletions

View File

@ -29,10 +29,10 @@ class UserCreateReactionApplicationService(
private val unicodeEmojiService: UnicodeEmojiService private val unicodeEmojiService: UnicodeEmojiService
) : ) :
LocalUserAbstractApplicationService<CreateReaction, Unit>( LocalUserAbstractApplicationService<CreateReaction, Unit>(
transaction, logger transaction,
logger
) { ) {
override suspend fun internalExecute(command: CreateReaction, principal: LocalUser) { override suspend fun internalExecute(command: CreateReaction, principal: LocalUser) {
val postId = PostId(command.postId) val postId = PostId(command.postId)
val post = postRepository.findById(postId) ?: throw IllegalArgumentException("Post $postId not found.") val post = postRepository.findById(postId) ?: throw IllegalArgumentException("Post $postId not found.")
if (postReadAccessControl.isAllow(post, principal).not()) { if (postReadAccessControl.isAllow(post, principal).not()) {

View File

@ -18,7 +18,8 @@ class UserRemoveReactionApplicationService(
private val unicodeEmojiService: UnicodeEmojiService private val unicodeEmojiService: UnicodeEmojiService
) : ) :
LocalUserAbstractApplicationService<RemoveReaction, Unit>( LocalUserAbstractApplicationService<RemoveReaction, Unit>(
transaction, logger transaction,
logger
) { ) {
override suspend fun internalExecute(command: RemoveReaction, principal: LocalUser) { override suspend fun internalExecute(command: RemoveReaction, principal: LocalUser) {
val postId = PostId(command.postId) val postId = PostId(command.postId)

View File

@ -29,9 +29,9 @@ sealed class Emoji {
abstract fun id(): String abstract fun id(): String
override fun toString(): String { override fun toString(): String {
return "Emoji(" + return "Emoji(" +
"domain='$domain', " + "domain='$domain', " +
"name='$name'" + "name='$name'" +
")" ")"
} }
} }

View File

@ -18,7 +18,6 @@ class Reaction(
val createdAt: Instant val createdAt: Instant
) : DomainEventStorable() { ) : DomainEventStorable() {
override fun equals(other: Any?): Boolean { override fun equals(other: Any?): Boolean {
if (this === other) return true if (this === other) return true
if (javaClass != other?.javaClass) return false if (javaClass != other?.javaClass) return false
@ -46,7 +45,12 @@ class Reaction(
createdAt: Instant createdAt: Instant
): Reaction { ): Reaction {
return Reaction( return Reaction(
id, postId, actorId, customEmojiId, unicodeEmoji, createdAt id,
postId,
actorId,
customEmojiId,
unicodeEmoji,
createdAt
).apply { addDomainEvent(ReactionEventFactory(this).createEvent(ReactionEvent.CREATE)) } ).apply { addDomainEvent(ReactionEventFactory(this).createEvent(ReactionEvent.CREATE)) }
} }
} }

View File

@ -61,9 +61,9 @@ class TimelineObject(
lastUpdatedAt = Instant.now() lastUpdatedAt = Instant.now()
isPureRepost = isPureRepost =
post.repostId != null && post.repostId != null &&
post.replyId == null && post.replyId == null &&
post.text.isEmpty() && post.text.isEmpty() &&
post.overview?.overview.isNullOrEmpty() post.overview?.overview.isNullOrEmpty()
warnFilters = filterResults.map { TimelineObjectWarnFilter(it.filter.id, it.matchedKeyword) } warnFilters = filterResults.map { TimelineObjectWarnFilter(it.filter.id, it.matchedKeyword) }
} }
@ -133,9 +133,9 @@ class TimelineObject(
repostActorId = repost.actorId, repostActorId = repost.actorId,
visibility = post.visibility, visibility = post.visibility,
isPureRepost = repost.mediaIds.isEmpty() && isPureRepost = repost.mediaIds.isEmpty() &&
repost.overview == null && repost.overview == null &&
repost.content == PostContent.empty && repost.content == PostContent.empty &&
repost.replyId == null, repost.replyId == null,
mediaIds = post.mediaIds, mediaIds = post.mediaIds,
emojiIds = post.emojiIds, emojiIds = post.emojiIds,
visibleActors = post.visibleActors.toList(), visibleActors = post.visibleActors.toList(),

View File

@ -37,7 +37,6 @@ class ExposedReactionsQueryService : ReactionsQueryService, AbstractRepository()
override suspend fun findAllByPostIdIn(postIds: List<PostId>): List<Reactions> { override suspend fun findAllByPostIdIn(postIds: List<PostId>): List<Reactions> {
return query { return query {
val actorIdsQuery = val actorIdsQuery =
ExposedrepositoryReactions.actorId.castTo<String>(VarCharColumnType()).groupConcat(",", true) ExposedrepositoryReactions.actorId.castTo<String>(VarCharColumnType()).groupConcat(",", true)

View File

@ -42,10 +42,10 @@ class ExposedUserTimelineQueryService : UserTimelineQueryService, AbstractReposi
.select(Posts.columns) .select(Posts.columns)
.where { .where {
Posts.visibility eq Visibility.PUBLIC.name or Posts.visibility eq Visibility.PUBLIC.name or
(Posts.visibility eq Visibility.UNLISTED.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.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.visibility eq Visibility.FOLLOWERS.name and (Relationships.blocking eq false and (relationshipsAlias[Relationships.following] eq true))) or
(Posts.actorId eq principal.actorId.id) (Posts.actorId eq principal.actorId.id)
} }
.alias("authorized_table") .alias("authorized_table")
} }
@ -61,10 +61,12 @@ class ExposedUserTimelineQueryService : UserTimelineQueryService, AbstractReposi
.leftJoin(iconMedia, { Actors.icon }, { iconMedia[Media.id] }) .leftJoin(iconMedia, { Actors.icon }, { iconMedia[Media.id] })
.leftJoin(PostsMedia, { authorizedQuery[Posts.id] }, { PostsMedia.postId }) .leftJoin(PostsMedia, { authorizedQuery[Posts.id] }, { PostsMedia.postId })
.leftJoin(Media, { PostsMedia.mediaId }, { Media.id }) .leftJoin(Media, { PostsMedia.mediaId }, { Media.id })
.leftJoin(Reactions, .leftJoin(
Reactions,
{ authorizedQuery[Posts.id] }, { authorizedQuery[Posts.id] },
{ Reactions.postId }, { Reactions.postId },
{ Reactions.id isDistinctFrom principal.actorId.id }) { Reactions.id isDistinctFrom principal.actorId.id }
)
.selectAll() .selectAll()
.where { authorizedQuery[Posts.id] inList idList.map { it.id } } .where { authorizedQuery[Posts.id] inList idList.map { it.id } }
.groupBy { it[authorizedQuery[Posts.id]] } .groupBy { it[authorizedQuery[Posts.id]] }
@ -73,7 +75,8 @@ class ExposedUserTimelineQueryService : UserTimelineQueryService, AbstractReposi
toPostDetail(it.first(), authorizedQuery, iconMedia).copy( toPostDetail(it.first(), authorizedQuery, iconMedia).copy(
mediaDetailList = it.mapNotNull { resultRow -> mediaDetailList = it.mapNotNull { resultRow ->
resultRow.toMediaOrNull()?.let { it1 -> MediaDetail.of(it1) } resultRow.toMediaOrNull()?.let { it1 -> MediaDetail.of(it1) }
}, favourited = it.any { it.getOrNull(Reactions.actorId) != null } },
favourited = it.any { it.getOrNull(Reactions.actorId) != null }
) )
} }
} }

View File

@ -17,13 +17,14 @@ import org.slf4j.LoggerFactory
import org.springframework.stereotype.Repository import org.springframework.stereotype.Repository
@Repository @Repository
class ExposedReactionRepository(override val domainEventPublisher: DomainEventPublisher) : ReactionRepository, class ExposedReactionRepository(override val domainEventPublisher: DomainEventPublisher) :
AbstractRepository(), DomainEventPublishableRepository<Reaction> { ReactionRepository,
AbstractRepository(),
DomainEventPublishableRepository<Reaction> {
override val logger: Logger override val logger: Logger
get() = Companion.logger get() = Companion.logger
override suspend fun save(reaction: Reaction): Reaction { override suspend fun save(reaction: Reaction): Reaction {
return query { return query {
Reactions.upsert { Reactions.upsert {
@ -66,7 +67,9 @@ class ExposedReactionRepository(override val domainEventPublisher: DomainEventPu
return query { return query {
Reactions.selectAll().where { Reactions.selectAll().where {
Reactions.postId.eq(postId.id).and(Reactions.actorId eq actorId.id) Reactions.postId.eq(postId.id).and(Reactions.actorId eq actorId.id)
.and((Reactions.customEmojiId eq customEmojiId?.emojiId or (Reactions.unicodeEmoji eq unicodeEmoji))) .and(
(Reactions.customEmojiId eq customEmojiId?.emojiId or (Reactions.unicodeEmoji eq unicodeEmoji))
)
}.empty().not() }.empty().not()
} }
} }
@ -89,10 +92,11 @@ class ExposedReactionRepository(override val domainEventPublisher: DomainEventPu
unicodeEmoji: String unicodeEmoji: String
): Reaction? { ): Reaction? {
return query { return query {
Reactions.selectAll().where { Reactions.selectAll().where {
Reactions.postId.eq(postId.id).and(Reactions.actorId eq actorId.id) Reactions.postId.eq(postId.id).and(Reactions.actorId eq actorId.id)
.and((Reactions.customEmojiId eq customEmojiId?.emojiId or (Reactions.unicodeEmoji eq unicodeEmoji))) .and(
(Reactions.customEmojiId eq customEmojiId?.emojiId or (Reactions.unicodeEmoji eq unicodeEmoji))
)
}.limit(1).singleOrNull()?.toReaction() }.limit(1).singleOrNull()?.toReaction()
} }
} }
@ -111,7 +115,6 @@ fun ResultRow.toReaction(): Reaction {
UnicodeEmoji(this[Reactions.unicodeEmoji]), UnicodeEmoji(this[Reactions.unicodeEmoji]),
this[Reactions.createdAt] this[Reactions.createdAt]
) )
} }
object Reactions : Table("reactions") { object Reactions : Table("reactions") {

View File

@ -25,6 +25,7 @@ import org.springframework.data.mongodb.core.MongoTemplate
import org.springframework.data.mongodb.core.mapping.Document import org.springframework.data.mongodb.core.mapping.Document
import org.springframework.data.mongodb.core.query.Criteria import org.springframework.data.mongodb.core.query.Criteria
import org.springframework.data.mongodb.core.query.Query import org.springframework.data.mongodb.core.query.Query
import org.springframework.data.mongodb.core.query.isEqualTo
import org.springframework.data.repository.kotlin.CoroutineCrudRepository import org.springframework.data.repository.kotlin.CoroutineCrudRepository
import org.springframework.stereotype.Repository import org.springframework.stereotype.Repository
import java.time.Instant import java.time.Instant
@ -84,6 +85,8 @@ class MongoInternalTimelineObjectRepository(
): PaginationList<TimelineObject, PostId> { ): PaginationList<TimelineObject, PostId> {
val query = Query() val query = Query()
query.addCriteria(Criteria.where("timelineId").isEqualTo(timelineId.value))
if (page?.minId != null) { if (page?.minId != null) {
query.with(Sort.by(Sort.Direction.ASC, "postCreatedAt")) query.with(Sort.by(Sort.Direction.ASC, "postCreatedAt"))
page.minId?.let { query.addCriteria(Criteria.where("postId").gt(it)) } page.minId?.let { query.addCriteria(Criteria.where("postId").gt(it)) }

View File

@ -246,8 +246,8 @@ abstract class AbstractTimelineStore(private val idGenerateService: IdGenerateSe
val actors = val actors =
getActors( getActors(
timelineObjectList.map { it.postActorId } + timelineObjectList.map { it.postActorId } +
timelineObjectList.mapNotNull { it.repostActorId } + timelineObjectList.mapNotNull { it.repostActorId } +
timelineObjectList.mapNotNull { it.replyActorId } timelineObjectList.mapNotNull { it.replyActorId }
) )
val postMap = posts.associate { post -> val postMap = posts.associate { post ->
@ -256,7 +256,7 @@ abstract class AbstractTimelineStore(private val idGenerateService: IdGenerateSe
val mediaMap = getMedias( val mediaMap = getMedias(
posts.flatMap { it.mediaIds } + posts.flatMap { it.mediaIds } +
actors.mapNotNull { it.value.icon } actors.mapNotNull { it.value.icon }
) )
val reactions = getReactions(posts.map { it.id }) val reactions = getReactions(posts.map { it.id })

View File

@ -47,7 +47,8 @@ class PostsController(
id, id,
null, null,
"" ""
), principal ),
principal
) )
return "redirect:/users/$name/posts/$id" return "redirect:/users/$name/posts/$id"
} }
@ -60,7 +61,8 @@ class PostsController(
id, id,
null, null,
"" ""
), principal ),
principal
) )
return "redirect:/users/$name/posts/$id" return "redirect:/users/$name/posts/$id"
} }

View File

@ -14,8 +14,8 @@
</noscript> </noscript>
</head> </head>
<body> <body>
<noscript>
<a th:href="${nsUrl}">No Script</a> <a th:href="${nsUrl}">No Script</a>
</noscript>
</body> </body>
</html> </html>

View File

@ -0,0 +1,13 @@
package dev.usbharu.hideout.mastodon.application.timeline
import dev.usbharu.hideout.core.domain.model.support.page.Page
class MastodonReadTimeline(
val timelineId: Long,
val mediaOnly: Boolean,
val localOnly: Boolean,
val remoteOnly: Boolean,
val page: Page
) {
}

View File

@ -0,0 +1,113 @@
package dev.usbharu.hideout.mastodon.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.Visibility.*
import dev.usbharu.hideout.core.domain.model.support.acct.Acct
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.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 dev.usbharu.hideout.mastodon.interfaces.api.generated.model.Account
import dev.usbharu.hideout.mastodon.interfaces.api.generated.model.MediaAttachment
import dev.usbharu.hideout.mastodon.interfaces.api.generated.model.Status
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
@Service
class MastodonReadTimelineApplicationService(
transaction: Transaction,
private val timelineRepository: TimelineRepository,
private val timelineStore: TimelineStore
) :
AbstractApplicationService<MastodonReadTimeline, PaginationList<Status, Long>>(transaction, logger) {
override suspend fun internalExecute(
command: MastodonReadTimeline,
principal: Principal
): PaginationList<Status, Long> {
val timeline = timelineRepository.findById(TimelineId(command.timelineId))
?: throw IllegalArgumentException("Timeline ${command.timelineId} not found.")
val readTimelineOption = ReadTimelineOption(
command.mediaOnly,
command.localOnly,
command.remoteOnly
)
val readTimeline = timelineStore.readTimeline(timeline, readTimelineOption, command.page, principal)
return PaginationList(readTimeline.map {
Status(
it.postId.id.toString(),
it.post.url.toString(),
it.post.createdAt.toString(),
account = Account(
id = it.postActor.id.id.toString(),
username = it.postActor.name.name,
acct = Acct(it.postActor.name.name, it.postActor.domain.domain).toString(),
url = it.postActor.url.toString(),
displayName = it.postActor.screenName.screenName,
note = it.postActor.description.description,
avatar = it.postActorIconMedia?.url.toString(),
avatarStatic = it.postActorIconMedia?.thumbnailUrl.toString(),
header = "",
headerStatic = "",
locked = false,
fields = emptyList(),
emojis = emptyList(),
bot = false,
group = false,
discoverable = true,
createdAt = it.postActor.createdAt.toString(),
statusesCount = it.postActor.postsCount.postsCount,
noindex = true,
moved = it.postActor.moveTo != null,
suspended = it.postActor.suspend,
limited = false,
lastStatusAt = it.postActor.lastPostAt?.toString(),
followersCount = it.postActor.followersCount?.relationshipCount,
followingCount = it.postActor.followingCount?.relationshipCount,
source = null
),
content = it.post.content.content,
visibility = when (it.post.visibility) {
PUBLIC -> Status.Visibility.public
UNLISTED -> Status.Visibility.unlisted
FOLLOWERS -> Status.Visibility.private
DIRECT -> Status.Visibility.direct
},
sensitive = it.post.sensitive,
spoilerText = it.post.overview?.overview.orEmpty(),
mediaAttachments = it.postMedias.map { MediaAttachment(it.id.id.toString()) },
mentions = emptyList(),
tags = emptyList(),
emojis = emptyList(),
reblogsCount = 0,
favouritesCount = it.reactionsList.sumOf { it.count },
repliesCount = 0,
url = it.post.url.toString(),
text = it.post.content.text,
application = null,
inReplyToId = it.replyPost?.id?.toString(),
inReplyToAccountId = it.replyPostActor?.id?.toString(),
reblog = null,
poll = null,
card = null,
language = null,
editedAt = null,
favourited = it.favourited,
reblogged = false,
muted = false,
bookmarked = false,
pinned = false,
filtered = emptyList(),
)
}, readTimeline.next?.id, readTimeline.prev?.id)
}
companion object {
private val logger = LoggerFactory.getLogger(MastodonReadTimelineApplicationService::class.java)
}
}

View File

@ -16,8 +16,59 @@
package dev.usbharu.hideout.mastodon.interfaces.api package dev.usbharu.hideout.mastodon.interfaces.api
import dev.usbharu.hideout.core.application.exception.InternalServerException
import dev.usbharu.hideout.core.application.shared.Transaction
import dev.usbharu.hideout.core.domain.model.support.page.Page
import dev.usbharu.hideout.core.domain.model.userdetails.UserDetailRepository
import dev.usbharu.hideout.core.infrastructure.springframework.oauth2.SpringSecurityOauth2PrincipalContextHolder
import dev.usbharu.hideout.mastodon.application.timeline.MastodonReadTimeline
import dev.usbharu.hideout.mastodon.application.timeline.MastodonReadTimelineApplicationService
import dev.usbharu.hideout.mastodon.interfaces.api.generated.TimelineApi import dev.usbharu.hideout.mastodon.interfaces.api.generated.TimelineApi
import dev.usbharu.hideout.mastodon.interfaces.api.generated.model.Status
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.runBlocking
import org.springframework.http.ResponseEntity
import org.springframework.stereotype.Controller import org.springframework.stereotype.Controller
@Controller @Controller
class SpringTimelineApi : TimelineApi class SpringTimelineApi(
private val mastodonReadTimelineApplicationService: MastodonReadTimelineApplicationService,
private val principalContextHolder: SpringSecurityOauth2PrincipalContextHolder,
private val userDetailRepository: UserDetailRepository,
private val transaction: Transaction,
) : TimelineApi {
override fun apiV1TimelinesHomeGet(
maxId: String?,
sinceId: String?,
minId: String?,
limit: Int?
): ResponseEntity<Flow<Status>> = runBlocking {
val principal = principalContextHolder.getPrincipal()
val userDetail = transaction.transaction {
userDetailRepository.findByActorId(principal.actorId.id)
?: throw InternalServerException("UserDetail not found.")
}
val homeTimelineId =
userDetail.homeTimelineId ?: throw InternalServerException("HomeTimeline ${userDetail.id} is null.")
ResponseEntity.ok(
mastodonReadTimelineApplicationService.execute(
MastodonReadTimeline(
timelineId = homeTimelineId.value,
mediaOnly = false,
localOnly = false,
remoteOnly = false,
page = Page.of(
maxId?.toLongOrNull(),
sinceId?.toLongOrNull(),
minId?.toLongOrNull(),
limit
)
), principal
).asFlow()
)
}
}

View File

@ -2020,7 +2020,7 @@ components:
type: object type: object
properties: properties:
filter: filter:
$ref: "#/components/schemas/FilterResult" $ref: "#/components/schemas/Filter"
keyword_matches: keyword_matches:
type: array type: array
items: items: