diff --git a/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/application/model/Reactions.kt b/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/application/model/Reactions.kt new file mode 100644 index 00000000..11d95f7e --- /dev/null +++ b/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/application/model/Reactions.kt @@ -0,0 +1,28 @@ +package dev.usbharu.hideout.core.application.model + +import dev.usbharu.hideout.core.domain.model.emoji.CustomEmoji +import dev.usbharu.hideout.core.domain.model.reaction.Reaction +import java.net.URI + +data class Reactions( + val postId: Long, + val count: Int, + val name: String, + val domain: String, + val url: URI?, + val actorIds: List, +) { + companion object { + fun of(reactionList: List, customEmoji: CustomEmoji?): Reactions { + val first = reactionList.first() + return Reactions( + first.id.value, + reactionList.size, + customEmoji?.name ?: first.unicodeEmoji.name, + customEmoji?.domain?.domain ?: first.unicodeEmoji.domain.domain, + customEmoji?.url, + reactionList.map { it.actorId.id } + ) + } + } +} diff --git a/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/application/post/GetPostDetailApplicationService.kt b/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/application/post/GetPostDetailApplicationService.kt index 7eb38ed3..278c0afe 100644 --- a/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/application/post/GetPostDetailApplicationService.kt +++ b/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/application/post/GetPostDetailApplicationService.kt @@ -9,8 +9,10 @@ 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 import dev.usbharu.hideout.core.domain.model.post.PostRepository +import dev.usbharu.hideout.core.domain.model.reaction.ReactionRepository import dev.usbharu.hideout.core.domain.model.support.principal.Principal import dev.usbharu.hideout.core.domain.service.post.IPostReadAccessControl +import dev.usbharu.hideout.core.query.reactions.ReactionsQueryService import org.slf4j.LoggerFactory import org.springframework.stereotype.Service @@ -20,7 +22,9 @@ class GetPostDetailApplicationService( private val postRepository: PostRepository, private val actorRepository: ActorRepository, private val mediaRepository: MediaRepository, - private val iPostReadAccessControl: IPostReadAccessControl + private val iPostReadAccessControl: IPostReadAccessControl, + private val reactionsQueryService: ReactionsQueryService, + private val reactionRepository: ReactionRepository, ) : AbstractApplicationService( transaction, logger @@ -38,6 +42,15 @@ class GetPostDetailApplicationService( val mediaList = mediaRepository.findByIds(post.mediaIds) + val reactions = reactionsQueryService.findAllByPostId(post.id) + + val favourited = reactionRepository.existsByPostIdAndActorIdAndCustomEmojiIdOrUnicodeEmoji( + post.id, + principal.actorId, + null, + "❤" + ) + return PostDetail.of( post = post, actor = actor, @@ -46,6 +59,8 @@ class GetPostDetailApplicationService( reply = post.replyId?.let { fetchChild(it, actor, iconMedia, principal) }, repost = post.repostId?.let { fetchChild(it, actor, iconMedia, principal) }, moveTo = post.moveTo?.let { fetchChild(it, actor, iconMedia, principal) }, + reactionsList = reactions, + favourited ) } @@ -69,10 +84,12 @@ class GetPostDetailApplicationService( val mediaList = mediaRepository.findByIds(post.mediaIds) return PostDetail.of( - post, - first, - third, - mediaList + post = post, + actor = first, + iconMedia = third, + mediaList = mediaList, + reactionsList = emptyList(), + favourited = false ) } diff --git a/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/application/post/PostDetail.kt b/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/application/post/PostDetail.kt index 567848df..67dd9d96 100644 --- a/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/application/post/PostDetail.kt +++ b/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/application/post/PostDetail.kt @@ -1,5 +1,6 @@ package dev.usbharu.hideout.core.application.post +import dev.usbharu.hideout.core.application.model.Reactions 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 @@ -23,7 +24,9 @@ data class PostDetail( val sensitive: Boolean, val deleted: Boolean, val mediaDetailList: List, - val moveTo: PostDetail? + val moveTo: PostDetail?, + val reactionsList: List, + val favourited: Boolean ) { companion object { @Suppress("LongParameterList") @@ -35,6 +38,8 @@ data class PostDetail( reply: PostDetail? = null, repost: PostDetail? = null, moveTo: PostDetail? = null, + reactionsList: List, + favourited: Boolean ): PostDetail { return PostDetail( id = post.id.id, @@ -52,7 +57,9 @@ data class PostDetail( sensitive = post.sensitive, deleted = false, mediaDetailList = mediaList.map { MediaDetail.of(it) }, - moveTo = moveTo + moveTo = moveTo, + reactionsList = reactionsList, + favourited = favourited ) } } diff --git a/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/application/reaction/CreateReaction.kt b/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/application/reaction/CreateReaction.kt new file mode 100644 index 00000000..7d6f171b --- /dev/null +++ b/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/application/reaction/CreateReaction.kt @@ -0,0 +1,3 @@ +package dev.usbharu.hideout.core.application.reaction + +data class CreateReaction(val postId: Long, val customEmojiId: Long?, val unicodeEmoji: String) diff --git a/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/application/reaction/RemoveReaction.kt b/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/application/reaction/RemoveReaction.kt new file mode 100644 index 00000000..6a7dd7ff --- /dev/null +++ b/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/application/reaction/RemoveReaction.kt @@ -0,0 +1,7 @@ +package dev.usbharu.hideout.core.application.reaction + +data class RemoveReaction( + val postId: Long, + val customEmojiId: Long?, + val unicodeEmoji: String +) diff --git a/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/application/reaction/UserCreateReactionApplicationService.kt b/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/application/reaction/UserCreateReactionApplicationService.kt new file mode 100644 index 00000000..f0a7f2dd --- /dev/null +++ b/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/application/reaction/UserCreateReactionApplicationService.kt @@ -0,0 +1,65 @@ +package dev.usbharu.hideout.core.application.reaction + +import dev.usbharu.hideout.core.application.exception.PermissionDeniedException +import dev.usbharu.hideout.core.application.shared.LocalUserAbstractApplicationService +import dev.usbharu.hideout.core.application.shared.Transaction +import dev.usbharu.hideout.core.domain.model.emoji.CustomEmojiRepository +import dev.usbharu.hideout.core.domain.model.emoji.UnicodeEmoji +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.reaction.Reaction +import dev.usbharu.hideout.core.domain.model.reaction.ReactionId +import dev.usbharu.hideout.core.domain.model.reaction.ReactionRepository +import dev.usbharu.hideout.core.domain.model.support.principal.LocalUser +import dev.usbharu.hideout.core.domain.service.emoji.UnicodeEmojiService +import dev.usbharu.hideout.core.domain.service.post.IPostReadAccessControl +import dev.usbharu.hideout.core.domain.shared.id.IdGenerateService +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service +import java.time.Instant + +@Service +class UserCreateReactionApplicationService( + transaction: Transaction, + private val idGenerateService: IdGenerateService, + private val reactionRepository: ReactionRepository, + private val postReadAccessControl: IPostReadAccessControl, + private val postRepository: PostRepository, + private val customEmojiRepository: CustomEmojiRepository, + private val unicodeEmojiService: UnicodeEmojiService +) : + LocalUserAbstractApplicationService( + 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()) { + throw PermissionDeniedException() + } + + val customEmoji = command.customEmojiId?.let { customEmojiRepository.findById(it) } + + val unicodeEmoji = if (unicodeEmojiService.isUnicodeEmoji(command.unicodeEmoji)) { + command.unicodeEmoji + } else { + "❤" + } + + val reaction = Reaction.create( + id = ReactionId(idGenerateService.generateId()), + postId = postId, + actorId = principal.actorId, + customEmojiId = customEmoji?.id, + unicodeEmoji = UnicodeEmoji(unicodeEmoji), + createdAt = Instant.now() + ) + + reactionRepository.save(reaction) + } + + companion object { + private val logger = LoggerFactory.getLogger(UserCreateReactionApplicationService::class.java) + } +} diff --git a/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/application/reaction/UserRemoveReactionApplicationService.kt b/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/application/reaction/UserRemoveReactionApplicationService.kt new file mode 100644 index 00000000..4335ee63 --- /dev/null +++ b/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/application/reaction/UserRemoveReactionApplicationService.kt @@ -0,0 +1,51 @@ +package dev.usbharu.hideout.core.application.reaction + +import dev.usbharu.hideout.core.application.shared.LocalUserAbstractApplicationService +import dev.usbharu.hideout.core.application.shared.Transaction +import dev.usbharu.hideout.core.domain.model.emoji.CustomEmojiRepository +import dev.usbharu.hideout.core.domain.model.post.PostId +import dev.usbharu.hideout.core.domain.model.reaction.ReactionRepository +import dev.usbharu.hideout.core.domain.model.support.principal.LocalUser +import dev.usbharu.hideout.core.domain.service.emoji.UnicodeEmojiService +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service + +@Service +class UserRemoveReactionApplicationService( + transaction: Transaction, + private val customEmojiRepository: CustomEmojiRepository, + private val reactionRepository: ReactionRepository, + private val unicodeEmojiService: UnicodeEmojiService +) : + LocalUserAbstractApplicationService( + transaction, + logger + ) { + override suspend fun internalExecute(command: RemoveReaction, principal: LocalUser) { + val postId = PostId(command.postId) + + val customEmoji = command.customEmojiId?.let { customEmojiRepository.findById(it) } + + val unicodeEmoji = if (unicodeEmojiService.isUnicodeEmoji(command.unicodeEmoji)) { + command.unicodeEmoji + } else { + "❤" + } + val reaction = + reactionRepository.findByPostIdAndActorIdAndCustomEmojiIdOrUnicodeEmoji( + postId, + principal.actorId, + customEmoji?.id, + unicodeEmoji + ) + ?: throw IllegalArgumentException("Reaction $postId ${principal.actorId} ${customEmoji?.id} $unicodeEmoji not found.") + + reaction.delete() + + reactionRepository.delete(reaction) + } + + companion object { + private val logger = LoggerFactory.getLogger(UserRemoveReactionApplicationService::class.java) + } +} diff --git a/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/application/timeline/ReadTimelineApplicationService.kt b/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/application/timeline/ReadTimelineApplicationService.kt index 9facf26c..8b045657 100644 --- a/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/application/timeline/ReadTimelineApplicationService.kt +++ b/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/application/timeline/ReadTimelineApplicationService.kt @@ -47,7 +47,9 @@ class ReadTimelineApplicationService( it.replyPost, it.replyPostActor!!, it.replyPostActorIconMedia, - it.replyPostMedias.orEmpty() + it.replyPostMedias.orEmpty(), + reactionsList = emptyList(), + favourited = false, ) } else { null @@ -56,10 +58,12 @@ class ReadTimelineApplicationService( val repost = if (it.repostPost != null) { @Suppress("UnsafeCallOnNullableType") PostDetail.of( - it.repostPost, - it.repostPostActor!!, - it.repostPostActorIconMedia, - it.repostPostMedias.orEmpty() + post = it.repostPost, + actor = it.repostPostActor!!, + iconMedia = it.repostPostActorIconMedia, + mediaList = it.repostPostMedias.orEmpty(), + reactionsList = emptyList(), + favourited = false ) } else { null @@ -71,7 +75,9 @@ class ReadTimelineApplicationService( iconMedia = it.postActorIconMedia, mediaList = it.postMedias, reply = reply, - repost = repost + repost = repost, + reactionsList = emptyList(), + favourited = it.favourited ) } diff --git a/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/domain/event/reaction/ReactionEvent.kt b/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/domain/event/reaction/ReactionEvent.kt new file mode 100644 index 00000000..6da7a58c --- /dev/null +++ b/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/domain/event/reaction/ReactionEvent.kt @@ -0,0 +1,22 @@ +package dev.usbharu.hideout.core.domain.event.reaction + +import dev.usbharu.hideout.core.domain.model.reaction.Reaction +import dev.usbharu.hideout.core.domain.model.reaction.ReactionId +import dev.usbharu.hideout.core.domain.shared.domainevent.DomainEvent +import dev.usbharu.hideout.core.domain.shared.domainevent.DomainEventBody + +class ReactionEventFactory(private val reaction: Reaction) { + fun createEvent(reactionEvent: ReactionEvent): DomainEvent = + DomainEvent.create(reactionEvent.eventName, ReactionEventBody(reaction)) +} + +class ReactionEventBody( + reaction: Reaction +) : DomainEventBody(mapOf("reactionId" to reaction.id)) { + fun getReactionId(): ReactionId = toMap()["reactionId"] as ReactionId +} + +enum class ReactionEvent(val eventName: String) { + CREATE("ReactionCreate"), + DELETE("ReactionDelete"), +} diff --git a/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/domain/model/actor/Actor.kt b/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/domain/model/actor/Actor.kt index 3b282c34..8fd41710 100644 --- a/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/domain/model/actor/Actor.kt +++ b/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/domain/model/actor/Actor.kt @@ -18,7 +18,7 @@ package dev.usbharu.hideout.core.domain.model.actor import dev.usbharu.hideout.core.domain.event.actor.ActorDomainEventFactory import dev.usbharu.hideout.core.domain.event.actor.ActorEvent.* -import dev.usbharu.hideout.core.domain.model.emoji.EmojiId +import dev.usbharu.hideout.core.domain.model.emoji.CustomEmojiId import dev.usbharu.hideout.core.domain.model.instance.InstanceId import dev.usbharu.hideout.core.domain.model.media.MediaId import dev.usbharu.hideout.core.domain.model.support.domain.Domain @@ -52,7 +52,7 @@ class Actor( var lastUpdateAt: Instant = createdAt, alsoKnownAs: Set = emptySet(), moveTo: ActorId? = null, - emojiIds: Set, + emojiIds: Set, deleted: Boolean, icon: MediaId?, banner: MediaId?, diff --git a/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/domain/model/emoji/CustomEmoji.kt b/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/domain/model/emoji/CustomEmoji.kt index e0e29b17..b80d06cf 100644 --- a/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/domain/model/emoji/CustomEmoji.kt +++ b/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/domain/model/emoji/CustomEmoji.kt @@ -36,7 +36,7 @@ sealed class Emoji { } data class CustomEmoji( - val id: EmojiId, + val id: CustomEmojiId, override val name: String, override val domain: Domain, val instanceId: InstanceId, @@ -50,6 +50,10 @@ data class CustomEmoji( data class UnicodeEmoji( override val name: String ) : Emoji() { - override val domain: Domain = Domain("unicode.org") + override val domain: Domain = Companion.domain override fun id(): String = name + + companion object { + val domain = Domain("unicode.org") + } } diff --git a/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/domain/model/emoji/EmojiId.kt b/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/domain/model/emoji/CustomEmojiId.kt similarity index 93% rename from hideout-core/src/main/kotlin/dev/usbharu/hideout/core/domain/model/emoji/EmojiId.kt rename to hideout-core/src/main/kotlin/dev/usbharu/hideout/core/domain/model/emoji/CustomEmojiId.kt index 5cf4284d..6ad2ec03 100644 --- a/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/domain/model/emoji/EmojiId.kt +++ b/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/domain/model/emoji/CustomEmojiId.kt @@ -17,7 +17,7 @@ package dev.usbharu.hideout.core.domain.model.emoji @JvmInline -value class EmojiId(val emojiId: Long) { +value class CustomEmojiId(val emojiId: Long) { init { require(0 <= emojiId) } diff --git a/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/domain/model/post/Post.kt b/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/domain/model/post/Post.kt index b953bd8e..db5f2732 100644 --- a/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/domain/model/post/Post.kt +++ b/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/domain/model/post/Post.kt @@ -20,7 +20,7 @@ import dev.usbharu.hideout.core.domain.event.post.PostDomainEventFactory import dev.usbharu.hideout.core.domain.event.post.PostEvent 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.emoji.EmojiId +import dev.usbharu.hideout.core.domain.model.emoji.CustomEmojiId import dev.usbharu.hideout.core.domain.model.instance.InstanceId import dev.usbharu.hideout.core.domain.model.media.MediaId import dev.usbharu.hideout.core.domain.shared.domainevent.DomainEventStorable @@ -138,7 +138,7 @@ class Post( return content.text } - val emojiIds: List + val emojiIds: List get() { if (hide) { return PostContent.empty.emojiIds @@ -217,7 +217,7 @@ class Post( override fun hashCode(): Int = id.hashCode() - fun reconstructWith(mediaIds: List, emojis: List, visibleActors: Set): Post { + fun reconstructWith(mediaIds: List, emojis: List, visibleActors: Set): Post { return Post( id = id, actorId = actorId, diff --git a/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/domain/model/post/PostContent.kt b/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/domain/model/post/PostContent.kt index 2d787e75..9f6b2db7 100644 --- a/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/domain/model/post/PostContent.kt +++ b/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/domain/model/post/PostContent.kt @@ -16,9 +16,9 @@ package dev.usbharu.hideout.core.domain.model.post -import dev.usbharu.hideout.core.domain.model.emoji.EmojiId +import dev.usbharu.hideout.core.domain.model.emoji.CustomEmojiId -data class PostContent(val text: String, val content: String, val emojiIds: List) { +data class PostContent(val text: String, val content: String, val emojiIds: List) { companion object { val empty = PostContent("", "", emptyList()) diff --git a/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/domain/model/reaction/Reaction.kt b/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/domain/model/reaction/Reaction.kt new file mode 100644 index 00000000..c85c061d --- /dev/null +++ b/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/domain/model/reaction/Reaction.kt @@ -0,0 +1,57 @@ +package dev.usbharu.hideout.core.domain.model.reaction + +import dev.usbharu.hideout.core.domain.event.reaction.ReactionEvent +import dev.usbharu.hideout.core.domain.event.reaction.ReactionEventFactory +import dev.usbharu.hideout.core.domain.model.actor.ActorId +import dev.usbharu.hideout.core.domain.model.emoji.CustomEmojiId +import dev.usbharu.hideout.core.domain.model.emoji.UnicodeEmoji +import dev.usbharu.hideout.core.domain.model.post.PostId +import dev.usbharu.hideout.core.domain.shared.domainevent.DomainEventStorable +import java.time.Instant + +class Reaction( + val id: ReactionId, + val postId: PostId, + val actorId: ActorId, + val customEmojiId: CustomEmojiId?, + val unicodeEmoji: UnicodeEmoji, + val createdAt: Instant +) : DomainEventStorable() { + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as Reaction + + return id == other.id + } + + override fun hashCode(): Int { + return id.hashCode() + } + + fun delete() { + addDomainEvent(ReactionEventFactory(this).createEvent(ReactionEvent.DELETE)) + } + + companion object { + fun create( + id: ReactionId, + postId: PostId, + actorId: ActorId, + customEmojiId: CustomEmojiId?, + unicodeEmoji: UnicodeEmoji, + createdAt: Instant + ): Reaction { + return Reaction( + id, + postId, + actorId, + customEmojiId, + unicodeEmoji, + createdAt + ).apply { addDomainEvent(ReactionEventFactory(this).createEvent(ReactionEvent.CREATE)) } + } + } +} diff --git a/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/domain/model/reaction/ReactionId.kt b/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/domain/model/reaction/ReactionId.kt new file mode 100644 index 00000000..948fb1ea --- /dev/null +++ b/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/domain/model/reaction/ReactionId.kt @@ -0,0 +1,4 @@ +package dev.usbharu.hideout.core.domain.model.reaction + +@JvmInline +value class ReactionId(val value: Long) diff --git a/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/domain/model/reaction/ReactionRepository.kt b/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/domain/model/reaction/ReactionRepository.kt new file mode 100644 index 00000000..01e893a7 --- /dev/null +++ b/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/domain/model/reaction/ReactionRepository.kt @@ -0,0 +1,26 @@ +package dev.usbharu.hideout.core.domain.model.reaction + +import dev.usbharu.hideout.core.domain.model.actor.ActorId +import dev.usbharu.hideout.core.domain.model.emoji.CustomEmojiId +import dev.usbharu.hideout.core.domain.model.post.PostId + +interface ReactionRepository { + suspend fun save(reaction: Reaction): Reaction + suspend fun findById(reactionId: ReactionId): Reaction? + suspend fun findByPostId(postId: PostId): List + suspend fun existsByPostIdAndActorIdAndCustomEmojiIdOrUnicodeEmoji( + postId: PostId, + actorId: ActorId, + customEmojiId: CustomEmojiId?, + unicodeEmoji: String + ): Boolean + + suspend fun findByPostIdAndActorIdAndCustomEmojiIdOrUnicodeEmoji( + postId: PostId, + actorId: ActorId, + customEmojiId: CustomEmojiId?, + unicodeEmoji: String + ): Reaction? + + suspend fun delete(reaction: Reaction) +} diff --git a/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/domain/model/support/timelineobjectdetail/TimelineObjectDetail.kt b/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/domain/model/support/timelineobjectdetail/TimelineObjectDetail.kt index 830612dc..43b0fa9f 100644 --- a/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/domain/model/support/timelineobjectdetail/TimelineObjectDetail.kt +++ b/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/domain/model/support/timelineobjectdetail/TimelineObjectDetail.kt @@ -1,5 +1,6 @@ package dev.usbharu.hideout.core.domain.model.support.timelineobjectdetail +import dev.usbharu.hideout.core.application.model.Reactions 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 @@ -29,7 +30,9 @@ data class TimelineObjectDetail( val isPureRepost: Boolean, val lastUpdateAt: Instant, val hasMediaInRepost: Boolean, - val warnFilter: List + val warnFilter: List, + val reactionsList: List, + val favourited: Boolean ) { companion object { @Suppress("LongParameterList") @@ -48,7 +51,9 @@ data class TimelineObjectDetail( repostPostMedias: List?, repostPostActor: Actor?, repostPostActorIconMedia: Media?, - warnFilter: List + warnFilter: List, + reactionsList: List, + favourited: Boolean ): TimelineObjectDetail { return TimelineObjectDetail( id = timelineObject.id, @@ -69,7 +74,9 @@ data class TimelineObjectDetail( isPureRepost = timelineObject.isPureRepost, lastUpdateAt = timelineObject.lastUpdatedAt, hasMediaInRepost = timelineObject.hasMediaInRepost, - warnFilter = warnFilter + warnFilter = warnFilter, + reactionsList = reactionsList, + favourited = favourited ) } } diff --git a/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/domain/model/timelineobject/TimelineObject.kt b/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/domain/model/timelineobject/TimelineObject.kt index bdce1a0d..f7f57f22 100644 --- a/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/domain/model/timelineobject/TimelineObject.kt +++ b/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/domain/model/timelineobject/TimelineObject.kt @@ -1,7 +1,7 @@ package dev.usbharu.hideout.core.domain.model.timelineobject import dev.usbharu.hideout.core.domain.model.actor.ActorId -import dev.usbharu.hideout.core.domain.model.emoji.EmojiId +import dev.usbharu.hideout.core.domain.model.emoji.CustomEmojiId import dev.usbharu.hideout.core.domain.model.filter.FilterResult import dev.usbharu.hideout.core.domain.model.media.MediaId import dev.usbharu.hideout.core.domain.model.post.Post @@ -28,12 +28,12 @@ class TimelineObject( visibility: Visibility, isPureRepost: Boolean, mediaIds: List, - emojiIds: List, + emojiIds: List, visibleActors: List, hasMediaInRepost: Boolean, lastUpdatedAt: Instant, var warnFilters: List, - + var favourited: Boolean ) { var isPureRepost = isPureRepost private set @@ -82,7 +82,8 @@ class TimelineObject( timeline: Timeline, post: Post, replyActorId: ActorId?, - filterResults: List + filterResults: List, + favourited: Boolean ): TimelineObject { return TimelineObject( id = timelineObjectId, @@ -102,7 +103,8 @@ class TimelineObject( visibleActors = post.visibleActors.toList(), hasMediaInRepost = false, lastUpdatedAt = Instant.now(), - warnFilters = filterResults.map { TimelineObjectWarnFilter(it.filter.id, it.matchedKeyword) } + warnFilters = filterResults.map { TimelineObjectWarnFilter(it.filter.id, it.matchedKeyword) }, + favourited = favourited ) } @@ -113,7 +115,8 @@ class TimelineObject( post: Post, replyActorId: ActorId?, repost: Post, - filterResults: List + filterResults: List, + favourited: Boolean ): TimelineObject { require(post.repostId == repost.id) @@ -138,7 +141,8 @@ class TimelineObject( visibleActors = post.visibleActors.toList(), hasMediaInRepost = repost.mediaIds.isNotEmpty(), lastUpdatedAt = Instant.now(), - warnFilters = filterResults.map { TimelineObjectWarnFilter(it.filter.id, it.matchedKeyword) } + warnFilters = filterResults.map { TimelineObjectWarnFilter(it.filter.id, it.matchedKeyword) }, + favourited = favourited ) } } diff --git a/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/domain/service/emoji/UnicodeEmojiService.kt b/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/domain/service/emoji/UnicodeEmojiService.kt new file mode 100644 index 00000000..35a44ca5 --- /dev/null +++ b/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/domain/service/emoji/UnicodeEmojiService.kt @@ -0,0 +1,5 @@ +package dev.usbharu.hideout.core.domain.service.emoji + +interface UnicodeEmojiService { + fun isUnicodeEmoji(emoji: String): Boolean +} diff --git a/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/emojikt/EmojiKtUnicodeEmojiService.kt b/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/emojikt/EmojiKtUnicodeEmojiService.kt new file mode 100644 index 00000000..3e0fdc43 --- /dev/null +++ b/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/emojikt/EmojiKtUnicodeEmojiService.kt @@ -0,0 +1,12 @@ +package dev.usbharu.hideout.core.infrastructure.emojikt + +import Emojis +import dev.usbharu.hideout.core.domain.service.emoji.UnicodeEmojiService +import org.springframework.stereotype.Service + +@Service +class EmojiKtUnicodeEmojiService : UnicodeEmojiService { + override fun isUnicodeEmoji(emoji: String): Boolean { + return Emojis.allEmojis.singleOrNull { it.char == emoji } != null + } +} diff --git a/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/exposed/ActorResultRowMapper.kt b/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/exposed/ActorResultRowMapper.kt index 59cb72fc..c552c541 100644 --- a/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/exposed/ActorResultRowMapper.kt +++ b/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/exposed/ActorResultRowMapper.kt @@ -17,7 +17,7 @@ package dev.usbharu.hideout.core.infrastructure.exposed import dev.usbharu.hideout.core.domain.model.actor.* -import dev.usbharu.hideout.core.domain.model.emoji.EmojiId +import dev.usbharu.hideout.core.domain.model.emoji.CustomEmojiId import dev.usbharu.hideout.core.domain.model.instance.InstanceId import dev.usbharu.hideout.core.domain.model.media.MediaId import dev.usbharu.hideout.core.domain.model.support.domain.Domain @@ -57,7 +57,7 @@ class ActorResultRowMapper : ResultRowMapper { emojiIds = resultRow[Actors.emojis] .split(",") .filter { it.isNotEmpty() } - .map { EmojiId(it.toLong()) } + .map { CustomEmojiId(it.toLong()) } .toSet(), deleted = resultRow[Actors.deleted], icon = resultRow[Actors.icon]?.let { MediaId(it) }, diff --git a/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/exposed/PostQueryMapper.kt b/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/exposed/PostQueryMapper.kt index 653533ed..139bd86c 100644 --- a/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/exposed/PostQueryMapper.kt +++ b/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/exposed/PostQueryMapper.kt @@ -17,7 +17,7 @@ package dev.usbharu.hideout.core.infrastructure.exposed import dev.usbharu.hideout.core.domain.model.actor.ActorId -import dev.usbharu.hideout.core.domain.model.emoji.EmojiId +import dev.usbharu.hideout.core.domain.model.emoji.CustomEmojiId import dev.usbharu.hideout.core.domain.model.media.MediaId import dev.usbharu.hideout.core.domain.model.post.Post import dev.usbharu.hideout.core.infrastructure.exposedrepository.Posts @@ -55,7 +55,7 @@ class PostQueryMapper(private val postResultRowMapper: ResultRowMapper) : .mapNotNull { resultRow: ResultRow -> resultRow .getOrNull(PostsEmojis.emojiId) - ?.let { emojiId -> EmojiId(emojiId) } + ?.let { emojiId -> CustomEmojiId(emojiId) } }, visibleActors = it.mapNotNull { resultRow: ResultRow -> resultRow diff --git a/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/exposedquery/ExposedReactionsQueryService.kt b/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/exposedquery/ExposedReactionsQueryService.kt new file mode 100644 index 00000000..d22184e7 --- /dev/null +++ b/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/exposedquery/ExposedReactionsQueryService.kt @@ -0,0 +1,73 @@ +package dev.usbharu.hideout.core.infrastructure.exposedquery + +import dev.usbharu.hideout.core.application.model.Reactions +import dev.usbharu.hideout.core.domain.model.emoji.UnicodeEmoji +import dev.usbharu.hideout.core.domain.model.post.PostId +import dev.usbharu.hideout.core.infrastructure.exposedrepository.AbstractRepository +import dev.usbharu.hideout.core.infrastructure.exposedrepository.CustomEmojis +import dev.usbharu.hideout.core.infrastructure.exposedrepository.toCustomEmojiOrNull +import dev.usbharu.hideout.core.infrastructure.exposedrepository.toReaction +import dev.usbharu.hideout.core.query.reactions.ReactionsQueryService +import org.jetbrains.exposed.sql.* +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Repository +import java.net.URI +import dev.usbharu.hideout.core.infrastructure.exposedrepository.Reactions as ExposedrepositoryReactions + +@Repository +class ExposedReactionsQueryService : ReactionsQueryService, AbstractRepository() { + override suspend fun findAllByPostId(postId: PostId): List { + return query { + ExposedrepositoryReactions.leftJoin(CustomEmojis).selectAll() + .where { ExposedrepositoryReactions.postId eq postId.id } + .groupBy { + it[ExposedrepositoryReactions.customEmojiId]?.toString() + ?: it[ExposedrepositoryReactions.unicodeEmoji] + } + .map { it.value } + .map { + Reactions.of( + it.map { resultRow -> resultRow.toReaction() }, + it.first().toCustomEmojiOrNull() + ) + } + } + } + + override suspend fun findAllByPostIdIn(postIds: List): List { + return query { + val actorIdsQuery = + ExposedrepositoryReactions.actorId.castTo(VarCharColumnType()).groupConcat(",", true) + + ExposedrepositoryReactions.leftJoin(CustomEmojis) + .select( + ExposedrepositoryReactions.postId, + ExposedrepositoryReactions.postId.count(), + ExposedrepositoryReactions.customEmojiId.max(), + ExposedrepositoryReactions.unicodeEmoji.max(), + actorIdsQuery + ) + .where { ExposedrepositoryReactions.postId inList postIds.map { it.id } } + .groupBy(ExposedrepositoryReactions.postId) + .map { + Reactions( + it[ExposedrepositoryReactions.postId], + it[ExposedrepositoryReactions.postId.count()].toInt(), + it.getOrNull(CustomEmojis.name) + ?: it[ExposedrepositoryReactions.unicodeEmoji.max()]!!, + it.getOrNull(CustomEmojis.domain) ?: UnicodeEmoji.domain.domain, + it.getOrNull(CustomEmojis.url)?.let { it1 -> URI.create(it1) }, + it[actorIdsQuery].split(",").mapNotNull { it.toLongOrNull() } + ) + } + } + } + + override val logger: Logger + get() = Companion.logger + + companion object { + private val logger = LoggerFactory.getLogger(ExposedReactionsQueryService::class.java) + } +} 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 index 82be6f96..db3fb0f2 100644 --- 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 @@ -61,6 +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, + { authorizedQuery[Posts.id] }, + { Reactions.postId }, + { Reactions.id isDistinctFrom principal.actorId.id } + ) .selectAll() .where { authorizedQuery[Posts.id] inList idList.map { it.id } } .groupBy { it[authorizedQuery[Posts.id]] } @@ -69,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 } ) } } @@ -100,7 +107,9 @@ class ExposedUserTimelineQueryService : UserTimelineQueryService, AbstractReposi sensitive = it[authorizedQuery[Posts.sensitive]], deleted = it[authorizedQuery[Posts.deleted]], mediaDetailList = emptyList(), - moveTo = null + moveTo = null, + emptyList(), + favourited = false ) } diff --git a/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/exposedrepository/CustomEmojiRepositoryImpl.kt b/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/exposedrepository/CustomEmojiRepositoryImpl.kt index c095ae55..e3ca9079 100644 --- a/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/exposedrepository/CustomEmojiRepositoryImpl.kt +++ b/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/exposedrepository/CustomEmojiRepositoryImpl.kt @@ -17,8 +17,8 @@ package dev.usbharu.hideout.core.infrastructure.exposedrepository import dev.usbharu.hideout.core.domain.model.emoji.CustomEmoji +import dev.usbharu.hideout.core.domain.model.emoji.CustomEmojiId import dev.usbharu.hideout.core.domain.model.emoji.CustomEmojiRepository -import dev.usbharu.hideout.core.domain.model.emoji.EmojiId import dev.usbharu.hideout.core.domain.model.instance.InstanceId import dev.usbharu.hideout.core.domain.model.support.domain.Domain import org.jetbrains.exposed.sql.* @@ -81,7 +81,7 @@ class CustomEmojiRepositoryImpl : CustomEmojiRepository, } fun ResultRow.toCustomEmoji(): CustomEmoji = CustomEmoji( - id = EmojiId(this[CustomEmojis.id]), + id = CustomEmojiId(this[CustomEmojis.id]), name = this[CustomEmojis.name], domain = Domain(this[CustomEmojis.domain]), instanceId = InstanceId(this[CustomEmojis.instanceId]), @@ -92,7 +92,7 @@ fun ResultRow.toCustomEmoji(): CustomEmoji = CustomEmoji( fun ResultRow.toCustomEmojiOrNull(): CustomEmoji? { return CustomEmoji( - id = EmojiId(this.getOrNull(CustomEmojis.id) ?: return null), + id = CustomEmojiId(this.getOrNull(CustomEmojis.id) ?: return null), name = this.getOrNull(CustomEmojis.name) ?: return null, domain = Domain(this.getOrNull(CustomEmojis.domain) ?: return null), instanceId = InstanceId(this.getOrNull(CustomEmojis.instanceId) ?: return null), 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 9408d01a..37a476b1 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 @@ -168,7 +168,7 @@ class ExposedPostRepository( Posts.id eq id.id } .let(postQueryMapper::map) - .first() + .firstOrNull() } override suspend fun findAllById(ids: List): List { diff --git a/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/exposedrepository/ExposedReactionRepository.kt b/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/exposedrepository/ExposedReactionRepository.kt new file mode 100644 index 00000000..d44bca1f --- /dev/null +++ b/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/exposedrepository/ExposedReactionRepository.kt @@ -0,0 +1,128 @@ +package dev.usbharu.hideout.core.infrastructure.exposedrepository + +import dev.usbharu.hideout.core.domain.model.actor.ActorId +import dev.usbharu.hideout.core.domain.model.emoji.CustomEmojiId +import dev.usbharu.hideout.core.domain.model.emoji.UnicodeEmoji +import dev.usbharu.hideout.core.domain.model.post.PostId +import dev.usbharu.hideout.core.domain.model.reaction.Reaction +import dev.usbharu.hideout.core.domain.model.reaction.ReactionId +import dev.usbharu.hideout.core.domain.model.reaction.ReactionRepository +import dev.usbharu.hideout.core.domain.shared.domainevent.DomainEventPublisher +import dev.usbharu.hideout.core.domain.shared.repository.DomainEventPublishableRepository +import org.jetbrains.exposed.sql.* +import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq +import org.jetbrains.exposed.sql.javatime.timestamp +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Repository + +@Repository +class ExposedReactionRepository(override val domainEventPublisher: DomainEventPublisher) : + ReactionRepository, + AbstractRepository(), + DomainEventPublishableRepository { + + override val logger: Logger + get() = Companion.logger + + override suspend fun save(reaction: Reaction): Reaction { + return query { + Reactions.upsert { + it[Reactions.id] = reaction.id.value + it[Reactions.postId] = reaction.postId.id + it[Reactions.actorId] = reaction.actorId.id + it[Reactions.customEmojiId] = reaction.customEmojiId?.emojiId + it[Reactions.unicodeEmoji] = reaction.unicodeEmoji.name + it[Reactions.createdAt] = reaction.createdAt + } + onComplete { + update(reaction) + } + reaction + } + } + + override suspend fun findById(reactionId: ReactionId): Reaction? { + return query { + Reactions.selectAll().where { + Reactions.id eq reactionId.value + }.singleOrNull()?.toReaction() + } + } + + override suspend fun findByPostId(postId: PostId): List { + return query { + Reactions.selectAll().where { + Reactions.postId eq postId.id + }.map { it.toReaction() } + } + } + + override suspend fun existsByPostIdAndActorIdAndCustomEmojiIdOrUnicodeEmoji( + postId: PostId, + actorId: ActorId, + customEmojiId: CustomEmojiId?, + unicodeEmoji: String + ): Boolean { + 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)) + ) + }.empty().not() + } + } + + override suspend fun delete(reaction: Reaction) { + return query { + Reactions.deleteWhere { + Reactions.id eq reaction.id.value + } + onComplete { + update(reaction) + } + } + } + + override suspend fun findByPostIdAndActorIdAndCustomEmojiIdOrUnicodeEmoji( + postId: PostId, + actorId: ActorId, + customEmojiId: CustomEmojiId?, + 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)) + ) + }.limit(1).singleOrNull()?.toReaction() + } + } + + companion object { + private val logger = LoggerFactory.getLogger(ExposedReactionRepository::class.java) + } +} + +fun ResultRow.toReaction(): Reaction { + return Reaction( + ReactionId(this[Reactions.id]), + PostId(this[Reactions.postId]), + ActorId(this[Reactions.actorId]), + this[Reactions.customEmojiId]?.let { CustomEmojiId(it) }, + UnicodeEmoji(this[Reactions.unicodeEmoji]), + this[Reactions.createdAt] + ) +} + +object Reactions : Table("reactions") { + val id = long("id") + val postId = long("post_id").references(Posts.id) + val actorId = long("actor_id").references(Actors.id) + val customEmojiId = long("custom_emoji_id").references(CustomEmojis.id).nullable() + val unicodeEmoji = varchar("unicode_emoji", 100) + val createdAt = timestamp("created_at") + override val primaryKey: PrimaryKey = PrimaryKey(id) +} diff --git a/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/mongorepository/MongoInternalTimelineObjectRepository.kt b/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/mongorepository/MongoInternalTimelineObjectRepository.kt index 1e476d0c..2e66bbbd 100644 --- a/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/mongorepository/MongoInternalTimelineObjectRepository.kt +++ b/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/mongorepository/MongoInternalTimelineObjectRepository.kt @@ -1,7 +1,7 @@ package dev.usbharu.hideout.core.infrastructure.mongorepository import dev.usbharu.hideout.core.domain.model.actor.ActorId -import dev.usbharu.hideout.core.domain.model.emoji.EmojiId +import dev.usbharu.hideout.core.domain.model.emoji.CustomEmojiId import dev.usbharu.hideout.core.domain.model.filter.FilterId import dev.usbharu.hideout.core.domain.model.media.MediaId import dev.usbharu.hideout.core.domain.model.post.PostId @@ -133,7 +133,8 @@ data class SpringDataMongoTimelineObject( val visibleActors: List, val hasMediaInRepost: Boolean, val lastUpdatedAt: Long, - val warnFilters: List + val warnFilters: List, + val favourited: Boolean ) { fun toTimelineObject(): TimelineObject { @@ -151,11 +152,12 @@ data class SpringDataMongoTimelineObject( visibility = visibility, isPureRepost = isPureRepost, mediaIds = mediaIds.map { MediaId(it) }, - emojiIds = emojiIds.map { EmojiId(it) }, + emojiIds = emojiIds.map { CustomEmojiId(it) }, visibleActors = visibleActors.map { ActorId(it) }, hasMediaInRepost = hasMediaInRepost, lastUpdatedAt = Instant.ofEpochSecond(lastUpdatedAt), - warnFilters = warnFilters.map { it.toTimelineObjectWarnFilter() } + warnFilters = warnFilters.map { it.toTimelineObjectWarnFilter() }, + favourited = favourited ) } @@ -179,7 +181,8 @@ data class SpringDataMongoTimelineObject( visibleActors = timelineObject.visibleActors.map { it.id }, hasMediaInRepost = timelineObject.hasMediaInRepost, lastUpdatedAt = timelineObject.lastUpdatedAt.epochSecond, - warnFilters = timelineObject.warnFilters.map { SpringDataMongoTimelineObjectWarnFilter.of(it) } + warnFilters = timelineObject.warnFilters.map { SpringDataMongoTimelineObjectWarnFilter.of(it) }, + favourited = timelineObject.favourited ) } } diff --git a/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/springframework/SPAInterceptor.kt b/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/springframework/SPAInterceptor.kt index b3a6eeb0..b3d9a394 100644 --- a/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/springframework/SPAInterceptor.kt +++ b/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/springframework/SPAInterceptor.kt @@ -29,7 +29,7 @@ class SPAInterceptor : HandlerInterceptor { return } - if (request.session.getAttribute("s") == "f") { + if (request.getSession(false)?.getAttribute("s") == "f") { return } diff --git a/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/timeline/AbstractTimelineStore.kt b/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/timeline/AbstractTimelineStore.kt index 50801dc2..b22d0121 100644 --- a/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/timeline/AbstractTimelineStore.kt +++ b/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/timeline/AbstractTimelineStore.kt @@ -1,5 +1,6 @@ package dev.usbharu.hideout.core.infrastructure.timeline +import dev.usbharu.hideout.core.application.model.Reactions 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 @@ -75,7 +76,8 @@ abstract class AbstractTimelineStore(private val idGenerateService: IdGenerateSe post = post, replyActorId = replyActorId, repost = repost, - filterResults = applyFilters.filterResults + filterResults = applyFilters.filterResults, + favourited = false ) } @@ -84,7 +86,8 @@ abstract class AbstractTimelineStore(private val idGenerateService: IdGenerateSe timeline = timeline, post = post, replyActorId = replyActorId, - filterResults = applyFilters.filterResults + filterResults = applyFilters.filterResults, + favourited = false ) } @@ -256,6 +259,8 @@ abstract class AbstractTimelineStore(private val idGenerateService: IdGenerateSe actors.mapNotNull { it.value.icon } ) + val reactions = getReactions(posts.map { it.id }) + return PaginationList( timelineObjectList.mapNotNull { val timelineUserDetail = userDetails[it.userDetailId] ?: return@mapNotNull null @@ -268,6 +273,7 @@ abstract class AbstractTimelineStore(private val idGenerateService: IdGenerateSe val repost = postMap[it.repostId] val repostMedias = repost?.post?.mediaIds?.mapNotNull { mediaId -> mediaMap[mediaId] } val repostActor = actors[it.repostActorId] + val reactionsList = reactions[it.postId].orEmpty() TimelineObjectDetail.of( timelineObject = it, timelineUserDetail = timelineUserDetail, @@ -288,7 +294,9 @@ abstract class AbstractTimelineStore(private val idGenerateService: IdGenerateSe filterResult.filter.id, filterResult.matchedKeyword ) - } + }, + reactionsList = reactionsList, + favourited = it.favourited ) }, timelineObjectList.lastOrNull()?.postId, @@ -300,5 +308,7 @@ abstract class AbstractTimelineStore(private val idGenerateService: IdGenerateSe protected abstract suspend fun getMedias(mediaIds: List): Map + protected abstract suspend fun getReactions(postIds: List): Map> + protected abstract suspend fun getUserDetails(userDetailIdList: List): Map } diff --git a/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/timeline/DefaultTimelineStore.kt b/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/timeline/DefaultTimelineStore.kt index 36faa561..7426d1b6 100644 --- a/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/timeline/DefaultTimelineStore.kt +++ b/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/timeline/DefaultTimelineStore.kt @@ -1,5 +1,6 @@ package dev.usbharu.hideout.core.infrastructure.timeline +import dev.usbharu.hideout.core.application.model.Reactions import dev.usbharu.hideout.core.config.DefaultTimelineStoreConfig import dev.usbharu.hideout.core.domain.model.actor.Actor import dev.usbharu.hideout.core.domain.model.actor.ActorId @@ -32,6 +33,7 @@ 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 dev.usbharu.hideout.core.query.reactions.ReactionsQueryService import org.springframework.stereotype.Component import java.time.Instant @@ -49,7 +51,8 @@ open class DefaultTimelineStore( private val userDetailRepository: UserDetailRepository, private val actorRepository: ActorRepository, private val mediaRepository: MediaRepository, - private val postIPostReadAccessControl: IPostReadAccessControl + private val postIPostReadAccessControl: IPostReadAccessControl, + private val reactionsQueryService: ReactionsQueryService, ) : AbstractTimelineStore(idGenerateService) { override suspend fun getTimelines(actorId: ActorId): List { return timelineRepository.findByIds( @@ -157,6 +160,10 @@ open class DefaultTimelineStore( override suspend fun getMedias(mediaIds: List): Map = mediaRepository.findByIds(mediaIds).associateBy { it.id } + override suspend fun getReactions(postIds: List): Map> { + return reactionsQueryService.findAllByPostIdIn(postIds).groupBy { PostId(it.postId) } + } + override suspend fun getUserDetails(userDetailIdList: List): Map = userDetailRepository.findAllById(userDetailIdList).associateBy { it.id } } diff --git a/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/interfaces/web/posts/PostsController.kt b/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/interfaces/web/posts/PostsController.kt index 4dda4759..f1005327 100644 --- a/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/interfaces/web/posts/PostsController.kt +++ b/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/interfaces/web/posts/PostsController.kt @@ -4,18 +4,25 @@ import dev.usbharu.hideout.core.application.exception.PermissionDeniedException import dev.usbharu.hideout.core.application.instance.GetLocalInstanceApplicationService import dev.usbharu.hideout.core.application.post.GetPostDetail import dev.usbharu.hideout.core.application.post.GetPostDetailApplicationService +import dev.usbharu.hideout.core.application.reaction.CreateReaction +import dev.usbharu.hideout.core.application.reaction.RemoveReaction +import dev.usbharu.hideout.core.application.reaction.UserCreateReactionApplicationService +import dev.usbharu.hideout.core.application.reaction.UserRemoveReactionApplicationService import dev.usbharu.hideout.core.infrastructure.springframework.SpringSecurityFormLoginPrincipalContextHolder import org.springframework.security.access.AccessDeniedException 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.PostMapping @Controller class PostsController( private val getPostDetailApplicationService: GetPostDetailApplicationService, private val springSecurityFormLoginPrincipalContextHolder: SpringSecurityFormLoginPrincipalContextHolder, - private val getLocalInstanceApplicationService: GetLocalInstanceApplicationService + private val getLocalInstanceApplicationService: GetLocalInstanceApplicationService, + private val userCreateReactionApplicationService: UserCreateReactionApplicationService, + private val userRemoveReactionApplicationService: UserRemoveReactionApplicationService ) { @GetMapping("/users/{name}/posts/{id}") suspend fun postById(@PathVariable id: Long, model: Model): String { @@ -31,4 +38,32 @@ class PostsController( return "postById" } + + @PostMapping("/users/{name}/posts/{id}/favourite") + suspend fun favourite(@PathVariable id: Long, @PathVariable name: String): String { + val principal = springSecurityFormLoginPrincipalContextHolder.getPrincipal() + userCreateReactionApplicationService.execute( + CreateReaction( + id, + null, + "❤" + ), + principal + ) + return "redirect:/users/$name/posts/$id" + } + + @PostMapping("/users/{name}/posts/{id}/unfavourite") + suspend fun unfavourite(@PathVariable id: Long, @PathVariable name: String): String { + val principal = springSecurityFormLoginPrincipalContextHolder.getPrincipal() + userRemoveReactionApplicationService.execute( + RemoveReaction( + id, + null, + "❤" + ), + principal + ) + return "redirect:/users/$name/posts/$id" + } } diff --git a/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/query/reactions/ReactionsQueryService.kt b/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/query/reactions/ReactionsQueryService.kt new file mode 100644 index 00000000..e410c4ae --- /dev/null +++ b/hideout-core/src/main/kotlin/dev/usbharu/hideout/core/query/reactions/ReactionsQueryService.kt @@ -0,0 +1,9 @@ +package dev.usbharu.hideout.core.query.reactions + +import dev.usbharu.hideout.core.application.model.Reactions +import dev.usbharu.hideout.core.domain.model.post.PostId + +interface ReactionsQueryService { + suspend fun findAllByPostId(postId: PostId): List + suspend fun findAllByPostIdIn(postIds: List): List +} diff --git a/hideout-core/src/main/resources/db/migration/V1__Init_DB.sql b/hideout-core/src/main/resources/db/migration/V1__Init_DB.sql index 6ce43568..f4c62702 100644 --- a/hideout-core/src/main/resources/db/migration/V1__Init_DB.sql +++ b/hideout-core/src/main/resources/db/migration/V1__Init_DB.sql @@ -318,4 +318,17 @@ create table if not exists filter_keywords keyword varchar(1000) not null, mode varchar(100) not null, constraint fk_filter_keywords_filter_id__id foreign key (filter_id) references filters (id) on delete cascade on update cascade -) \ No newline at end of file +); + +create table if not exists reactions +( + id bigint primary key, + post_id bigint not null, + actor_id bigint not null, + custom_emoji_id bigint null, + unicode_emoji varchar(100) not null, + created_at timestamp not null, + constraint fk_reactions_post_id__id foreign key (post_id) references posts (id) on delete cascade on update cascade, + constraint fk_reactions_actor_id__id foreign key (actor_id) references actors (id) on delete cascade on update cascade, + unique (post_id, actor_id, created_at, unicode_emoji) +); \ No newline at end of file diff --git a/hideout-core/src/main/resources/templates/fragments-post.html b/hideout-core/src/main/resources/templates/fragments-post.html index e55f55be..d16f4861 100644 --- a/hideout-core/src/main/resources/templates/fragments-post.html +++ b/hideout-core/src/main/resources/templates/fragments-post.html @@ -44,10 +44,27 @@
- Reply - - - + +
+ Reply + + + + +
+
+ +
+ Reply + + + + +
+
+
diff --git a/hideout-core/src/test/kotlin/dev/usbharu/hideout/core/domain/model/actor/TestActorFactory.kt b/hideout-core/src/test/kotlin/dev/usbharu/hideout/core/domain/model/actor/TestActorFactory.kt index 3286ed74..4f81a194 100644 --- a/hideout-core/src/test/kotlin/dev/usbharu/hideout/core/domain/model/actor/TestActorFactory.kt +++ b/hideout-core/src/test/kotlin/dev/usbharu/hideout/core/domain/model/actor/TestActorFactory.kt @@ -1,6 +1,6 @@ package dev.usbharu.hideout.core.domain.model.actor -import dev.usbharu.hideout.core.domain.model.emoji.EmojiId +import dev.usbharu.hideout.core.domain.model.emoji.CustomEmojiId import dev.usbharu.hideout.core.domain.model.instance.InstanceId import dev.usbharu.hideout.core.domain.model.support.domain.Domain import dev.usbharu.hideout.core.infrastructure.other.TwitterSnowflakeIdGenerateService @@ -35,7 +35,7 @@ object TestActorFactory { suspend: Boolean = false, alsoKnownAs: Set = emptySet(), moveTo: Long? = null, - emojiIds: Set = emptySet(), + emojiIds: Set = emptySet(), deleted: Boolean = false, roles: Set = emptySet(), ): Actor { diff --git a/hideout-core/src/test/kotlin/dev/usbharu/hideout/core/domain/model/emoji/EmojiIdTest.kt b/hideout-core/src/test/kotlin/dev/usbharu/hideout/core/domain/model/emoji/CustomEmojiIdTest.kt similarity index 82% rename from hideout-core/src/test/kotlin/dev/usbharu/hideout/core/domain/model/emoji/EmojiIdTest.kt rename to hideout-core/src/test/kotlin/dev/usbharu/hideout/core/domain/model/emoji/CustomEmojiIdTest.kt index c304294b..e9383cdb 100644 --- a/hideout-core/src/test/kotlin/dev/usbharu/hideout/core/domain/model/emoji/EmojiIdTest.kt +++ b/hideout-core/src/test/kotlin/dev/usbharu/hideout/core/domain/model/emoji/CustomEmojiIdTest.kt @@ -3,18 +3,18 @@ package dev.usbharu.hideout.core.domain.model.emoji import org.junit.jupiter.api.Assertions.assertDoesNotThrow import org.junit.jupiter.api.Test -class EmojiIdTest { +class CustomEmojiIdTest { @Test fun emojiIdは0以上である必要がある() { org.junit.jupiter.api.assertThrows { - EmojiId(-1) + CustomEmojiId(-1) } } @Test fun emojiIdは0以上なら設定できる() { assertDoesNotThrow { - EmojiId(1) + CustomEmojiId(1) } } } \ No newline at end of file diff --git a/hideout-core/src/test/kotlin/dev/usbharu/hideout/core/domain/model/post/PostTest.kt b/hideout-core/src/test/kotlin/dev/usbharu/hideout/core/domain/model/post/PostTest.kt index ae8cc674..a07a0b0d 100644 --- a/hideout-core/src/test/kotlin/dev/usbharu/hideout/core/domain/model/post/PostTest.kt +++ b/hideout-core/src/test/kotlin/dev/usbharu/hideout/core/domain/model/post/PostTest.kt @@ -4,7 +4,7 @@ import dev.usbharu.hideout.core.domain.event.post.PostEvent import dev.usbharu.hideout.core.domain.model.actor.ActorId import dev.usbharu.hideout.core.domain.model.actor.ActorPublicKey import dev.usbharu.hideout.core.domain.model.actor.TestActorFactory -import dev.usbharu.hideout.core.domain.model.emoji.EmojiId +import dev.usbharu.hideout.core.domain.model.emoji.CustomEmojiId import dev.usbharu.hideout.core.domain.model.media.MediaId import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test @@ -447,7 +447,7 @@ class PostTest { @Test fun `emojiIds hideがtrueの時empty`() { val actor = TestActorFactory.create() - val emojiIds = listOf(EmojiId(1), EmojiId(2)) + val emojiIds = listOf(CustomEmojiId(1), CustomEmojiId(2)) val post = Post.create( id = PostId(1), actorId = actor.id, @@ -473,7 +473,7 @@ class PostTest { @Test fun `emojiIds hideがfalseの時中身が返される`() { val actor = TestActorFactory.create() - val emojiIds = listOf(EmojiId(1), EmojiId(2)) + val emojiIds = listOf(CustomEmojiId(1), CustomEmojiId(2)) val post = Post.create( id = PostId(1), actorId = actor.id, @@ -500,7 +500,7 @@ class PostTest { val post = TestPostFactory.create() val mediaIds = listOf(MediaId(1)) val visibleActors = setOf((ActorId(2))) - val emojis = listOf(EmojiId(3)) + val emojis = listOf(CustomEmojiId(3)) val reconstructWith = post.reconstructWith(mediaIds, emojis, visibleActors) assertEquals(mediaIds, reconstructWith.mediaIds) @@ -511,7 +511,7 @@ class PostTest { @Test fun `mediaIds hideがtrueの時emptyが返される`() { val actor = TestActorFactory.create() - val emojiIds = listOf(EmojiId(1), EmojiId(2)) + val emojiIds = listOf(CustomEmojiId(1), CustomEmojiId(2)) val mediaIds = listOf(MediaId(1)) val post = Post.create( id = PostId(1), @@ -538,7 +538,7 @@ class PostTest { @Test fun `mediaIds hideがfalseの時中身が返される`() { val actor = TestActorFactory.create() - val emojiIds = listOf(EmojiId(1), EmojiId(2)) + val emojiIds = listOf(CustomEmojiId(1), CustomEmojiId(2)) val mediaIds = listOf(MediaId(2)) val post = Post.create( id = PostId(1), @@ -603,7 +603,7 @@ class PostTest { fun `restore 指定された引数で再構成されCHECKUPDATEイベントが発生する`() { val post = TestPostFactory.create(deleted = true) - val postContent = PostContent("aiueo", "aiueo", listOf(EmojiId(1))) + val postContent = PostContent("aiueo", "aiueo", listOf(CustomEmojiId(1))) val overview = PostOverview("overview") val mediaIds = listOf(MediaId(1)) post.restore( @@ -622,7 +622,7 @@ class PostTest { fun deletedがfalseの時失敗する() { val post = TestPostFactory.create(deleted = false) - val postContent = PostContent("aiueo", "aiueo", listOf(EmojiId(1))) + val postContent = PostContent("aiueo", "aiueo", listOf(CustomEmojiId(1))) val overview = PostOverview("overview") val mediaIds = listOf(MediaId(1)) assertThrows { diff --git a/hideout-core/src/test/kotlin/dev/usbharu/hideout/core/infrastructure/emojikt/EmojiKtUnicodeEmojiServiceTest.kt b/hideout-core/src/test/kotlin/dev/usbharu/hideout/core/infrastructure/emojikt/EmojiKtUnicodeEmojiServiceTest.kt new file mode 100644 index 00000000..f589be2c --- /dev/null +++ b/hideout-core/src/test/kotlin/dev/usbharu/hideout/core/infrastructure/emojikt/EmojiKtUnicodeEmojiServiceTest.kt @@ -0,0 +1,16 @@ +package dev.usbharu.hideout.core.infrastructure.emojikt + +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource + +class EmojiKtUnicodeEmojiServiceTest { + + @ParameterizedTest + @ValueSource(strings = ["❤", "👱", "☠️", "⁉️"]) + fun 絵文字の判定ができる(s: String) { + assertTrue(EmojiKtUnicodeEmojiService().isUnicodeEmoji(s)) + } + + +} \ No newline at end of file diff --git a/hideout-mastodon/src/main/kotlin/dev/usbharu/hideout/mastodon/interfaces/api/SpringStatusApi.kt b/hideout-mastodon/src/main/kotlin/dev/usbharu/hideout/mastodon/interfaces/api/SpringStatusApi.kt index 5e045a59..10e54af0 100644 --- a/hideout-mastodon/src/main/kotlin/dev/usbharu/hideout/mastodon/interfaces/api/SpringStatusApi.kt +++ b/hideout-mastodon/src/main/kotlin/dev/usbharu/hideout/mastodon/interfaces/api/SpringStatusApi.kt @@ -18,6 +18,10 @@ package dev.usbharu.hideout.mastodon.interfaces.api import dev.usbharu.hideout.core.application.post.RegisterLocalPost import dev.usbharu.hideout.core.application.post.RegisterLocalPostApplicationService +import dev.usbharu.hideout.core.application.reaction.CreateReaction +import dev.usbharu.hideout.core.application.reaction.RemoveReaction +import dev.usbharu.hideout.core.application.reaction.UserCreateReactionApplicationService +import dev.usbharu.hideout.core.application.reaction.UserRemoveReactionApplicationService import dev.usbharu.hideout.core.domain.model.post.Visibility import dev.usbharu.hideout.core.infrastructure.springframework.oauth2.SpringSecurityOauth2PrincipalContextHolder import dev.usbharu.hideout.mastodon.application.status.GetStatus @@ -32,7 +36,9 @@ import org.springframework.stereotype.Controller class SpringStatusApi( private val registerLocalPostApplicationService: RegisterLocalPostApplicationService, private val getStatusApplicationService: GetStatusApplicationService, - private val principalContextHolder: SpringSecurityOauth2PrincipalContextHolder + private val principalContextHolder: SpringSecurityOauth2PrincipalContextHolder, + private val userCreateReactionApplicationService: UserCreateReactionApplicationService, + private val userRemoveReactionApplicationService: UserRemoveReactionApplicationService ) : StatusApi { override suspend fun apiV1StatusesIdEmojiReactionsEmojiDelete(id: String, emoji: String): ResponseEntity = super.apiV1StatusesIdEmojiReactionsEmojiDelete(id, emoji) @@ -49,6 +55,20 @@ class SpringStatusApi( ) } + override suspend fun apiV1StatusesIdFavouritePost(id: String): ResponseEntity { + val principal = principalContextHolder.getPrincipal() + + userCreateReactionApplicationService.execute(CreateReaction(postId = id.toLong(), null, "❤"), principal) + return ResponseEntity.ok(getStatusApplicationService.execute(GetStatus(id), principal)) + } + + override suspend fun apiV1StatusesIdUnfavouritePost(id: String): ResponseEntity { + val principal = principalContextHolder.getPrincipal() + + userRemoveReactionApplicationService.execute(RemoveReaction(postId = id.toLong(), null, "❤"), principal) + return ResponseEntity.ok(getStatusApplicationService.execute(GetStatus(id), principal)) + } + override suspend fun apiV1StatusesPost(statusesRequest: StatusesRequest): ResponseEntity { val principal = principalContextHolder.getPrincipal() val execute = registerLocalPostApplicationService.execute( diff --git a/hideout-mastodon/src/main/resources/openapi/mastodon.yaml b/hideout-mastodon/src/main/resources/openapi/mastodon.yaml index bc5195d5..76d8f5aa 100644 --- a/hideout-mastodon/src/main/resources/openapi/mastodon.yaml +++ b/hideout-mastodon/src/main/resources/openapi/mastodon.yaml @@ -177,6 +177,48 @@ paths: schema: $ref: "#/components/schemas/Status" + /api/v1/statuses/{id}/favourite: + post: + tags: + - status + security: + - OAuth2: + - "write:favourites" + parameters: + - in: path + name: id + required: true + schema: + type: string + responses: + 200: + description: 成功 + content: + application/json: + schema: + $ref: "#/components/schemas/Status" + + /api/v1/statuses/{id}/unfavourite: + post: + tags: + - status + security: + - OAuth2: + - "write:favourites" + parameters: + - in: path + name: id + required: true + schema: + type: string + responses: + 200: + description: 成功 + content: + application/json: + schema: + $ref: "#/components/schemas/Status" + /api/v1/statuses/{id}/emoji_reactions/{emoji}: put: tags: