feat: リアクションしたかを確認できるように

This commit is contained in:
usbharu 2024-09-08 22:26:50 +09:00
parent e42919ce3c
commit d4aaad3fb6
Signed by: usbharu
GPG Key ID: 6556747BF94EEBC8
19 changed files with 224 additions and 42 deletions

View File

@ -9,6 +9,7 @@ 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
@ -23,6 +24,7 @@ class GetPostDetailApplicationService(
private val mediaRepository: MediaRepository,
private val iPostReadAccessControl: IPostReadAccessControl,
private val reactionsQueryService: ReactionsQueryService,
private val reactionRepository: ReactionRepository,
) : AbstractApplicationService<GetPostDetail, PostDetail>(
transaction,
logger
@ -42,6 +44,13 @@ class GetPostDetailApplicationService(
val reactions = reactionsQueryService.findAllByPostId(post.id)
val favourited = reactionRepository.existsByPostIdAndActorIdAndCustomEmojiIdOrUnicodeEmoji(
post.id,
principal.actorId,
null,
""
)
return PostDetail.of(
post = post,
actor = actor,
@ -50,7 +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
reactionsList = reactions,
favourited
)
}
@ -78,7 +88,8 @@ class GetPostDetailApplicationService(
actor = first,
iconMedia = third,
mediaList = mediaList,
reactionsList = emptyList()
reactionsList = emptyList(),
favourited = false
)
}

View File

@ -25,7 +25,8 @@ data class PostDetail(
val deleted: Boolean,
val mediaDetailList: List<MediaDetail>,
val moveTo: PostDetail?,
val reactionsList: List<Reactions>
val reactionsList: List<Reactions>,
val favourited: Boolean
) {
companion object {
@Suppress("LongParameterList")
@ -37,7 +38,8 @@ data class PostDetail(
reply: PostDetail? = null,
repost: PostDetail? = null,
moveTo: PostDetail? = null,
reactionsList: List<Reactions>
reactionsList: List<Reactions>,
favourited: Boolean
): PostDetail {
return PostDetail(
id = post.id.id,
@ -56,7 +58,8 @@ data class PostDetail(
deleted = false,
mediaDetailList = mediaList.map { MediaDetail.of(it) },
moveTo = moveTo,
reactionsList = reactionsList
reactionsList = reactionsList,
favourited = favourited
)
}
}

View File

@ -0,0 +1,3 @@
package dev.usbharu.hideout.core.application.reaction
data class CreateReaction(val postId: Long, val customEmojiId: Long?, val unicodeEmoji: String)

View File

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

View File

@ -49,6 +49,7 @@ class ReadTimelineApplicationService(
it.replyPostActorIconMedia,
it.replyPostMedias.orEmpty(),
reactionsList = emptyList(),
favourited = false,
)
} else {
null
@ -61,7 +62,8 @@ class ReadTimelineApplicationService(
actor = it.repostPostActor!!,
iconMedia = it.repostPostActorIconMedia,
mediaList = it.repostPostMedias.orEmpty(),
reactionsList = emptyList()
reactionsList = emptyList(),
favourited = false
)
} else {
null
@ -74,7 +76,8 @@ class ReadTimelineApplicationService(
mediaList = it.postMedias,
reply = reply,
repost = repost,
reactionsList = emptyList()
reactionsList = emptyList(),
favourited = it.favourited
)
}

View File

@ -1,10 +1,19 @@
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<Reaction>
suspend fun existsByPostIdAndActorIdAndCustomEmojiIdOrUnicodeEmoji(
postId: PostId,
actorId: ActorId,
customEmojiId: CustomEmojiId?,
unicodeEmoji: String
): Boolean
suspend fun delete(reaction: Reaction)
}

View File

@ -31,7 +31,8 @@ data class TimelineObjectDetail(
val lastUpdateAt: Instant,
val hasMediaInRepost: Boolean,
val warnFilter: List<TimelineObjectWarnFilter>,
val reactionsList: List<Reactions>
val reactionsList: List<Reactions>,
val favourited: Boolean
) {
companion object {
@Suppress("LongParameterList")
@ -51,7 +52,8 @@ data class TimelineObjectDetail(
repostPostActor: Actor?,
repostPostActorIconMedia: Media?,
warnFilter: List<TimelineObjectWarnFilter>,
reactionsList: List<Reactions>
reactionsList: List<Reactions>,
favourited: Boolean
): TimelineObjectDetail {
return TimelineObjectDetail(
id = timelineObject.id,
@ -73,7 +75,8 @@ data class TimelineObjectDetail(
lastUpdateAt = timelineObject.lastUpdatedAt,
hasMediaInRepost = timelineObject.hasMediaInRepost,
warnFilter = warnFilter,
reactionsList = reactionsList
reactionsList = reactionsList,
favourited = favourited
)
}
}

View File

@ -33,8 +33,8 @@ class TimelineObject(
hasMediaInRepost: Boolean,
lastUpdatedAt: Instant,
var warnFilters: List<TimelineObjectWarnFilter>,
) {
var favourited: Boolean
) {
var isPureRepost = isPureRepost
private set
var visibleActors = visibleActors
@ -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) }
}
@ -82,7 +82,8 @@ class TimelineObject(
timeline: Timeline,
post: Post,
replyActorId: ActorId?,
filterResults: List<FilterResult>
filterResults: List<FilterResult>,
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<FilterResult>
filterResults: List<FilterResult>,
favourited: Boolean
): TimelineObject {
require(post.repostId == repost.id)
@ -130,15 +133,16 @@ 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(),
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
)
}
}

View File

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

View File

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

View File

@ -46,7 +46,7 @@ class ExposedReactionsQueryService : ReactionsQueryService, AbstractRepository()
ExposedrepositoryReactions.postId,
ExposedrepositoryReactions.postId.count(),
ExposedrepositoryReactions.customEmojiId.max(),
ExposedrepositoryReactions.unicodeEmoji.max(),
ExposedrepositoryReactions.unicodeEmoji.max<String, String>(),
actorIdsQuery
)
.where { ExposedrepositoryReactions.postId inList postIds.map { it.id } }
@ -55,7 +55,8 @@ class ExposedReactionsQueryService : ReactionsQueryService, AbstractRepository()
Reactions(
it[ExposedrepositoryReactions.postId],
it[ExposedrepositoryReactions.postId.count()].toInt(),
it.getOrNull(CustomEmojis.name) ?: it[ExposedrepositoryReactions.unicodeEmoji],
it.getOrNull(CustomEmojis.name)
?: it[ExposedrepositoryReactions.unicodeEmoji.max<String, String>()]!!,
it.getOrNull(CustomEmojis.domain) ?: UnicodeEmoji.domain.domain,
it.getOrNull(CustomEmojis.url)?.let { it1 -> URI.create(it1) },
it[actorIdsQuery].split(",").mapNotNull { it.toLongOrNull() }

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,6 +61,10 @@ 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 +73,7 @@ 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 }
)
}
}
@ -101,7 +105,8 @@ class ExposedUserTimelineQueryService : UserTimelineQueryService, AbstractReposi
deleted = it[authorizedQuery[Posts.deleted]],
mediaDetailList = emptyList(),
moveTo = null,
emptyList()
emptyList(),
favourited = false
)
}

View File

@ -32,6 +32,7 @@ class ExposedReactionRepository(override val domainEventPublisher: DomainEventPu
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)
@ -56,6 +57,20 @@ class ExposedReactionRepository(override val domainEventPublisher: DomainEventPu
}
}
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 {

View File

@ -133,7 +133,8 @@ data class SpringDataMongoTimelineObject(
val visibleActors: List<Long>,
val hasMediaInRepost: Boolean,
val lastUpdatedAt: Long,
val warnFilters: List<SpringDataMongoTimelineObjectWarnFilter>
val warnFilters: List<SpringDataMongoTimelineObjectWarnFilter>,
val favourited: Boolean
) {
fun toTimelineObject(): TimelineObject {
@ -155,7 +156,8 @@ data class SpringDataMongoTimelineObject(
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
)
}
}

View File

@ -76,7 +76,8 @@ abstract class AbstractTimelineStore(private val idGenerateService: IdGenerateSe
post = post,
replyActorId = replyActorId,
repost = repost,
filterResults = applyFilters.filterResults
filterResults = applyFilters.filterResults,
favourited = false
)
}
@ -85,7 +86,8 @@ abstract class AbstractTimelineStore(private val idGenerateService: IdGenerateSe
timeline = timeline,
post = post,
replyActorId = replyActorId,
filterResults = applyFilters.filterResults
filterResults = applyFilters.filterResults,
favourited = false
)
}
@ -293,7 +295,8 @@ abstract class AbstractTimelineStore(private val idGenerateService: IdGenerateSe
filterResult.matchedKeyword
)
},
reactionsList = reactionsList
reactionsList = reactionsList,
favourited = it.favourited
)
},
timelineObjectList.lastOrNull()?.postId,

View File

@ -4,18 +4,22 @@ 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.UserCreateReactionApplicationService
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
) {
@GetMapping("/users/{name}/posts/{id}")
suspend fun postById(@PathVariable id: Long, model: Model): String {
@ -31,4 +35,17 @@ 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"
}
}

View File

@ -44,10 +44,14 @@
<div class="post-controller" th:fragment="single-post-controller(post)">
<!--/*@thymesVar id="post" type="dev.usbharu.hideout.core.application.post.PostDetail"*/-->
<a th:href="${'/publish?reply_to=' + post.id}">Reply</a>
<a th:href="${post.apId}">
<time th:datetime="${post.createdAt}" th:text="${#temporals.format(post.createdAt, 'yyyy-MM-dd HH:mm')}"></time>
</a>
<form method="post" th:action="@{/users/a/posts/{id}/favourite(id=${post.id})}">
<a th:href="${'/publish?reply_to=' + post.id}">Reply</a>
<input type="submit" value="❤">
<a th:href="${post.apId}">
<time th:datetime="${post.createdAt}"
th:text="${#temporals.format(post.createdAt, 'yyyy-MM-dd HH:mm')}"></time>
</a>
</form>
</div>

View File

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

View File

@ -103,7 +103,7 @@ mongodb-kotlin-coroutine = { module = "org.mongodb:mongodb-driver-kotlin-corouti
mockito-kotlin = { module = "org.mockito.kotlin:mockito-kotlin", version = "5.4.0" }
http-signature = { module = "dev.usbharu:http-signature", version = "1.0.0" }
emoji-kt = { module = "dev.usbharu:emoji-kt", version = "2.0.0" }
emoji-kt = { module = "dev.usbharu:emoji-kt", version = "2.0.1" }
logback-ecs-encoder = { module = "co.elastic.logging:logback-ecs-encoder", version = "1.6.0" }