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
) :
LocalUserAbstractApplicationService<CreateReaction, Unit>(
transaction, logger
transaction,
logger
) {
override suspend fun internalExecute(command: CreateReaction, principal: LocalUser) {
val postId = PostId(command.postId)
val post = postRepository.findById(postId) ?: throw IllegalArgumentException("Post $postId not found.")
if (postReadAccessControl.isAllow(post, principal).not()) {
@ -62,4 +62,4 @@ class UserCreateReactionApplicationService(
companion object {
private val logger = LoggerFactory.getLogger(UserCreateReactionApplicationService::class.java)
}
}
}

View File

@ -18,7 +18,8 @@ class UserRemoveReactionApplicationService(
private val unicodeEmojiService: UnicodeEmojiService
) :
LocalUserAbstractApplicationService<RemoveReaction, Unit>(
transaction, logger
transaction,
logger
) {
override suspend fun internalExecute(command: RemoveReaction, principal: LocalUser) {
val postId = PostId(command.postId)
@ -47,4 +48,4 @@ class UserRemoveReactionApplicationService(
companion object {
private val logger = LoggerFactory.getLogger(UserRemoveReactionApplicationService::class.java)
}
}
}

View File

@ -19,4 +19,4 @@ class ReactionEventBody(
enum class ReactionEvent(val eventName: String) {
CREATE("ReactionCreate"),
DELETE("ReactionDelete"),
}
}

View File

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

View File

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

View File

@ -1,4 +1,4 @@
package dev.usbharu.hideout.core.domain.model.reaction
@JvmInline
value class ReactionId(val value: Long)
value class ReactionId(val value: Long)

View File

@ -23,4 +23,4 @@ interface ReactionRepository {
): Reaction?
suspend fun delete(reaction: Reaction)
}
}

View File

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

View File

@ -2,4 +2,4 @@ package dev.usbharu.hideout.core.domain.service.emoji
interface UnicodeEmojiService {
fun isUnicodeEmoji(emoji: String): Boolean
}
}

View File

@ -9,4 +9,4 @@ class EmojiKtUnicodeEmojiService : UnicodeEmojiService {
override fun isUnicodeEmoji(emoji: String): Boolean {
return Emojis.allEmojis.singleOrNull { it.char == emoji } != null
}
}
}

View File

@ -37,7 +37,6 @@ class ExposedReactionsQueryService : ReactionsQueryService, AbstractRepository()
override suspend fun findAllByPostIdIn(postIds: List<PostId>): List<Reactions> {
return query {
val actorIdsQuery =
ExposedrepositoryReactions.actorId.castTo<String>(VarCharColumnType()).groupConcat(",", true)
@ -71,4 +70,4 @@ class ExposedReactionsQueryService : ReactionsQueryService, AbstractRepository()
companion object {
private val logger = LoggerFactory.getLogger(ExposedReactionsQueryService::class.java)
}
}
}

View File

@ -42,10 +42,10 @@ class ExposedUserTimelineQueryService : UserTimelineQueryService, AbstractReposi
.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)
(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")
}
@ -61,10 +61,12 @@ class ExposedUserTimelineQueryService : UserTimelineQueryService, AbstractReposi
.leftJoin(iconMedia, { Actors.icon }, { iconMedia[Media.id] })
.leftJoin(PostsMedia, { authorizedQuery[Posts.id] }, { PostsMedia.postId })
.leftJoin(Media, { PostsMedia.mediaId }, { Media.id })
.leftJoin(Reactions,
.leftJoin(
Reactions,
{ authorizedQuery[Posts.id] },
{ Reactions.postId },
{ Reactions.id isDistinctFrom principal.actorId.id })
{ Reactions.id isDistinctFrom principal.actorId.id }
)
.selectAll()
.where { authorizedQuery[Posts.id] inList idList.map { it.id } }
.groupBy { it[authorizedQuery[Posts.id]] }
@ -73,7 +75,8 @@ class ExposedUserTimelineQueryService : UserTimelineQueryService, AbstractReposi
toPostDetail(it.first(), authorizedQuery, iconMedia).copy(
mediaDetailList = it.mapNotNull { resultRow ->
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
@Repository
class ExposedReactionRepository(override val domainEventPublisher: DomainEventPublisher) : ReactionRepository,
AbstractRepository(), DomainEventPublishableRepository<Reaction> {
class ExposedReactionRepository(override val domainEventPublisher: DomainEventPublisher) :
ReactionRepository,
AbstractRepository(),
DomainEventPublishableRepository<Reaction> {
override val logger: Logger
get() = Companion.logger
override suspend fun save(reaction: Reaction): Reaction {
return query {
Reactions.upsert {
@ -66,7 +67,9 @@ class ExposedReactionRepository(override val domainEventPublisher: DomainEventPu
return query {
Reactions.selectAll().where {
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()
}
}
@ -89,10 +92,11 @@ class ExposedReactionRepository(override val domainEventPublisher: DomainEventPu
unicodeEmoji: String
): Reaction? {
return query {
Reactions.selectAll().where {
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()
}
}
@ -111,7 +115,6 @@ fun ResultRow.toReaction(): Reaction {
UnicodeEmoji(this[Reactions.unicodeEmoji]),
this[Reactions.createdAt]
)
}
object Reactions : Table("reactions") {
@ -122,4 +125,4 @@ object Reactions : Table("reactions") {
val unicodeEmoji = varchar("unicode_emoji", 100)
val createdAt = timestamp("created_at")
override val primaryKey: PrimaryKey = PrimaryKey(id)
}
}

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.query.Criteria
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.stereotype.Repository
import java.time.Instant
@ -84,6 +85,8 @@ class MongoInternalTimelineObjectRepository(
): PaginationList<TimelineObject, PostId> {
val query = Query()
query.addCriteria(Criteria.where("timelineId").isEqualTo(timelineId.value))
if (page?.minId != null) {
query.with(Sort.by(Sort.Direction.ASC, "postCreatedAt"))
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 =
getActors(
timelineObjectList.map { it.postActorId } +
timelineObjectList.mapNotNull { it.repostActorId } +
timelineObjectList.mapNotNull { it.replyActorId }
timelineObjectList.mapNotNull { it.repostActorId } +
timelineObjectList.mapNotNull { it.replyActorId }
)
val postMap = posts.associate { post ->
@ -256,7 +256,7 @@ abstract class AbstractTimelineStore(private val idGenerateService: IdGenerateSe
val mediaMap = getMedias(
posts.flatMap { it.mediaIds } +
actors.mapNotNull { it.value.icon }
actors.mapNotNull { it.value.icon }
)
val reactions = getReactions(posts.map { it.id })

View File

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

View File

@ -6,4 +6,4 @@ import dev.usbharu.hideout.core.domain.model.post.PostId
interface ReactionsQueryService {
suspend fun findAllByPostId(postId: PostId): List<Reactions>
suspend fun findAllByPostIdIn(postIds: List<PostId>): List<Reactions>
}
}

View File

@ -14,8 +14,8 @@
</noscript>
</head>
<body>
<noscript>
<a th:href="${nsUrl}">No Script</a>
</noscript>
<a th:href="${nsUrl}">No Script</a>
</body>
</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
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.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
@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
properties:
filter:
$ref: "#/components/schemas/FilterResult"
$ref: "#/components/schemas/Filter"
keyword_matches:
type: array
items: