diff --git a/build.gradle.kts b/build.gradle.kts index 18d64acb..ce4ceb3d 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -142,6 +142,15 @@ repositories { password = project.findProperty("gpr.key") as String? ?: System.getenv("TOKEN") } } + maven { + name = "GitHubPackages2" + url = uri("https://maven.pkg.github.com/multim-dev/emoji-kt") + credentials { + + username = project.findProperty("gpr.user") as String? ?: System.getenv("USERNAME") + password = project.findProperty("gpr.key") as String? ?: System.getenv("TOKEN") + } + } } kotlin { @@ -205,6 +214,8 @@ dependencies { implementation("org.bytedeco:javacv-platform:1.5.9") implementation("org.flywaydb:flyway-core") + implementation("dev.usbharu:emoji-kt:2.0.0") + implementation("io.ktor:ktor-client-logging-jvm:$ktor_version") testImplementation("org.jetbrains.kotlin:kotlin-test-junit:$kotlin_version") diff --git a/src/e2eTest/kotlin/federation/InboxCommonTest.kt b/src/e2eTest/kotlin/federation/InboxCommonTest.kt index 33d595af..67ad80af 100644 --- a/src/e2eTest/kotlin/federation/InboxCommonTest.kt +++ b/src/e2eTest/kotlin/federation/InboxCommonTest.kt @@ -112,18 +112,17 @@ class InboxCommonTest { @BeforeAll @JvmStatic - fun beforeAll(@Autowired flyway: Flyway) { + fun beforeAll() { server = MockServer.feature("classpath:federation/InboxxCommonMockServerTest.feature").http(0).build() _remotePort = server.port.toString() - - flyway.clean() - flyway.migrate() } @AfterAll @JvmStatic - fun afterAll() { + fun afterAll(@Autowired flyway: Flyway) { server.stop() + flyway.clean() + flyway.migrate() } } } diff --git a/src/intTest/kotlin/mastodon/status/StatusTest.kt b/src/intTest/kotlin/mastodon/status/StatusTest.kt index 54b61c9d..93785f56 100644 --- a/src/intTest/kotlin/mastodon/status/StatusTest.kt +++ b/src/intTest/kotlin/mastodon/status/StatusTest.kt @@ -1,7 +1,15 @@ package mastodon.status import dev.usbharu.hideout.SpringApplication +import dev.usbharu.hideout.core.domain.model.emoji.CustomEmoji +import dev.usbharu.hideout.core.domain.model.emoji.UnicodeEmoji +import dev.usbharu.hideout.core.infrastructure.exposedrepository.CustomEmojis +import dev.usbharu.hideout.core.infrastructure.exposedrepository.Reactions +import dev.usbharu.hideout.core.infrastructure.exposedrepository.toReaction +import org.assertj.core.api.Assertions.assertThat import org.flywaydb.core.Flyway +import org.jetbrains.exposed.sql.and +import org.jetbrains.exposed.sql.select import org.junit.jupiter.api.AfterAll import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @@ -13,19 +21,24 @@ import org.springframework.security.core.authority.SimpleGrantedAuthority import org.springframework.security.test.context.support.WithAnonymousUser import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt import org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers import org.springframework.test.context.jdbc.Sql import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.post +import org.springframework.test.web.servlet.put import org.springframework.test.web.servlet.setup.DefaultMockMvcBuilder import org.springframework.test.web.servlet.setup.MockMvcBuilders import org.springframework.transaction.annotation.Transactional import org.springframework.web.context.WebApplicationContext +import java.time.Instant @SpringBootTest(classes = [SpringApplication::class]) @AutoConfigureMockMvc @Transactional @Sql("/sql/test-user.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_CLASS) +@Sql("/sql/test-post.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_CLASS) +@Sql("/sql/test-custom-emoji.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_CLASS) class StatusTest { @Autowired @@ -124,7 +137,6 @@ class StatusTest { } @Test - @Sql("/sql/test-post.sql") fun in_reply_to_idを指定したら返信として処理される() { mockMvc .post("/api/v1/statuses") { @@ -145,6 +157,64 @@ class StatusTest { .andExpect { jsonPath("\$.in_reply_to_id") { value("1") } } } + @Test + fun ユニコード絵文字をリアクションできる() { + mockMvc + .put("/api/v1/statuses/1/emoji_reactions/😭") { + with(jwt().jwt { it.claim("uid", "1") }.authorities(SimpleGrantedAuthority("SCOPE_write"))) + } + .andDo { print() } + .asyncDispatch() + .andExpect { status { isOk() } } + + val reaction = Reactions.select { Reactions.postId eq 1 and (Reactions.actorId eq 1) }.single().toReaction() + assertThat(reaction.emoji).isEqualTo(UnicodeEmoji("😭")) + assertThat(reaction.postId).isEqualTo(1) + assertThat(reaction.actorId).isEqualTo(1) + } + + @Test + fun 存在しない絵文字はフォールバックされる() { + mockMvc + .put("/api/v1/statuses/1/emoji_reactions/hoge") { + with(jwt().jwt { it.claim("uid", "1") }.authorities(SimpleGrantedAuthority("SCOPE_write"))) + } + .andDo { print() } + .asyncDispatch() + .andExpect { status { isOk() } } + + val reaction = Reactions.select { Reactions.postId eq 1 and (Reactions.actorId eq 1) }.single().toReaction() + assertThat(reaction.emoji).isEqualTo(UnicodeEmoji("❤")) + assertThat(reaction.postId).isEqualTo(1) + assertThat(reaction.actorId).isEqualTo(1) + } + + @Test + fun カスタム絵文字をリアクションできる() { + mockMvc + .put("/api/v1/statuses/1/emoji_reactions/kotlin") { + with(jwt().jwt { it.claim("uid", "1") }.authorities(SimpleGrantedAuthority("SCOPE_write"))) + } + .andDo { print() } + .asyncDispatch() + .andExpect { status { isOk() } } + + val reaction = + Reactions.leftJoin(CustomEmojis).select { Reactions.postId eq 1 and (Reactions.actorId eq 1) }.single() + .toReaction() + assertThat(reaction.emoji).isEqualTo( + CustomEmoji( + 1, + "kotlin", + "example.com", + null, + "https://example.com/emojis/kotlin", + null, + Instant.ofEpochMilli(1704700290036) + ) + ) + } + companion object { @JvmStatic @AfterAll diff --git a/src/intTest/resources/sql/test-custom-emoji.sql b/src/intTest/resources/sql/test-custom-emoji.sql new file mode 100644 index 00000000..83c2747a --- /dev/null +++ b/src/intTest/resources/sql/test-custom-emoji.sql @@ -0,0 +1,3 @@ +insert into emojis(id, name, domain, instance_id, url, category, created_at) +VALUES (1, 'kotlin', 'example.com', null, 'https://example.com/emojis/kotlin', null, + TIMESTAMP '2024-01-08 07:51:30.036Z'); diff --git a/src/main/kotlin/dev/usbharu/hideout/activitypub/domain/model/Note.kt b/src/main/kotlin/dev/usbharu/hideout/activitypub/domain/model/Note.kt index 2b16e1c4..b3383ef5 100644 --- a/src/main/kotlin/dev/usbharu/hideout/activitypub/domain/model/Note.kt +++ b/src/main/kotlin/dev/usbharu/hideout/activitypub/domain/model/Note.kt @@ -1,6 +1,8 @@ package dev.usbharu.hideout.activitypub.domain.model +import com.fasterxml.jackson.databind.annotation.JsonDeserialize import dev.usbharu.hideout.activitypub.domain.model.objects.Object +import dev.usbharu.hideout.activitypub.domain.model.objects.ObjectDeserializer open class Note @Suppress("LongParameterList") @@ -14,7 +16,9 @@ constructor( val cc: List = emptyList(), val sensitive: Boolean = false, val inReplyTo: String? = null, - val attachment: List = emptyList() + val attachment: List = emptyList(), + @JsonDeserialize(contentUsing = ObjectDeserializer::class) + val tag: List = emptyList() ) : Object( type = add(type, "Note") ), @@ -36,6 +40,7 @@ constructor( if (sensitive != other.sensitive) return false if (inReplyTo != other.inReplyTo) return false if (attachment != other.attachment) return false + if (tag != other.tag) return false return true } @@ -51,21 +56,23 @@ constructor( result = 31 * result + sensitive.hashCode() result = 31 * result + (inReplyTo?.hashCode() ?: 0) result = 31 * result + attachment.hashCode() + result = 31 * result + tag.hashCode() return result } override fun toString(): String { return "Note(" + - "id='$id', " + - "attributedTo='$attributedTo', " + - "content='$content', " + - "published='$published', " + - "to=$to, " + - "cc=$cc, " + - "sensitive=$sensitive, " + - "inReplyTo=$inReplyTo, " + - "attachment=$attachment" + - ")" + - " ${super.toString()}" + "id='$id', " + + "attributedTo='$attributedTo', " + + "content='$content', " + + "published='$published', " + + "to=$to, " + + "cc=$cc, " + + "sensitive=$sensitive, " + + "inReplyTo=$inReplyTo, " + + "attachment=$attachment, " + + "tag=$tag" + + ")" + + " ${super.toString()}" } } diff --git a/src/main/kotlin/dev/usbharu/hideout/activitypub/domain/model/objects/ObjectDeserializer.kt b/src/main/kotlin/dev/usbharu/hideout/activitypub/domain/model/objects/ObjectDeserializer.kt index caa6caff..f35b0c4f 100644 --- a/src/main/kotlin/dev/usbharu/hideout/activitypub/domain/model/objects/ObjectDeserializer.kt +++ b/src/main/kotlin/dev/usbharu/hideout/activitypub/domain/model/objects/ObjectDeserializer.kt @@ -9,7 +9,7 @@ import dev.usbharu.hideout.activitypub.service.common.ExtendedActivityVocabulary class ObjectDeserializer : JsonDeserializer() { @Suppress("LongMethod", "CyclomaticComplexMethod") - override fun deserialize(p: JsonParser?, ctxt: DeserializationContext?): Object { + override fun deserialize(p: JsonParser?, ctxt: DeserializationContext?): Object? { requireNotNull(p) val treeNode: JsonNode = requireNotNull(p.codec?.readTree(p)) if (treeNode.isValueNode) { @@ -24,70 +24,71 @@ class ObjectDeserializer : JsonDeserializer() { ExtendedActivityVocabulary.values().firstOrNull { it.name.equals(jsonNode.asText(), true) } } } else if (type.isValueNode) { - ExtendedActivityVocabulary.values().first { it.name.equals(type.asText(), true) } + ExtendedActivityVocabulary.values().firstOrNull { it.name.equals(type.asText(), true) } } else { - TODO() + null } return when (activityType) { ExtendedActivityVocabulary.Follow -> p.codec.treeToValue(treeNode, Follow::class.java) ExtendedActivityVocabulary.Note -> p.codec.treeToValue(treeNode, Note::class.java) ExtendedActivityVocabulary.Object -> p.codec.treeToValue(treeNode, Object::class.java) - ExtendedActivityVocabulary.Link -> TODO() - ExtendedActivityVocabulary.Activity -> TODO() - ExtendedActivityVocabulary.IntransitiveActivity -> TODO() - ExtendedActivityVocabulary.Collection -> TODO() - ExtendedActivityVocabulary.OrderedCollection -> TODO() - ExtendedActivityVocabulary.CollectionPage -> TODO() - ExtendedActivityVocabulary.OrderedCollectionPage -> TODO() + ExtendedActivityVocabulary.Link -> null + ExtendedActivityVocabulary.Activity -> null + ExtendedActivityVocabulary.IntransitiveActivity -> null + ExtendedActivityVocabulary.Collection -> null + ExtendedActivityVocabulary.OrderedCollection -> null + ExtendedActivityVocabulary.CollectionPage -> null + ExtendedActivityVocabulary.OrderedCollectionPage -> null ExtendedActivityVocabulary.Accept -> p.codec.treeToValue(treeNode, Accept::class.java) - ExtendedActivityVocabulary.Add -> TODO() - ExtendedActivityVocabulary.Announce -> TODO() - ExtendedActivityVocabulary.Arrive -> TODO() + ExtendedActivityVocabulary.Add -> null + ExtendedActivityVocabulary.Announce -> null + ExtendedActivityVocabulary.Arrive -> null ExtendedActivityVocabulary.Block -> p.codec.treeToValue(treeNode, Block::class.java) ExtendedActivityVocabulary.Create -> p.codec.treeToValue(treeNode, Create::class.java) ExtendedActivityVocabulary.Delete -> p.codec.treeToValue(treeNode, Delete::class.java) - ExtendedActivityVocabulary.Dislike -> TODO() - ExtendedActivityVocabulary.Flag -> TODO() - ExtendedActivityVocabulary.Ignore -> TODO() - ExtendedActivityVocabulary.Invite -> TODO() - ExtendedActivityVocabulary.Join -> TODO() - ExtendedActivityVocabulary.Leave -> TODO() + ExtendedActivityVocabulary.Dislike -> null + ExtendedActivityVocabulary.Flag -> null + ExtendedActivityVocabulary.Ignore -> null + ExtendedActivityVocabulary.Invite -> null + ExtendedActivityVocabulary.Join -> null + ExtendedActivityVocabulary.Leave -> null ExtendedActivityVocabulary.Like -> p.codec.treeToValue(treeNode, Like::class.java) - ExtendedActivityVocabulary.Listen -> TODO() - ExtendedActivityVocabulary.Move -> TODO() - ExtendedActivityVocabulary.Offer -> TODO() - ExtendedActivityVocabulary.Question -> TODO() + ExtendedActivityVocabulary.Listen -> null + ExtendedActivityVocabulary.Move -> null + ExtendedActivityVocabulary.Offer -> null + ExtendedActivityVocabulary.Question -> null ExtendedActivityVocabulary.Reject -> p.codec.treeToValue(treeNode, Reject::class.java) - ExtendedActivityVocabulary.Read -> TODO() - ExtendedActivityVocabulary.Remove -> TODO() - ExtendedActivityVocabulary.TentativeReject -> TODO() - ExtendedActivityVocabulary.TentativeAccept -> TODO() - ExtendedActivityVocabulary.Travel -> TODO() + ExtendedActivityVocabulary.Read -> null + ExtendedActivityVocabulary.Remove -> null + ExtendedActivityVocabulary.TentativeReject -> null + ExtendedActivityVocabulary.TentativeAccept -> null + ExtendedActivityVocabulary.Travel -> null ExtendedActivityVocabulary.Undo -> p.codec.treeToValue(treeNode, Undo::class.java) - ExtendedActivityVocabulary.Update -> TODO() - ExtendedActivityVocabulary.View -> TODO() - ExtendedActivityVocabulary.Application -> TODO() - ExtendedActivityVocabulary.Group -> TODO() - ExtendedActivityVocabulary.Organization -> TODO() + ExtendedActivityVocabulary.Update -> null + ExtendedActivityVocabulary.View -> null + ExtendedActivityVocabulary.Application -> null + ExtendedActivityVocabulary.Group -> null + ExtendedActivityVocabulary.Organization -> null ExtendedActivityVocabulary.Person -> p.codec.treeToValue(treeNode, Person::class.java) - ExtendedActivityVocabulary.Service -> TODO() - ExtendedActivityVocabulary.Article -> TODO() - ExtendedActivityVocabulary.Audio -> TODO() + ExtendedActivityVocabulary.Service -> null + ExtendedActivityVocabulary.Article -> null + ExtendedActivityVocabulary.Audio -> null ExtendedActivityVocabulary.Document -> p.codec.treeToValue(treeNode, Document::class.java) - ExtendedActivityVocabulary.Event -> TODO() + ExtendedActivityVocabulary.Event -> null ExtendedActivityVocabulary.Image -> p.codec.treeToValue(treeNode, Image::class.java) - ExtendedActivityVocabulary.Page -> TODO() - ExtendedActivityVocabulary.Place -> TODO() - ExtendedActivityVocabulary.Profile -> TODO() - ExtendedActivityVocabulary.Relationship -> TODO() + ExtendedActivityVocabulary.Page -> null + ExtendedActivityVocabulary.Place -> null + ExtendedActivityVocabulary.Profile -> null + ExtendedActivityVocabulary.Relationship -> null ExtendedActivityVocabulary.Tombstone -> p.codec.treeToValue(treeNode, Tombstone::class.java) - ExtendedActivityVocabulary.Video -> TODO() - ExtendedActivityVocabulary.Mention -> TODO() + ExtendedActivityVocabulary.Video -> null + ExtendedActivityVocabulary.Mention -> null ExtendedActivityVocabulary.Emoji -> p.codec.treeToValue(treeNode, Emoji::class.java) + null -> null } } else { - TODO() + return null } } } diff --git a/src/main/kotlin/dev/usbharu/hideout/activitypub/service/activity/like/APLikeProcessor.kt b/src/main/kotlin/dev/usbharu/hideout/activitypub/service/activity/like/APLikeProcessor.kt index 994e2135..47819d9b 100644 --- a/src/main/kotlin/dev/usbharu/hideout/activitypub/service/activity/like/APLikeProcessor.kt +++ b/src/main/kotlin/dev/usbharu/hideout/activitypub/service/activity/like/APLikeProcessor.kt @@ -1,13 +1,16 @@ package dev.usbharu.hideout.activitypub.service.activity.like import dev.usbharu.hideout.activitypub.domain.exception.FailedToGetActivityPubResourceException +import dev.usbharu.hideout.activitypub.domain.model.Emoji import dev.usbharu.hideout.activitypub.domain.model.Like import dev.usbharu.hideout.activitypub.service.common.AbstractActivityPubProcessor import dev.usbharu.hideout.activitypub.service.common.ActivityPubProcessContext import dev.usbharu.hideout.activitypub.service.common.ActivityType +import dev.usbharu.hideout.activitypub.service.objects.emoji.EmojiService import dev.usbharu.hideout.activitypub.service.objects.note.APNoteService import dev.usbharu.hideout.activitypub.service.objects.user.APUserService import dev.usbharu.hideout.application.external.Transaction +import dev.usbharu.hideout.core.domain.model.emoji.UnicodeEmoji import dev.usbharu.hideout.core.service.reaction.ReactionService import org.springframework.stereotype.Service @@ -16,7 +19,8 @@ class APLikeProcessor( transaction: Transaction, private val apUserService: APUserService, private val apNoteService: APNoteService, - private val reactionService: ReactionService + private val reactionService: ReactionService, + private val emojiService: EmojiService ) : AbstractActivityPubProcessor(transaction) { override suspend fun internalProcess(activity: ActivityPubProcessContext) { @@ -29,9 +33,16 @@ class APLikeProcessor( try { val post = apNoteService.fetchNoteWithEntity(target).second + + val emoji = if (content.startsWith(":")) { + val tag = activity.activity.tag + (tag.firstOrNull { it is Emoji } as? Emoji)?.let { emojiService.fetchEmoji(it).second } + } else { + UnicodeEmoji(content) + } + reactionService.receiveReaction( - content, - actor.substringAfter("://").substringBefore("/"), + emoji ?: UnicodeEmoji("❤"), personWithEntity.second.id, post.id ) diff --git a/src/main/kotlin/dev/usbharu/hideout/activitypub/service/common/APResourceResolveServiceImpl.kt b/src/main/kotlin/dev/usbharu/hideout/activitypub/service/common/APResourceResolveServiceImpl.kt index 246b02d8..05eed6d7 100644 --- a/src/main/kotlin/dev/usbharu/hideout/activitypub/service/common/APResourceResolveServiceImpl.kt +++ b/src/main/kotlin/dev/usbharu/hideout/activitypub/service/common/APResourceResolveServiceImpl.kt @@ -83,8 +83,6 @@ class APResourceResolveServiceImpl( return objects == other.objects } - override fun hashCode(): Int { - return objects.hashCode() - } + override fun hashCode(): Int = objects.hashCode() } } diff --git a/src/main/kotlin/dev/usbharu/hideout/activitypub/service/common/AbstractActivityPubProcessor.kt b/src/main/kotlin/dev/usbharu/hideout/activitypub/service/common/AbstractActivityPubProcessor.kt index e26e909d..2ba9d969 100644 --- a/src/main/kotlin/dev/usbharu/hideout/activitypub/service/common/AbstractActivityPubProcessor.kt +++ b/src/main/kotlin/dev/usbharu/hideout/activitypub/service/common/AbstractActivityPubProcessor.kt @@ -5,8 +5,10 @@ import dev.usbharu.hideout.activitypub.domain.exception.FailedProcessException import dev.usbharu.hideout.activitypub.domain.exception.HttpSignatureUnauthorizedException import dev.usbharu.hideout.activitypub.domain.model.objects.Object import dev.usbharu.hideout.application.external.Transaction +import dev.usbharu.hideout.core.domain.exception.resource.ResourceAccessException import org.slf4j.Logger import org.slf4j.LoggerFactory +import java.sql.SQLException abstract class AbstractActivityPubProcessor( private val transaction: Transaction, @@ -21,7 +23,11 @@ abstract class AbstractActivityPubProcessor( logger.info("START ActivityPub process. {}", this.type()) try { transaction.transaction { - internalProcess(activity) + try { + internalProcess(activity) + } catch (e: ResourceAccessException) { + throw SQLException(e) + } } } catch (e: ActivityPubProcessException) { logger.warn("FAILED ActivityPub process", e) diff --git a/src/main/kotlin/dev/usbharu/hideout/activitypub/service/inbox/InboxJobProcessor.kt b/src/main/kotlin/dev/usbharu/hideout/activitypub/service/inbox/InboxJobProcessor.kt index 2a884ae0..fbe38266 100644 --- a/src/main/kotlin/dev/usbharu/hideout/activitypub/service/inbox/InboxJobProcessor.kt +++ b/src/main/kotlin/dev/usbharu/hideout/activitypub/service/inbox/InboxJobProcessor.kt @@ -112,7 +112,7 @@ class InboxJobProcessor( logger.debug("Is verifying success? {}", verify) val activityPubProcessor = - activityPubProcessorList.firstOrNull { it.isSupported(param.type) } as ActivityPubProcessor? + activityPubProcessorList.firstOrNull { it.isSupported(param.type) } as? ActivityPubProcessor if (activityPubProcessor == null) { logger.warn("ActivityType {} is not support.", param.type) diff --git a/src/main/kotlin/dev/usbharu/hideout/activitypub/service/objects/emoji/EmojiService.kt b/src/main/kotlin/dev/usbharu/hideout/activitypub/service/objects/emoji/EmojiService.kt new file mode 100644 index 00000000..430908b1 --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/activitypub/service/objects/emoji/EmojiService.kt @@ -0,0 +1,10 @@ +package dev.usbharu.hideout.activitypub.service.objects.emoji + +import dev.usbharu.hideout.activitypub.domain.model.Emoji +import dev.usbharu.hideout.core.domain.model.emoji.CustomEmoji + +interface EmojiService { + suspend fun fetchEmoji(url: String): Pair + suspend fun fetchEmoji(emoji: Emoji): Pair + suspend fun findByEmojiName(emojiName: String): CustomEmoji? +} diff --git a/src/main/kotlin/dev/usbharu/hideout/activitypub/service/objects/emoji/EmojiServiceImpl.kt b/src/main/kotlin/dev/usbharu/hideout/activitypub/service/objects/emoji/EmojiServiceImpl.kt new file mode 100644 index 00000000..d54f508a --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/activitypub/service/objects/emoji/EmojiServiceImpl.kt @@ -0,0 +1,79 @@ +package dev.usbharu.hideout.activitypub.service.objects.emoji + +import dev.usbharu.hideout.activitypub.domain.model.Emoji +import dev.usbharu.hideout.activitypub.service.common.APResourceResolveServiceImpl +import dev.usbharu.hideout.activitypub.service.common.resolve +import dev.usbharu.hideout.application.config.ApplicationConfig +import dev.usbharu.hideout.core.domain.model.emoji.CustomEmoji +import dev.usbharu.hideout.core.domain.model.emoji.CustomEmojiRepository +import dev.usbharu.hideout.core.service.instance.InstanceService +import dev.usbharu.hideout.core.service.media.MediaService +import dev.usbharu.hideout.core.service.media.RemoteMedia +import org.springframework.stereotype.Service +import java.net.URL +import java.time.Instant + +@Service +class EmojiServiceImpl( + private val customEmojiRepository: CustomEmojiRepository, + private val instanceService: InstanceService, + private val mediaService: MediaService, + private val apResourceResolveServiceImpl: APResourceResolveServiceImpl, + private val applicationConfig: ApplicationConfig +) : EmojiService { + override suspend fun fetchEmoji(url: String): Pair { + val emoji = apResourceResolveServiceImpl.resolve(url, null as Long?) + return fetchEmoji(emoji) + } + + override suspend fun fetchEmoji(emoji: Emoji): Pair = emoji to save(emoji) + + private suspend fun save(emoji: Emoji): CustomEmoji { + val domain = URL(emoji.id).host + val name = emoji.name.trim(':') + val customEmoji = customEmojiRepository.findByNameAndDomain(name, domain) + + if (customEmoji != null) { + return customEmoji + } + + val instance = instanceService.fetchInstance(emoji.id) + + val media = mediaService.uploadRemoteMedia( + RemoteMedia( + emoji.name, + emoji.icon.url, + emoji.icon.mediaType.orEmpty(), + null + ) + ) + + val customEmoji1 = CustomEmoji( + id = customEmojiRepository.generateId(), + name = name, + domain = domain, + instanceId = instance.id, + url = media.url, + category = null, + createdAt = Instant.now() + ) + + return customEmojiRepository.save(customEmoji1) + } + + override suspend fun findByEmojiName(emojiName: String): CustomEmoji? { + val split = emojiName.trim(':').split("@") + + return when (split.size) { + 1 -> { + customEmojiRepository.findByNameAndDomain(split.first(), applicationConfig.url.host) + } + + 2 -> { + customEmojiRepository.findByNameAndDomain(split.first(), split[1]) + } + + else -> throw IllegalArgumentException("Unknown Emoji Format. $emojiName") + } + } +} diff --git a/src/main/kotlin/dev/usbharu/hideout/activitypub/service/objects/note/APNoteService.kt b/src/main/kotlin/dev/usbharu/hideout/activitypub/service/objects/note/APNoteService.kt index 7d284e0f..43d0b7f1 100644 --- a/src/main/kotlin/dev/usbharu/hideout/activitypub/service/objects/note/APNoteService.kt +++ b/src/main/kotlin/dev/usbharu/hideout/activitypub/service/objects/note/APNoteService.kt @@ -1,10 +1,12 @@ package dev.usbharu.hideout.activitypub.service.objects.note import dev.usbharu.hideout.activitypub.domain.exception.FailedToGetActivityPubResourceException +import dev.usbharu.hideout.activitypub.domain.model.Emoji import dev.usbharu.hideout.activitypub.domain.model.Note import dev.usbharu.hideout.activitypub.query.NoteQueryService import dev.usbharu.hideout.activitypub.service.common.APResourceResolveService import dev.usbharu.hideout.activitypub.service.common.resolve +import dev.usbharu.hideout.activitypub.service.objects.emoji.EmojiService import dev.usbharu.hideout.activitypub.service.objects.user.APUserService import dev.usbharu.hideout.core.domain.model.post.Post import dev.usbharu.hideout.core.domain.model.post.PostRepository @@ -32,7 +34,8 @@ class APNoteServiceImpl( private val apResourceResolveService: APResourceResolveService, private val postBuilder: Post.PostBuilder, private val noteQueryService: NoteQueryService, - private val mediaService: MediaService + private val mediaService: MediaService, + private val emojiService: EmojiService ) : APNoteService { @@ -101,6 +104,15 @@ class APNoteServiceImpl( postRepository.findByUrl(it) } + val emojis = note.tag + .filterIsInstance() + .map { + emojiService.fetchEmoji(it).second + } + .map { + it.id + } + val mediaList = note.attachment.map { mediaService.uploadRemoteMedia( RemoteMedia( @@ -123,7 +135,8 @@ class APNoteServiceImpl( replyId = reply?.id, sensitive = note.sensitive, apId = note.id, - mediaIds = mediaList + mediaIds = mediaList, + emojiIds = emojis ) ) return note to createRemote diff --git a/src/main/kotlin/dev/usbharu/hideout/application/infrastructure/exposed/ExposedTransaction.kt b/src/main/kotlin/dev/usbharu/hideout/application/infrastructure/exposed/ExposedTransaction.kt index 05fe6305..535375c5 100644 --- a/src/main/kotlin/dev/usbharu/hideout/application/infrastructure/exposed/ExposedTransaction.kt +++ b/src/main/kotlin/dev/usbharu/hideout/application/infrastructure/exposed/ExposedTransaction.kt @@ -13,12 +13,6 @@ import java.sql.Connection @Service class ExposedTransaction : Transaction { override suspend fun transaction(block: suspend () -> T): T { -// return newSuspendedTransaction(MDCContext(), transactionIsolation = java.sql.Connection.TRANSACTION_READ_COMMITTED) { -// warnLongQueriesDuration = 1000 -// addLogger(Slf4jSqlDebugLogger) -// block() -// } - return transaction(transactionIsolation = Connection.TRANSACTION_READ_COMMITTED) { debug = true warnLongQueriesDuration = 1000 diff --git a/src/main/kotlin/dev/usbharu/hideout/core/domain/model/actor/Actor.kt b/src/main/kotlin/dev/usbharu/hideout/core/domain/model/actor/Actor.kt index 0377df95..59ab16d3 100644 --- a/src/main/kotlin/dev/usbharu/hideout/core/domain/model/actor/Actor.kt +++ b/src/main/kotlin/dev/usbharu/hideout/core/domain/model/actor/Actor.kt @@ -26,7 +26,8 @@ data class Actor private constructor( val followersCount: Int = 0, val followingCount: Int = 0, val postsCount: Int = 0, - val lastPostDate: Instant? = null + val lastPostDate: Instant? = null, + val emojis: List = emptyList() ) { @Component @@ -55,7 +56,8 @@ data class Actor private constructor( followersCount: Int = 0, followingCount: Int = 0, postsCount: Int = 0, - lastPostDate: Instant? = null + lastPostDate: Instant? = null, + emojis: List = emptyList() ): Actor { if (id == 0L) { return Actor( @@ -78,7 +80,8 @@ data class Actor private constructor( followersCount = followersCount, followingCount = followingCount, postsCount = postsCount, - lastPostDate = lastPostDate + lastPostDate = lastPostDate, + emojis = emojis ) } @@ -188,7 +191,8 @@ data class Actor private constructor( followersCount = followersCount, followingCount = followingCount, postsCount = postsCount, - lastPostDate = lastPostDate + lastPostDate = lastPostDate, + emojis = emojis ) } } @@ -206,7 +210,6 @@ data class Actor private constructor( fun decrementPostsCount(): Actor = this.copy(postsCount = this.postsCount - 1) fun withLastPostAt(lastPostDate: Instant): Actor = this.copy(lastPostDate = lastPostDate) - override fun toString(): String { return "Actor(" + "id=$id, " + @@ -228,7 +231,8 @@ data class Actor private constructor( "followersCount=$followersCount, " + "followingCount=$followingCount, " + "postsCount=$postsCount, " + - "lastPostDate=$lastPostDate" + + "lastPostDate=$lastPostDate, " + + "emojis=$emojis" + ")" } } diff --git a/src/main/kotlin/dev/usbharu/hideout/core/domain/model/emoji/CustomEmoji.kt b/src/main/kotlin/dev/usbharu/hideout/core/domain/model/emoji/CustomEmoji.kt new file mode 100644 index 00000000..fb4579aa --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/core/domain/model/emoji/CustomEmoji.kt @@ -0,0 +1,36 @@ +package dev.usbharu.hideout.core.domain.model.emoji + +import java.time.Instant + +sealed class Emoji { + abstract val domain: String + abstract val name: String + + @Suppress("FunctionMinLength") + abstract fun id(): String + override fun toString(): String { + return "Emoji(" + + "domain='$domain', " + + "name='$name'" + + ")" + } +} + +data class CustomEmoji( + val id: Long, + override val name: String, + override val domain: String, + val instanceId: Long?, + val url: String, + val category: String?, + val createdAt: Instant +) : Emoji() { + override fun id(): String = id.toString() +} + +data class UnicodeEmoji( + override val name: String +) : Emoji() { + override val domain: String = "unicode.org" + override fun id(): String = name +} diff --git a/src/main/kotlin/dev/usbharu/hideout/core/domain/model/emoji/CustomEmojiRepository.kt b/src/main/kotlin/dev/usbharu/hideout/core/domain/model/emoji/CustomEmojiRepository.kt new file mode 100644 index 00000000..df7e87f3 --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/core/domain/model/emoji/CustomEmojiRepository.kt @@ -0,0 +1,9 @@ +package dev.usbharu.hideout.core.domain.model.emoji + +interface CustomEmojiRepository { + suspend fun generateId(): Long + suspend fun save(customEmoji: CustomEmoji): CustomEmoji + suspend fun findById(id: Long): CustomEmoji? + suspend fun delete(customEmoji: CustomEmoji) + suspend fun findByNameAndDomain(name: String, domain: String): CustomEmoji? +} diff --git a/src/main/kotlin/dev/usbharu/hideout/core/domain/model/instance/Nodeinfo.kt b/src/main/kotlin/dev/usbharu/hideout/core/domain/model/instance/Nodeinfo.kt index e2e44267..a17fc724 100644 --- a/src/main/kotlin/dev/usbharu/hideout/core/domain/model/instance/Nodeinfo.kt +++ b/src/main/kotlin/dev/usbharu/hideout/core/domain/model/instance/Nodeinfo.kt @@ -12,9 +12,7 @@ class Nodeinfo private constructor() { return links == other.links } - override fun hashCode(): Int { - return links.hashCode() - } + override fun hashCode(): Int = links.hashCode() } class Links private constructor() { diff --git a/src/main/kotlin/dev/usbharu/hideout/core/domain/model/post/Post.kt b/src/main/kotlin/dev/usbharu/hideout/core/domain/model/post/Post.kt index cc9edc51..cad1606f 100644 --- a/src/main/kotlin/dev/usbharu/hideout/core/domain/model/post/Post.kt +++ b/src/main/kotlin/dev/usbharu/hideout/core/domain/model/post/Post.kt @@ -17,7 +17,8 @@ data class Post private constructor( val sensitive: Boolean = false, val apId: String = url, val mediaIds: List = emptyList(), - val delted: Boolean = false + val delted: Boolean = false, + val emojiIds: List = emptyList() ) { @Component @@ -35,7 +36,8 @@ data class Post private constructor( replyId: Long? = null, sensitive: Boolean = false, apId: String = url, - mediaIds: List = emptyList() + mediaIds: List = emptyList(), + emojiIds: List = emptyList() ): Post { require(id >= 0) { "id must be greater than or equal to 0." } @@ -74,7 +76,8 @@ data class Post private constructor( sensitive = sensitive, apId = apId, mediaIds = mediaIds, - delted = false + delted = false, + emojiIds = emojiIds ) } diff --git a/src/main/kotlin/dev/usbharu/hideout/core/domain/model/reaction/Reaction.kt b/src/main/kotlin/dev/usbharu/hideout/core/domain/model/reaction/Reaction.kt index 02997373..ad2fefec 100644 --- a/src/main/kotlin/dev/usbharu/hideout/core/domain/model/reaction/Reaction.kt +++ b/src/main/kotlin/dev/usbharu/hideout/core/domain/model/reaction/Reaction.kt @@ -1,3 +1,5 @@ package dev.usbharu.hideout.core.domain.model.reaction -data class Reaction(val id: Long, val emojiId: Long, val postId: Long, val actorId: Long) +import dev.usbharu.hideout.core.domain.model.emoji.Emoji + +data class Reaction(val id: Long, val emoji: Emoji, val postId: Long, val actorId: Long) diff --git a/src/main/kotlin/dev/usbharu/hideout/core/domain/model/reaction/ReactionRepository.kt b/src/main/kotlin/dev/usbharu/hideout/core/domain/model/reaction/ReactionRepository.kt index 0b6183b5..df35e349 100644 --- a/src/main/kotlin/dev/usbharu/hideout/core/domain/model/reaction/ReactionRepository.kt +++ b/src/main/kotlin/dev/usbharu/hideout/core/domain/model/reaction/ReactionRepository.kt @@ -1,17 +1,23 @@ package dev.usbharu.hideout.core.domain.model.reaction +import dev.usbharu.hideout.core.domain.model.emoji.Emoji import org.springframework.stereotype.Repository @Repository -@Suppress("FunctionMaxLength") +@Suppress("FunctionMaxLength", "TooManyFunction") interface ReactionRepository { suspend fun generateId(): Long suspend fun save(reaction: Reaction): Reaction suspend fun delete(reaction: Reaction): Reaction suspend fun deleteByPostId(postId: Long): Int suspend fun deleteByActorId(actorId: Long): Int + suspend fun deleteByPostIdAndActorId(postId: Long, actorId: Long) + suspend fun deleteByPostIdAndActorIdAndEmoji(postId: Long, actorId: Long, emoji: Emoji) suspend fun findByPostId(postId: Long): List suspend fun findByPostIdAndActorIdAndEmojiId(postId: Long, actorId: Long, emojiId: Long): Reaction? suspend fun existByPostIdAndActorIdAndEmojiId(postId: Long, actorId: Long, emojiId: Long): Boolean + suspend fun existByPostIdAndActorIdAndUnicodeEmoji(postId: Long, actorId: Long, unicodeEmoji: String): Boolean + suspend fun existByPostIdAndActorIdAndEmoji(postId: Long, actorId: Long, emoji: Emoji): Boolean + suspend fun existByPostIdAndActor(postId: Long, actorId: Long): Boolean suspend fun findByPostIdAndActorId(postId: Long, actorId: Long): List } diff --git a/src/main/kotlin/dev/usbharu/hideout/core/domain/model/relationship/RelationshipRepositoryImpl.kt b/src/main/kotlin/dev/usbharu/hideout/core/domain/model/relationship/RelationshipRepositoryImpl.kt index a8085686..9cea8aaa 100644 --- a/src/main/kotlin/dev/usbharu/hideout/core/domain/model/relationship/RelationshipRepositoryImpl.kt +++ b/src/main/kotlin/dev/usbharu/hideout/core/domain/model/relationship/RelationshipRepositoryImpl.kt @@ -11,6 +11,9 @@ import org.springframework.stereotype.Service @Service class RelationshipRepositoryImpl : RelationshipRepository, AbstractRepository() { + override val logger: Logger + get() = Companion.logger + override suspend fun save(relationship: Relationship): Relationship = query { val singleOrNull = Relationships.select { (Relationships.actorId eq relationship.actorId).and( @@ -94,9 +97,6 @@ class RelationshipRepositoryImpl : RelationshipRepository, AbstractRepository() return@query query.map { it.toRelationships() } } - override val logger: Logger - get() = Companion.logger - companion object { private val logger = LoggerFactory.getLogger(RelationshipRepositoryImpl::class.java) } diff --git a/src/main/kotlin/dev/usbharu/hideout/core/domain/model/timeline/Timeline.kt b/src/main/kotlin/dev/usbharu/hideout/core/domain/model/timeline/Timeline.kt index cc9b181e..d3605ea5 100644 --- a/src/main/kotlin/dev/usbharu/hideout/core/domain/model/timeline/Timeline.kt +++ b/src/main/kotlin/dev/usbharu/hideout/core/domain/model/timeline/Timeline.kt @@ -21,5 +21,6 @@ data class Timeline( val sensitive: Boolean, val isLocal: Boolean, val isPureRepost: Boolean = false, - val mediaIds: List = emptyList() + val mediaIds: List = emptyList(), + val emojiIds: List = emptyList() ) diff --git a/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/exposed/PostQueryMapper.kt b/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/exposed/PostQueryMapper.kt index a21fcf4d..478812da 100644 --- a/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/exposed/PostQueryMapper.kt +++ b/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/exposed/PostQueryMapper.kt @@ -4,6 +4,7 @@ import dev.usbharu.hideout.application.infrastructure.exposed.QueryMapper import dev.usbharu.hideout.application.infrastructure.exposed.ResultRowMapper import dev.usbharu.hideout.core.domain.model.post.Post import dev.usbharu.hideout.core.infrastructure.exposedrepository.Posts +import dev.usbharu.hideout.core.infrastructure.exposedrepository.PostsEmojis import dev.usbharu.hideout.core.infrastructure.exposedrepository.PostsMedia import org.jetbrains.exposed.sql.Query import org.springframework.stereotype.Component @@ -15,7 +16,10 @@ class PostQueryMapper(private val postResultRowMapper: ResultRowMapper) : .map { it.value } .map { it.first().let(postResultRowMapper::map) - .copy(mediaIds = it.mapNotNull { resultRow -> resultRow.getOrNull(PostsMedia.mediaId) }) + .copy( + mediaIds = it.mapNotNull { resultRow -> resultRow.getOrNull(PostsMedia.mediaId) }, + emojiIds = it.mapNotNull { resultRow -> resultRow.getOrNull(PostsEmojis.emojiId) } + ) } } } diff --git a/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/exposed/UserResultRowMapper.kt b/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/exposed/UserResultRowMapper.kt index a5b58c60..6f8f4161 100644 --- a/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/exposed/UserResultRowMapper.kt +++ b/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/exposed/UserResultRowMapper.kt @@ -31,6 +31,7 @@ class UserResultRowMapper(private val actorBuilder: Actor.UserBuilder) : ResultR followersCount = resultRow[Actors.followersCount], postsCount = resultRow[Actors.postsCount], lastPostDate = resultRow[Actors.lastPostAt], + emojis = resultRow[Actors.emojis].split(",").filter { it.isNotEmpty() }.map { it.toLong() } ) } } diff --git a/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/exposedrepository/AbstractRepository.kt b/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/exposedrepository/AbstractRepository.kt index 3e22db17..383a93a5 100644 --- a/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/exposedrepository/AbstractRepository.kt +++ b/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/exposedrepository/AbstractRepository.kt @@ -1,6 +1,7 @@ package dev.usbharu.hideout.core.infrastructure.exposedrepository import dev.usbharu.hideout.core.domain.exception.SpringDataAccessExceptionSQLExceptionTranslator +import org.jetbrains.exposed.sql.transactions.TransactionManager import org.slf4j.Logger import org.springframework.beans.factory.annotation.Value import org.springframework.jdbc.support.SQLErrorCodeSQLExceptionTranslator @@ -37,6 +38,7 @@ ${Throwable().stackTrace.joinToString("\n")} if (traceQueryException) { logger.trace("FAILED EXECUTE SQL", e) } + TransactionManager.currentOrNull()?.rollback() if (e.cause !is SQLException) { throw e } diff --git a/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/exposedrepository/ActorRepositoryImpl.kt b/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/exposedrepository/ActorRepositoryImpl.kt index f4a70693..e0d7eca2 100644 --- a/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/exposedrepository/ActorRepositoryImpl.kt +++ b/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/exposedrepository/ActorRepositoryImpl.kt @@ -45,6 +45,7 @@ class ActorRepositoryImpl( it[followingCount] = actor.followingCount it[postsCount] = actor.postsCount it[lastPostAt] = actor.lastPostDate + it[emojis] = actor.emojis.joinToString(",") } } else { Actors.update({ Actors.id eq actor.id }) { @@ -67,6 +68,7 @@ class ActorRepositoryImpl( it[followingCount] = actor.followingCount it[postsCount] = actor.postsCount it[lastPostAt] = actor.lastPostDate + it[emojis] = actor.emojis.joinToString(",") } } return@query actor @@ -152,7 +154,7 @@ object Actors : Table("actors") { val followersCount = integer("followers_count") val postsCount = integer("posts_count") val lastPostAt = timestamp("last_post_at").nullable() - + val emojis = varchar("emojis", 3000) override val primaryKey: PrimaryKey = PrimaryKey(id) init { diff --git a/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/exposedrepository/CustomEmojiRepositoryImpl.kt b/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/exposedrepository/CustomEmojiRepositoryImpl.kt new file mode 100644 index 00000000..3310c138 --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/exposedrepository/CustomEmojiRepositoryImpl.kt @@ -0,0 +1,103 @@ +package dev.usbharu.hideout.core.infrastructure.exposedrepository + +import dev.usbharu.hideout.application.service.id.IdGenerateService +import dev.usbharu.hideout.core.domain.model.emoji.CustomEmoji +import dev.usbharu.hideout.core.domain.model.emoji.CustomEmojiRepository +import org.jetbrains.exposed.sql.* +import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq +import org.jetbrains.exposed.sql.javatime.CurrentTimestamp +import org.jetbrains.exposed.sql.javatime.timestamp +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Repository + +@Repository +class CustomEmojiRepositoryImpl(private val idGenerateService: IdGenerateService) : CustomEmojiRepository, + AbstractRepository() { + override val logger: Logger + get() = Companion.logger + + override suspend fun generateId(): Long = idGenerateService.generateId() + + override suspend fun save(customEmoji: CustomEmoji): CustomEmoji = query { + val singleOrNull = CustomEmojis.select { CustomEmojis.id eq customEmoji.id }.forUpdate().singleOrNull() + if (singleOrNull == null) { + CustomEmojis.insert { + it[CustomEmojis.id] = customEmoji.id + it[CustomEmojis.name] = customEmoji.name + it[CustomEmojis.domain] = customEmoji.domain + it[CustomEmojis.instanceId] = customEmoji.instanceId + it[CustomEmojis.url] = customEmoji.url + it[CustomEmojis.category] = customEmoji.category + it[CustomEmojis.createdAt] = customEmoji.createdAt + } + } else { + CustomEmojis.update({ CustomEmojis.id eq customEmoji.id }) { + it[CustomEmojis.name] = customEmoji.name + it[CustomEmojis.domain] = customEmoji.domain + it[CustomEmojis.instanceId] = customEmoji.instanceId + it[CustomEmojis.url] = customEmoji.url + it[CustomEmojis.category] = customEmoji.category + it[CustomEmojis.createdAt] = customEmoji.createdAt + } + } + return@query customEmoji + } + + override suspend fun findById(id: Long): CustomEmoji? = query { + return@query CustomEmojis.select { CustomEmojis.id eq id }.singleOrNull()?.toCustomEmoji() + } + + override suspend fun delete(customEmoji: CustomEmoji): Unit = query { + CustomEmojis.deleteWhere { CustomEmojis.id eq customEmoji.id } + } + + override suspend fun findByNameAndDomain(name: String, domain: String): CustomEmoji? = query { + return@query CustomEmojis + .select { CustomEmojis.name eq name and (CustomEmojis.domain eq domain) } + .singleOrNull() + ?.toCustomEmoji() + } + + companion object { + private val logger = LoggerFactory.getLogger(CustomEmojiRepositoryImpl::class.java) + } +} + +fun ResultRow.toCustomEmoji(): CustomEmoji = CustomEmoji( + id = this[CustomEmojis.id], + name = this[CustomEmojis.name], + domain = this[CustomEmojis.domain], + instanceId = this[CustomEmojis.instanceId], + url = this[CustomEmojis.url], + category = this[CustomEmojis.category], + createdAt = this[CustomEmojis.createdAt] +) + +fun ResultRow.toCustomEmojiOrNull(): CustomEmoji? { + return CustomEmoji( + id = this.getOrNull(CustomEmojis.id) ?: return null, + name = this.getOrNull(CustomEmojis.name) ?: return null, + domain = this.getOrNull(CustomEmojis.domain) ?: return null, + instanceId = this[CustomEmojis.instanceId], + url = this.getOrNull(CustomEmojis.url) ?: return null, + category = this[CustomEmojis.category], + createdAt = this.getOrNull(CustomEmojis.createdAt) ?: return null + ) +} + +object CustomEmojis : Table("emojis") { + val id = long("id") + val name = varchar("name", 1000) + val domain = varchar("domain", 1000) + val instanceId = long("instance_id").references(Instance.id).nullable() + val url = varchar("url", 255).uniqueIndex() + val category = varchar("category", 255).nullable() + val createdAt = timestamp("created_at").defaultExpression(CurrentTimestamp()) + + override val primaryKey: PrimaryKey = PrimaryKey(id) + + init { + uniqueIndex(name, instanceId) + } +} diff --git a/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/exposedrepository/ExposedTimelineRepository.kt b/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/exposedrepository/ExposedTimelineRepository.kt index 2630f733..7a630374 100644 --- a/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/exposedrepository/ExposedTimelineRepository.kt +++ b/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/exposedrepository/ExposedTimelineRepository.kt @@ -37,6 +37,7 @@ class ExposedTimelineRepository(private val idGenerateService: IdGenerateService it[isLocal] = timeline.isLocal it[isPureRepost] = timeline.isPureRepost it[mediaIds] = timeline.mediaIds.joinToString(",") + it[emojiIds] = timeline.emojiIds.joinToString(",") } } else { Timelines.update({ Timelines.id eq timeline.id }) { @@ -52,6 +53,7 @@ class ExposedTimelineRepository(private val idGenerateService: IdGenerateService it[isLocal] = timeline.isLocal it[isPureRepost] = timeline.isPureRepost it[mediaIds] = timeline.mediaIds.joinToString(",") + it[emojiIds] = timeline.emojiIds.joinToString(",") } } return@query timeline @@ -72,6 +74,7 @@ class ExposedTimelineRepository(private val idGenerateService: IdGenerateService this[Timelines.isLocal] = it.isLocal this[Timelines.isPureRepost] = it.isPureRepost this[Timelines.mediaIds] = it.mediaIds.joinToString(",") + this[Timelines.emojiIds] = it.emojiIds.joinToString(",") } return@query timelines } @@ -104,7 +107,8 @@ fun ResultRow.toTimeline(): Timeline { sensitive = this[Timelines.sensitive], isLocal = this[Timelines.isLocal], isPureRepost = this[Timelines.isPureRepost], - mediaIds = this[Timelines.mediaIds].split(",").mapNotNull { it.toLongOrNull() } + mediaIds = this[Timelines.mediaIds].split(",").mapNotNull { it.toLongOrNull() }, + emojiIds = this[Timelines.emojiIds].split(",").mapNotNull { it.toLongOrNull() } ) } @@ -122,6 +126,7 @@ object Timelines : Table("timelines") { val isLocal = bool("is_local") val isPureRepost = bool("is_pure_repost") val mediaIds = varchar("media_ids", 255) + val emojiIds = varchar("emoji_ids", 255) override val primaryKey: PrimaryKey = PrimaryKey(id) diff --git a/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/exposedrepository/PostRepositoryImpl.kt b/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/exposedrepository/PostRepositoryImpl.kt index 2a1899fd..4df40c2d 100644 --- a/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/exposedrepository/PostRepositoryImpl.kt +++ b/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/exposedrepository/PostRepositoryImpl.kt @@ -41,14 +41,25 @@ class PostRepositoryImpl( this[PostsMedia.postId] = post.id this[PostsMedia.mediaId] = it } + PostsEmojis.batchInsert(post.emojiIds) { + this[PostsEmojis.postId] = post.id + this[PostsEmojis.emojiId] = it + } } else { PostsMedia.deleteWhere { postId eq post.id } + PostsEmojis.deleteWhere { + postId eq post.id + } PostsMedia.batchInsert(post.mediaIds) { this[PostsMedia.postId] = post.id this[PostsMedia.mediaId] = it } + PostsEmojis.batchInsert(post.emojiIds) { + this[PostsEmojis.postId] = post.id + this[PostsEmojis.emojiId] = it + } Posts.update({ Posts.id eq post.id }) { it[actorId] = post.actorId it[overview] = post.overview @@ -67,21 +78,27 @@ class PostRepositoryImpl( } override suspend fun findById(id: Long): Post? = query { - return@query Posts.leftJoin(PostsMedia) + return@query Posts + .leftJoin(PostsMedia) + .leftJoin(PostsEmojis) .select { Posts.id eq id } .let(postQueryMapper::map) .singleOrNull() } override suspend fun findByUrl(url: String): Post? = query { - return@query Posts.leftJoin(PostsMedia) + return@query Posts + .leftJoin(PostsMedia) + .leftJoin(PostsEmojis) .select { Posts.url eq url } .let(postQueryMapper::map) .singleOrNull() } override suspend fun findByApId(apId: String): Post? = query { - return@query Posts.leftJoin(PostsMedia) + return@query Posts + .leftJoin(PostsMedia) + .leftJoin(PostsEmojis) .select { Posts.apId eq apId } .let(postQueryMapper::map) .singleOrNull() @@ -92,7 +109,10 @@ class PostRepositoryImpl( } override suspend fun findByActorId(actorId: Long): List = query { - return@query Posts.select { Posts.actorId eq actorId }.let(postQueryMapper::map) + return@query Posts + .leftJoin(PostsMedia) + .leftJoin(PostsEmojis) + .select { Posts.actorId eq actorId }.let(postQueryMapper::map) } override suspend fun delete(id: Long): Unit = query { @@ -125,3 +145,9 @@ object PostsMedia : Table("posts_media") { val mediaId = long("media_id").references(Media.id, ReferenceOption.CASCADE, ReferenceOption.CASCADE) override val primaryKey = PrimaryKey(postId, mediaId) } + +object PostsEmojis : Table("posts_emojis") { + val postId = long("post_id").references(Posts.id) + val emojiId = long("emoji_id").references(CustomEmojis.id) + override val primaryKey: PrimaryKey = PrimaryKey(postId, emojiId) +} diff --git a/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/exposedrepository/ReactionRepositoryImpl.kt b/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/exposedrepository/ReactionRepositoryImpl.kt index 1a3a14ff..50887de2 100644 --- a/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/exposedrepository/ReactionRepositoryImpl.kt +++ b/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/exposedrepository/ReactionRepositoryImpl.kt @@ -1,6 +1,9 @@ package dev.usbharu.hideout.core.infrastructure.exposedrepository import dev.usbharu.hideout.application.service.id.IdGenerateService +import dev.usbharu.hideout.core.domain.model.emoji.CustomEmoji +import dev.usbharu.hideout.core.domain.model.emoji.Emoji +import dev.usbharu.hideout.core.domain.model.emoji.UnicodeEmoji import dev.usbharu.hideout.core.domain.model.reaction.Reaction import dev.usbharu.hideout.core.domain.model.reaction.ReactionRepository import org.jetbrains.exposed.dao.id.LongIdTable @@ -23,13 +26,25 @@ class ReactionRepositoryImpl( if (Reactions.select { Reactions.id eq reaction.id }.forUpdate().empty()) { Reactions.insert { it[id] = reaction.id - it[emojiId] = reaction.emojiId + if (reaction.emoji is CustomEmoji) { + it[customEmojiId] = reaction.emoji.id + it[unicodeEmoji] = null + } else { + it[customEmojiId] = null + it[unicodeEmoji] = reaction.emoji.name + } it[postId] = reaction.postId it[actorId] = reaction.actorId } } else { Reactions.update({ Reactions.id eq reaction.id }) { - it[emojiId] = reaction.emojiId + if (reaction.emoji is CustomEmoji) { + it[customEmojiId] = reaction.emoji.id + it[unicodeEmoji] = null + } else { + it[customEmojiId] = null + it[unicodeEmoji] = reaction.emoji.name + } it[postId] = reaction.postId it[actorId] = reaction.actorId } @@ -38,9 +53,16 @@ class ReactionRepositoryImpl( } override suspend fun delete(reaction: Reaction): Reaction = query { - Reactions.deleteWhere { - id.eq(reaction.id).and(postId.eq(reaction.postId)).and(actorId.eq(reaction.actorId)) - .and(emojiId.eq(reaction.emojiId)) + if (reaction.emoji is CustomEmoji) { + Reactions.deleteWhere { + id.eq(reaction.id).and(postId.eq(reaction.postId)).and(actorId.eq(reaction.actorId)) + .and(customEmojiId.eq(reaction.emoji.id)) + } + } else { + Reactions.deleteWhere { + id.eq(reaction.id).and(postId.eq(reaction.postId)).and(actorId.eq(reaction.actorId)) + .and(unicodeEmoji.eq(reaction.emoji.name)) + } } return@query reaction } @@ -57,15 +79,37 @@ class ReactionRepositoryImpl( } } + override suspend fun deleteByPostIdAndActorId(postId: Long, actorId: Long): Unit = query { + Reactions.deleteWhere { + Reactions.postId eq postId and (Reactions.actorId eq actorId) + } + } + + override suspend fun deleteByPostIdAndActorIdAndEmoji(postId: Long, actorId: Long, emoji: Emoji): Unit = query { + if (emoji is CustomEmoji) { + Reactions.deleteWhere { + Reactions.postId.eq(postId) + .and(Reactions.actorId.eq(actorId)) + .and(Reactions.customEmojiId.eq(emoji.id)) + } + } else { + Reactions.deleteWhere { + Reactions.postId.eq(postId) + .and(Reactions.actorId.eq(actorId)) + .and(Reactions.unicodeEmoji.eq(emoji.name)) + } + } + } + override suspend fun findByPostId(postId: Long): List = query { - return@query Reactions.select { Reactions.postId eq postId }.map { it.toReaction() } + return@query Reactions.leftJoin(CustomEmojis).select { Reactions.postId eq postId }.map { it.toReaction() } } override suspend fun findByPostIdAndActorIdAndEmojiId(postId: Long, actorId: Long, emojiId: Long): Reaction? = query { - return@query Reactions.select { + return@query Reactions.leftJoin(CustomEmojis).select { Reactions.postId eq postId and (Reactions.actorId eq actorId).and( - Reactions.emojiId.eq( + Reactions.customEmojiId.eq( emojiId ) ) @@ -78,12 +122,49 @@ class ReactionRepositoryImpl( Reactions.postId .eq(postId) .and(Reactions.actorId.eq(actorId)) - .and(Reactions.emojiId.eq(emojiId)) + .and(Reactions.customEmojiId.eq(emojiId)) }.empty().not() } + override suspend fun existByPostIdAndActorIdAndUnicodeEmoji( + postId: Long, + actorId: Long, + unicodeEmoji: String + ): Boolean = query { + return@query Reactions.select { + Reactions.postId + .eq(postId) + .and(Reactions.actorId.eq(actorId)) + .and(Reactions.unicodeEmoji.eq(unicodeEmoji)) + }.empty().not() + } + + override suspend fun existByPostIdAndActorIdAndEmoji(postId: Long, actorId: Long, emoji: Emoji): Boolean = query { + val query = Reactions.select { + Reactions.postId + .eq(postId) + .and(Reactions.actorId.eq(actorId)) + } + + if (emoji is UnicodeEmoji) { + query.andWhere { Reactions.unicodeEmoji eq emoji.name } + } else { + emoji as CustomEmoji + query.andWhere { Reactions.customEmojiId eq emoji.id } + } + + return@query query.empty().not() + } + + override suspend fun existByPostIdAndActor(postId: Long, actorId: Long): Boolean = query { + Reactions.select { + Reactions.postId.eq(postId).and(Reactions.actorId.eq(actorId)) + }.empty().not() + } + override suspend fun findByPostIdAndActorId(postId: Long, actorId: Long): List = query { - return@query Reactions.select { Reactions.postId eq postId and (Reactions.actorId eq actorId) } + return@query Reactions.leftJoin(CustomEmojis) + .select { Reactions.postId eq postId and (Reactions.actorId eq actorId) } .map { it.toReaction() } } @@ -93,22 +174,39 @@ class ReactionRepositoryImpl( } fun ResultRow.toReaction(): Reaction { + val emoji = if (this[Reactions.customEmojiId] != null) { + CustomEmoji( + id = this[Reactions.customEmojiId]!!, + name = this[CustomEmojis.name], + domain = this[CustomEmojis.domain], + instanceId = this[CustomEmojis.instanceId], + url = this[CustomEmojis.url], + category = this[CustomEmojis.category], + createdAt = this[CustomEmojis.createdAt] + ) + } else if (this[Reactions.unicodeEmoji] != null) { + UnicodeEmoji(this[Reactions.unicodeEmoji]!!) + } else { + throw IllegalStateException("customEmojiId and unicodeEmoji is null.") + } + return Reaction( this[Reactions.id].value, - this[Reactions.emojiId], + emoji, this[Reactions.postId], this[Reactions.actorId] ) } object Reactions : LongIdTable("reactions") { - val emojiId: Column = long("emoji_id") + val customEmojiId = long("custom_emoji_id").references(CustomEmojis.id).nullable() + val unicodeEmoji = varchar("unicode_emoji", 255).nullable() val postId: Column = long("post_id").references(Posts.id, onDelete = ReferenceOption.CASCADE, onUpdate = ReferenceOption.CASCADE) val actorId: Column = long("actor_id").references(Actors.id, onDelete = ReferenceOption.CASCADE, onUpdate = ReferenceOption.CASCADE) init { - uniqueIndex(emojiId, postId, actorId) + uniqueIndex(customEmojiId, postId, actorId) } } diff --git a/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/springframework/oauth2/UserDetailsImpl.kt b/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/springframework/oauth2/UserDetailsImpl.kt index cb06f2ce..f41d3fe4 100644 --- a/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/springframework/oauth2/UserDetailsImpl.kt +++ b/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/springframework/oauth2/UserDetailsImpl.kt @@ -26,11 +26,6 @@ class UserDetailsImpl( accountNonLocked: Boolean, authorities: MutableCollection? ) : User(username, password, enabled, accountNonExpired, credentialsNonExpired, accountNonLocked, authorities) { - companion object { - @Serial - private const val serialVersionUID: Long = -899168205656607781L - } - override fun toString(): String { return "UserDetailsImpl(" + "id=$id" + @@ -53,6 +48,11 @@ class UserDetailsImpl( result = 31 * result + id.hashCode() return result } + + companion object { + @Serial + private const val serialVersionUID: Long = -899168205656607781L + } } @JsonTypeInfo(use = JsonTypeInfo.Id.CLASS, include = JsonTypeInfo.As.PROPERTY) diff --git a/src/main/kotlin/dev/usbharu/hideout/core/service/media/SavedMedia.kt b/src/main/kotlin/dev/usbharu/hideout/core/service/media/SavedMedia.kt index 50a2caac..7a5eaa1c 100644 --- a/src/main/kotlin/dev/usbharu/hideout/core/service/media/SavedMedia.kt +++ b/src/main/kotlin/dev/usbharu/hideout/core/service/media/SavedMedia.kt @@ -10,9 +10,7 @@ sealed class SavedMedia(val success: Boolean) { return success == other.success } - override fun hashCode(): Int { - return success.hashCode() - } + override fun hashCode(): Int = success.hashCode() } class SuccessSavedMedia( diff --git a/src/main/kotlin/dev/usbharu/hideout/core/service/reaction/ReactionService.kt b/src/main/kotlin/dev/usbharu/hideout/core/service/reaction/ReactionService.kt index 38121bb6..115bac21 100644 --- a/src/main/kotlin/dev/usbharu/hideout/core/service/reaction/ReactionService.kt +++ b/src/main/kotlin/dev/usbharu/hideout/core/service/reaction/ReactionService.kt @@ -1,11 +1,12 @@ package dev.usbharu.hideout.core.service.reaction +import dev.usbharu.hideout.core.domain.model.emoji.Emoji import org.springframework.stereotype.Service @Service interface ReactionService { - suspend fun receiveReaction(name: String, domain: String, actorId: Long, postId: Long) + suspend fun receiveReaction(emoji: Emoji, actorId: Long, postId: Long) suspend fun receiveRemoveReaction(actorId: Long, postId: Long) - suspend fun sendReaction(name: String, actorId: Long, postId: Long) + suspend fun sendReaction(emoji: Emoji, actorId: Long, postId: Long) suspend fun removeReaction(actorId: Long, postId: Long) } diff --git a/src/main/kotlin/dev/usbharu/hideout/core/service/reaction/ReactionServiceImpl.kt b/src/main/kotlin/dev/usbharu/hideout/core/service/reaction/ReactionServiceImpl.kt index 2a731029..927dd9fb 100644 --- a/src/main/kotlin/dev/usbharu/hideout/core/service/reaction/ReactionServiceImpl.kt +++ b/src/main/kotlin/dev/usbharu/hideout/core/service/reaction/ReactionServiceImpl.kt @@ -1,9 +1,10 @@ package dev.usbharu.hideout.core.service.reaction import dev.usbharu.hideout.activitypub.service.activity.like.APReactionService +import dev.usbharu.hideout.core.domain.exception.resource.DuplicateException +import dev.usbharu.hideout.core.domain.model.emoji.Emoji import dev.usbharu.hideout.core.domain.model.reaction.Reaction import dev.usbharu.hideout.core.domain.model.reaction.ReactionRepository -import org.jetbrains.exposed.exceptions.ExposedSQLException import org.slf4j.Logger import org.slf4j.LoggerFactory import org.springframework.stereotype.Service @@ -13,14 +14,17 @@ class ReactionServiceImpl( private val reactionRepository: ReactionRepository, private val apReactionService: APReactionService ) : ReactionService { - override suspend fun receiveReaction(name: String, domain: String, actorId: Long, postId: Long) { - if (reactionRepository.existByPostIdAndActorIdAndEmojiId(postId, actorId, 0).not()) { - try { - reactionRepository.save( - Reaction(reactionRepository.generateId(), 0, postId, actorId) - ) - } catch (_: ExposedSQLException) { - } + override suspend fun receiveReaction( + emoji: Emoji, + actorId: Long, + postId: Long + ) { + if (reactionRepository.existByPostIdAndActor(postId, actorId)) { + reactionRepository.deleteByPostIdAndActorId(postId, actorId) + } + try { + reactionRepository.save(Reaction(reactionRepository.generateId(), emoji, postId, actorId)) + } catch (_: DuplicateException) { } } @@ -33,7 +37,7 @@ class ReactionServiceImpl( reactionRepository.delete(reaction) } - override suspend fun sendReaction(name: String, actorId: Long, postId: Long) { + override suspend fun sendReaction(emoji: Emoji, actorId: Long, postId: Long) { val findByPostIdAndUserIdAndEmojiId = reactionRepository.findByPostIdAndActorIdAndEmojiId(postId, actorId, 0) @@ -42,7 +46,7 @@ class ReactionServiceImpl( reactionRepository.delete(findByPostIdAndUserIdAndEmojiId) } - val reaction = Reaction(reactionRepository.generateId(), 0, postId, actorId) + val reaction = Reaction(reactionRepository.generateId(), emoji, postId, actorId) reactionRepository.save(reaction) apReactionService.reaction(reaction) } diff --git a/src/main/kotlin/dev/usbharu/hideout/core/service/timeline/ExposedGenerateTimelineService.kt b/src/main/kotlin/dev/usbharu/hideout/core/service/timeline/ExposedGenerateTimelineService.kt index 03d2f49a..7d83a9ac 100644 --- a/src/main/kotlin/dev/usbharu/hideout/core/service/timeline/ExposedGenerateTimelineService.kt +++ b/src/main/kotlin/dev/usbharu/hideout/core/service/timeline/ExposedGenerateTimelineService.kt @@ -45,7 +45,8 @@ class ExposedGenerateTimelineService(private val statusQueryService: StatusQuery it[Timelines.postId], it[Timelines.replyId], it[Timelines.repostId], - it[Timelines.mediaIds].split(",").mapNotNull { s -> s.toLongOrNull() } + it[Timelines.mediaIds].split(",").mapNotNull { s -> s.toLongOrNull() }, + it[Timelines.emojiIds].split(",").mapNotNull { s -> s.toLongOrNull() } ) } diff --git a/src/main/kotlin/dev/usbharu/hideout/core/service/timeline/MongoGenerateTimelineService.kt b/src/main/kotlin/dev/usbharu/hideout/core/service/timeline/MongoGenerateTimelineService.kt index f3accd56..541f24c3 100644 --- a/src/main/kotlin/dev/usbharu/hideout/core/service/timeline/MongoGenerateTimelineService.kt +++ b/src/main/kotlin/dev/usbharu/hideout/core/service/timeline/MongoGenerateTimelineService.kt @@ -57,7 +57,8 @@ class MongoGenerateTimelineService( it.postId, it.replyId, it.repostId, - it.mediaIds + it.mediaIds, + it.emojiIds ) } ) diff --git a/src/main/kotlin/dev/usbharu/hideout/core/service/timeline/TimelineService.kt b/src/main/kotlin/dev/usbharu/hideout/core/service/timeline/TimelineService.kt index a6d8b185..18d1632c 100644 --- a/src/main/kotlin/dev/usbharu/hideout/core/service/timeline/TimelineService.kt +++ b/src/main/kotlin/dev/usbharu/hideout/core/service/timeline/TimelineService.kt @@ -37,7 +37,8 @@ class TimelineService( sensitive = post.sensitive, isLocal = isLocal, isPureRepost = post.repostId == null || (post.text.isBlank() && post.overview.isNullOrBlank()), - mediaIds = post.mediaIds + mediaIds = post.mediaIds, + emojiIds = post.emojiIds ) }.toMutableList() if (post.visibility == Visibility.PUBLIC) { @@ -55,7 +56,8 @@ class TimelineService( sensitive = post.sensitive, isLocal = isLocal, isPureRepost = post.repostId == null || (post.text.isBlank() && post.overview.isNullOrBlank()), - mediaIds = post.mediaIds + mediaIds = post.mediaIds, + emojiIds = post.emojiIds ) ) } diff --git a/src/main/kotlin/dev/usbharu/hideout/mastodon/infrastructure/exposedquery/StatusQueryServiceImpl.kt b/src/main/kotlin/dev/usbharu/hideout/mastodon/infrastructure/exposedquery/StatusQueryServiceImpl.kt index 831c693a..e3c47e6e 100644 --- a/src/main/kotlin/dev/usbharu/hideout/mastodon/infrastructure/exposedquery/StatusQueryServiceImpl.kt +++ b/src/main/kotlin/dev/usbharu/hideout/mastodon/infrastructure/exposedquery/StatusQueryServiceImpl.kt @@ -1,5 +1,6 @@ package dev.usbharu.hideout.mastodon.infrastructure.exposedquery +import dev.usbharu.hideout.core.domain.model.emoji.CustomEmoji import dev.usbharu.hideout.core.domain.model.media.toMediaAttachments import dev.usbharu.hideout.core.infrastructure.exposedrepository.* import dev.usbharu.hideout.domain.mastodon.model.generated.Account @@ -12,6 +13,7 @@ import org.jetbrains.exposed.sql.andWhere import org.jetbrains.exposed.sql.select import org.springframework.stereotype.Repository import java.time.Instant +import dev.usbharu.hideout.domain.mastodon.model.generated.CustomEmoji as MastodonEmoji @Suppress("IncompleteDestructuring") @Repository @@ -23,6 +25,10 @@ class StatusQueryServiceImpl : StatusQueryService { postIdSet.addAll(statusQueries.flatMap { listOfNotNull(it.postId, it.replyId, it.repostId) }) val mediaIdSet = mutableSetOf() mediaIdSet.addAll(statusQueries.flatMap { it.mediaIds }) + + val emojiIdSet = mutableSetOf() + emojiIdSet.addAll(statusQueries.flatMap { it.emojiIds }) + val postMap = Posts .leftJoin(Actors) .select { Posts.id inList postIdSet } @@ -32,12 +38,16 @@ class StatusQueryServiceImpl : StatusQueryService { it[Media.id] to it.toMedia().toMediaAttachments() } + val emojiMap = CustomEmojis.select { CustomEmojis.id inList emojiIdSet }.associate { + it[CustomEmojis.id] to it.toCustomEmoji().toMastodonEmoji() + } return statusQueries.mapNotNull { statusQuery -> postMap[statusQuery.postId]?.copy( inReplyToId = statusQuery.replyId?.toString(), inReplyToAccountId = postMap[statusQuery.replyId]?.account?.id, reblog = postMap[statusQuery.repostId], - mediaAttachments = statusQuery.mediaIds.mapNotNull { mediaMap[it] } + mediaAttachments = statusQuery.mediaIds.mapNotNull { mediaMap[it] }, + emojis = statusQuery.emojiIds.mapNotNull { emojiMap[it] } ) } } @@ -98,6 +108,25 @@ class StatusQueryServiceImpl : StatusQueryService { return resolveReplyAndRepost(pairs) } + override suspend fun findByPostId(id: Long): Status { + val map = Posts + .leftJoin(PostsMedia) + .leftJoin(Actors) + .leftJoin(Media) + .select { Posts.id eq id } + .groupBy { it[Posts.id] } + .map { it.value } + .map { + toStatus(it.first()).copy( + mediaAttachments = it.mapNotNull { resultRow -> + resultRow.toMediaOrNull()?.toMediaAttachments() + }, + emojis = it.mapNotNull { resultRow -> resultRow.toCustomEmojiOrNull()?.toMastodonEmoji() } + ) to it.first()[Posts.repostId] + } + return resolveReplyAndRepost(map).single() + } + private fun resolveReplyAndRepost(pairs: List>): List { val statuses = pairs.map { it.first } return pairs @@ -120,6 +149,8 @@ class StatusQueryServiceImpl : StatusQueryService { private suspend fun findByPostIdsWithMedia(ids: List): List { val pairs = Posts .leftJoin(PostsMedia) + .leftJoin(PostsEmojis) + .leftJoin(CustomEmojis) .leftJoin(Actors) .leftJoin(Media) .select { Posts.id inList ids } @@ -129,13 +160,22 @@ class StatusQueryServiceImpl : StatusQueryService { toStatus(it.first()).copy( mediaAttachments = it.mapNotNull { resultRow -> resultRow.toMediaOrNull()?.toMediaAttachments() - } + }, + emojis = it.mapNotNull { resultRow -> resultRow.toCustomEmojiOrNull()?.toMastodonEmoji() } ) to it.first()[Posts.repostId] } return resolveReplyAndRepost(pairs) } } +private fun CustomEmoji.toMastodonEmoji(): MastodonEmoji = MastodonEmoji( + shortcode = this.name, + url = this.url, + staticUrl = this.url, + visibleInPicker = true, + category = this.category.orEmpty() +) + private fun toStatus(it: ResultRow) = Status( id = it[Posts.id].toString(), uri = it[Posts.apId], diff --git a/src/main/kotlin/dev/usbharu/hideout/mastodon/interfaces/api/status/MastodonStatusesApiContoller.kt b/src/main/kotlin/dev/usbharu/hideout/mastodon/interfaces/api/status/MastodonStatusesApiContoller.kt index 2b2e65eb..cc6443ce 100644 --- a/src/main/kotlin/dev/usbharu/hideout/mastodon/interfaces/api/status/MastodonStatusesApiContoller.kt +++ b/src/main/kotlin/dev/usbharu/hideout/mastodon/interfaces/api/status/MastodonStatusesApiContoller.kt @@ -24,4 +24,20 @@ class MastodonStatusesApiContoller(private val statusesApiService: StatusesApiSe HttpStatus.OK ) } + + override suspend fun apiV1StatusesIdEmojiReactionsEmojiDelete(id: String, emoji: String): ResponseEntity { + val uid = + (SecurityContextHolder.getContext().authentication.principal as Jwt).getClaim("uid").toLong() + + return ResponseEntity.ok(statusesApiService.removeEmojiReactions(id.toLong(), uid, emoji)) + } + + override suspend fun apiV1StatusesIdEmojiReactionsEmojiPut(id: String, emoji: String): ResponseEntity { + val uid = + (SecurityContextHolder.getContext().authentication.principal as Jwt).getClaim("uid").toLong() + + return ResponseEntity.ok(statusesApiService.emojiReactions(id.toLong(), uid, emoji)) + } + + override suspend fun apiV1StatusesIdGet(id: String): ResponseEntity = super.apiV1StatusesIdGet(id) } diff --git a/src/main/kotlin/dev/usbharu/hideout/mastodon/interfaces/api/status/StatusQuery.kt b/src/main/kotlin/dev/usbharu/hideout/mastodon/interfaces/api/status/StatusQuery.kt index ea5c1517..c53117ff 100644 --- a/src/main/kotlin/dev/usbharu/hideout/mastodon/interfaces/api/status/StatusQuery.kt +++ b/src/main/kotlin/dev/usbharu/hideout/mastodon/interfaces/api/status/StatusQuery.kt @@ -4,5 +4,6 @@ data class StatusQuery( val postId: Long, val replyId: Long?, val repostId: Long?, - val mediaIds: List + val mediaIds: List, + val emojiIds: List ) diff --git a/src/main/kotlin/dev/usbharu/hideout/mastodon/query/StatusQueryService.kt b/src/main/kotlin/dev/usbharu/hideout/mastodon/query/StatusQueryService.kt index 2b4e2a31..fe78a70d 100644 --- a/src/main/kotlin/dev/usbharu/hideout/mastodon/query/StatusQueryService.kt +++ b/src/main/kotlin/dev/usbharu/hideout/mastodon/query/StatusQueryService.kt @@ -36,4 +36,6 @@ interface StatusQueryService { tagged: String? = null, includeFollowers: Boolean = false ): List + + suspend fun findByPostId(id: Long): Status } diff --git a/src/main/kotlin/dev/usbharu/hideout/mastodon/service/status/StatusesApiService.kt b/src/main/kotlin/dev/usbharu/hideout/mastodon/service/status/StatusesApiService.kt index 1a095d82..563f2059 100644 --- a/src/main/kotlin/dev/usbharu/hideout/mastodon/service/status/StatusesApiService.kt +++ b/src/main/kotlin/dev/usbharu/hideout/mastodon/service/status/StatusesApiService.kt @@ -1,17 +1,24 @@ package dev.usbharu.hideout.mastodon.service.status +import dev.usbharu.hideout.activitypub.service.objects.emoji.EmojiService import dev.usbharu.hideout.application.external.Transaction import dev.usbharu.hideout.core.domain.model.actor.ActorRepository +import dev.usbharu.hideout.core.domain.model.emoji.UnicodeEmoji import dev.usbharu.hideout.core.domain.model.media.MediaRepository import dev.usbharu.hideout.core.domain.model.media.toMediaAttachments import dev.usbharu.hideout.core.domain.model.post.PostRepository +import dev.usbharu.hideout.core.domain.model.relationship.RelationshipRepository import dev.usbharu.hideout.core.service.post.PostCreateDto import dev.usbharu.hideout.core.service.post.PostService +import dev.usbharu.hideout.core.service.reaction.ReactionService import dev.usbharu.hideout.domain.mastodon.model.generated.Status +import dev.usbharu.hideout.domain.mastodon.model.generated.Status.Visibility.* import dev.usbharu.hideout.mastodon.interfaces.api.status.StatusesRequest import dev.usbharu.hideout.mastodon.interfaces.api.status.toPostVisibility import dev.usbharu.hideout.mastodon.interfaces.api.status.toStatusVisibility +import dev.usbharu.hideout.mastodon.query.StatusQueryService import dev.usbharu.hideout.mastodon.service.account.AccountService +import dev.usbharu.hideout.util.EmojiUtil import org.slf4j.LoggerFactory import org.springframework.stereotype.Service import java.time.Instant @@ -22,16 +29,38 @@ interface StatusesApiService { statusesRequest: StatusesRequest, userId: Long ): Status + + suspend fun findById( + id: Long, + userId: Long? + ): Status? + + suspend fun emojiReactions( + postId: Long, + userId: Long, + emojiName: String + ): Status? + + suspend fun removeEmojiReactions( + postId: Long, + userId: Long, + emojiName: String + ): Status? } @Service +@Suppress("LongParameterList") class StatsesApiServiceImpl( private val postService: PostService, private val accountService: AccountService, private val mediaRepository: MediaRepository, private val transaction: Transaction, private val actorRepository: ActorRepository, - private val postRepository: PostRepository + private val postRepository: PostRepository, + private val statusQueryService: StatusQueryService, + private val relationshipRepository: RelationshipRepository, + private val reactionService: ReactionService, + private val emojiService: EmojiService ) : StatusesApiService { override suspend fun postStatus( @@ -95,6 +124,61 @@ class StatsesApiServiceImpl( ) } + override suspend fun findById(id: Long, userId: Long?): Status? { + val status = statusQueryService.findByPostId(id) + + return status(status, userId) + } + + private suspend fun status( + status: Status, + userId: Long? + ): Status? { + return when (status.visibility) { + public -> status + unlisted -> status + private -> { + if (userId == null) { + return null + } + + val relationship = + relationshipRepository.findByUserIdAndTargetUserId(userId, status.account.id.toLong()) + ?: return null + if (relationship.following) { + return status + } + return null + } + + direct -> null + } + } + + override suspend fun emojiReactions(postId: Long, userId: Long, emojiName: String): Status? { + status(statusQueryService.findByPostId(postId), userId) ?: return null + + val emoji = try { + if (EmojiUtil.isEmoji(emojiName)) { + UnicodeEmoji(emojiName) + } else { + emojiService.findByEmojiName(emojiName)!! + } + } catch (_: IllegalStateException) { + UnicodeEmoji("❤") + } catch (_: NullPointerException) { + UnicodeEmoji("❤") + } + reactionService.sendReaction(emoji, userId, postId) + return statusQueryService.findByPostId(postId) + } + + override suspend fun removeEmojiReactions(postId: Long, userId: Long, emojiName: String): Status? { + reactionService.removeReaction(userId, postId) + + return status(statusQueryService.findByPostId(postId), userId) + } + companion object { private val logger = LoggerFactory.getLogger(StatusesApiService::class.java) } diff --git a/src/main/kotlin/dev/usbharu/hideout/util/EmojiUtil.kt b/src/main/kotlin/dev/usbharu/hideout/util/EmojiUtil.kt new file mode 100644 index 00000000..4e5c5393 --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/util/EmojiUtil.kt @@ -0,0 +1,14 @@ +package dev.usbharu.hideout.util + +import Emojis + +object EmojiUtil { + + val emojiMap by lazy { + Emojis.allEmojis + .associate { it.code.replace(" ", "-") to it.char } + .filterValues { it != "™" } + } + + fun isEmoji(string: String): Boolean = emojiMap.any { it.value == string } +} diff --git a/src/main/kotlin/dev/usbharu/hideout/util/TempFileUtil.kt b/src/main/kotlin/dev/usbharu/hideout/util/TempFileUtil.kt index 8a506767..7e57e31e 100644 --- a/src/main/kotlin/dev/usbharu/hideout/util/TempFileUtil.kt +++ b/src/main/kotlin/dev/usbharu/hideout/util/TempFileUtil.kt @@ -19,7 +19,5 @@ class TempFile(val path: T) : AutoCloseable { return path == other.path } - override fun hashCode(): Int { - return path?.hashCode() ?: 0 - } + override fun hashCode(): Int = path?.hashCode() ?: 0 } diff --git a/src/main/resources/db/migration/V1__Init_DB.sql b/src/main/resources/db/migration/V1__Init_DB.sql index 1badf0ae..2e9e638b 100644 --- a/src/main/resources/db/migration/V1__Init_DB.sql +++ b/src/main/resources/db/migration/V1__Init_DB.sql @@ -1,3 +1,15 @@ +create table if not exists emojis +( + id bigint primary key, + "name" varchar(1000) not null, + domain varchar(1000) not null, + instance_id bigint null, + url varchar(255) not null unique, + category varchar(255), + created_at timestamp not null default current_timestamp, + unique ("name", instance_id) +); + create table if not exists instance ( id bigint primary key, @@ -13,6 +25,10 @@ create table if not exists instance moderation_note varchar(10000) not null, created_at timestamp not null ); + +alter table emojis + add constraint fk_emojis_instance_id__id foreign key (instance_id) references instance (id) on delete cascade on update cascade; + create table if not exists actors ( id bigint primary key, @@ -34,7 +50,8 @@ create table if not exists actors following_count int not null, followers_count int not null, posts_count int not null, - last_post_at timestamp null default null, + last_post_at timestamp null default null, + emojis varchar(300) not null default '', unique ("name", "domain"), constraint fk_actors_instance__id foreign key ("instance") references instance (id) on delete restrict on update restrict ); @@ -99,24 +116,42 @@ alter table posts_media add constraint fk_posts_media_post_id__id foreign key (post_id) references posts (id) on delete cascade on update cascade; alter table posts_media add constraint fk_posts_media_media_id__id foreign key (media_id) references media (id) on delete cascade on update cascade; + +create table if not exists posts_emojis +( + post_id bigint not null, + emoji_id bigint not null, + constraint pk_postsemoji primary key (post_id, emoji_id) +); + +alter table posts_emojis + add constraint fk_posts_emojis_post_id__id foreign key (post_id) references posts (id) on delete cascade on update cascade; +alter table posts_emojis + add constraint fk_posts_emojis_emoji_id__id foreign key (emoji_id) references emojis (id) on delete cascade on update cascade; + create table if not exists reactions ( - id bigserial primary key, - emoji_id bigint not null, - post_id bigint not null, - actor_id bigint not null + id bigint primary key, + unicode_emoji varchar(255) null default null, + custom_emoji_id bigint null default null, + post_id bigint not null, + actor_id bigint not null, + unique (post_id, actor_id) ); alter table reactions add constraint fk_reactions_post_id__id foreign key (post_id) references posts (id) on delete restrict on update restrict; alter table reactions add constraint fk_reactions_actor_id__id foreign key (actor_id) references actors (id) on delete restrict on update restrict; +alter table reactions + add constraint fk_reactions_custom_emoji_id__id foreign key (custom_emoji_id) references emojis (id) on delete cascade on update cascade; + create table if not exists timelines ( id bigint primary key, user_id bigint not null, timeline_id bigint not null, post_id bigint not null, - post_actor_id bigint not null, + post_actor_id bigint not null, created_at bigint not null, reply_id bigint null, repost_id bigint null, @@ -124,7 +159,8 @@ create table if not exists timelines "sensitive" boolean not null, is_local boolean not null, is_pure_repost boolean not null, - media_ids varchar(255) not null + media_ids varchar(255) not null, + emoji_ids varchar(255) not null ); create table if not exists application_authorization diff --git a/src/main/resources/openapi/mastodon.yaml b/src/main/resources/openapi/mastodon.yaml index 15df90d9..3a6ce32c 100644 --- a/src/main/resources/openapi/mastodon.yaml +++ b/src/main/resources/openapi/mastodon.yaml @@ -152,6 +152,79 @@ paths: schema: $ref: "#/components/schemas/Status" + /api/v1/statuses/{id}: + get: + tags: + - status + security: + - OAuth2: + - "write:statuses" + 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: + - status + security: + - OAuth2: + - "write:statuses" + parameters: + - in: path + name: id + required: true + schema: + type: string + - in: path + name: emoji + required: true + schema: + type: string + responses: + 200: + description: 成功 + content: + application/json: + schema: + $ref: "#/components/schemas/Status" + + delete: + tags: + - status + security: + - OAuth2: + - "write:statuses" + parameters: + - in: path + name: id + required: true + schema: + type: string + - in: path + name: emoji + required: true + schema: + type: string + responses: + 200: + description: 成功 + content: + application/json: + schema: + $ref: "#/components/schemas/Status" + + /api/v1/apps: post: tags: diff --git a/src/test/kotlin/dev/usbharu/hideout/EqualsAndToStringTest.kt b/src/test/kotlin/dev/usbharu/hideout/EqualsAndToStringTest.kt index 11360eb6..7d2454c1 100644 --- a/src/test/kotlin/dev/usbharu/hideout/EqualsAndToStringTest.kt +++ b/src/test/kotlin/dev/usbharu/hideout/EqualsAndToStringTest.kt @@ -3,6 +3,7 @@ package dev.usbharu.hideout import com.fasterxml.jackson.module.kotlin.isKotlinClass import com.jparams.verifier.tostring.ToStringVerifier import com.jparams.verifier.tostring.preset.Presets +import dev.usbharu.hideout.core.domain.model.emoji.UnicodeEmoji import nl.jqno.equalsverifier.EqualsVerifier import nl.jqno.equalsverifier.Warning import nl.jqno.equalsverifier.internal.reflection.PackageScanner @@ -95,6 +96,7 @@ class EqualsAndToStringTest { .filter { it.superclass == Any::class.java || it.superclass?.packageName?.startsWith("dev.usbharu") ?: true } + .filterNot { it == UnicodeEmoji::class.java } .map { dynamicTest(it.name) { diff --git a/src/test/kotlin/dev/usbharu/hideout/activitypub/domain/model/NoteSerializeTest.kt b/src/test/kotlin/dev/usbharu/hideout/activitypub/domain/model/NoteSerializeTest.kt index 9e1397a9..cfca4a97 100644 --- a/src/test/kotlin/dev/usbharu/hideout/activitypub/domain/model/NoteSerializeTest.kt +++ b/src/test/kotlin/dev/usbharu/hideout/activitypub/domain/model/NoteSerializeTest.kt @@ -3,6 +3,7 @@ package dev.usbharu.hideout.activitypub.domain.model import com.fasterxml.jackson.module.kotlin.readValue import dev.usbharu.hideout.activitypub.service.objects.note.APNoteServiceImpl.Companion.public import dev.usbharu.hideout.application.config.ActivityPubConfig +import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test @@ -51,11 +52,7 @@ class NoteSerializeTest { "attachment": [], "sensitive": false, "tag": [ - { - "type": "Mention", - "href": "https://calckey.jp/users/9bu1xzwjyb", - "name": "@trapezial@calckey.jp" - } + ] }""" @@ -77,4 +74,105 @@ class NoteSerializeTest { ) assertEquals(note, readValue) } + + @Test + fun 絵文字付きNoteのデシリアライズができる() { + val json = """{ + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1", + { + "manuallyApprovesFollowers": "as:manuallyApprovesFollowers", + "sensitive": "as:sensitive", + "Hashtag": "as:Hashtag", + "quoteUrl": "as:quoteUrl", + "toot": "http://joinmastodon.org/ns#", + "Emoji": "toot:Emoji", + "featured": "toot:featured", + "discoverable": "toot:discoverable", + "schema": "http://schema.org#", + "PropertyValue": "schema:PropertyValue", + "value": "schema:value", + "misskey": "https://misskey-hub.net/ns#", + "_misskey_content": "misskey:_misskey_content", + "_misskey_quote": "misskey:_misskey_quote", + "_misskey_reaction": "misskey:_misskey_reaction", + "_misskey_votes": "misskey:_misskey_votes", + "_misskey_summary": "misskey:_misskey_summary", + "isCat": "misskey:isCat", + "vcard": "http://www.w3.org/2006/vcard/ns#" + } + ], + "id": "https://misskey.usbharu.dev/notes/9nj1omt1rn", + "type": "Note", + "attributedTo": "https://misskey.usbharu.dev/users/97ws8y3rj6", + "content": "

​:oyasumi:​

", + "_misskey_content": ":oyasumi:", + "source": { + "content": ":oyasumi:", + "mediaType": "text/x.misskeymarkdown" + }, + "published": "2023-12-21T17:32:36.853Z", + "to": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "cc": [ + "https://misskey.usbharu.dev/users/97ws8y3rj6/followers" + ], + "inReplyTo": null, + "attachment": [], + "sensitive": false, + "tag": [ + { + "id": "https://misskey.usbharu.dev/emojis/oyasumi", + "type": "Emoji", + "name": ":oyasumi:", + "updated": "2023-04-07T08:21:25.246Z", + "icon": { + "type": "Image", + "mediaType": "image/png", + "url": "https://s3misskey.usbharu.dev/misskey-minio/misskey-minio/data/cf8db710-1d70-4076-8a00-dbb28131096e.png" + } + } + ] +}""" + + + val objectMapper = ActivityPubConfig().objectMapper() + + val expected = Note( + type = emptyList(), + id = "https://misskey.usbharu.dev/notes/9nj1omt1rn", + attributedTo = "https://misskey.usbharu.dev/users/97ws8y3rj6", + content = "

\u200B:oyasumi:\u200B

", + published = "2023-12-21T17:32:36.853Z", + to = listOf("https://www.w3.org/ns/activitystreams#Public"), + cc = listOf("https://misskey.usbharu.dev/users/97ws8y3rj6/followers"), + sensitive = false, + inReplyTo = null, + attachment = emptyList(), + tag = listOf( + Emoji( + type = emptyList(), + name = ":oyasumi:", + id = "https://misskey.usbharu.dev/emojis/oyasumi", + updated = "2023-04-07T08:21:25.246Z", + icon = Image( + type = emptyList(), + mediaType = "image/png", + url = "https://s3misskey.usbharu.dev/misskey-minio/misskey-minio/data/cf8db710-1d70-4076-8a00-dbb28131096e.png" + ) + ) + ) + ) + + expected.context = listOf( + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1" + ) + + val note = objectMapper.readValue(json) + + assertThat(note).isEqualTo(expected) + } } diff --git a/src/test/kotlin/dev/usbharu/hideout/activitypub/service/activity/like/APReactionServiceImplTest.kt b/src/test/kotlin/dev/usbharu/hideout/activitypub/service/activity/like/APReactionServiceImplTest.kt index 716762b3..b6327fab 100644 --- a/src/test/kotlin/dev/usbharu/hideout/activitypub/service/activity/like/APReactionServiceImplTest.kt +++ b/src/test/kotlin/dev/usbharu/hideout/activitypub/service/activity/like/APReactionServiceImplTest.kt @@ -3,6 +3,7 @@ package dev.usbharu.hideout.activitypub.service.activity.like import dev.usbharu.hideout.application.service.id.TwitterSnowflakeIdGenerateService import dev.usbharu.hideout.core.domain.model.actor.ActorRepository +import dev.usbharu.hideout.core.domain.model.emoji.UnicodeEmoji import dev.usbharu.hideout.core.domain.model.post.PostRepository import dev.usbharu.hideout.core.domain.model.reaction.Reaction import dev.usbharu.hideout.core.external.job.DeliverReactionJob @@ -48,7 +49,7 @@ class APReactionServiceImplTest { apReactionServiceImpl.reaction( Reaction( id = TwitterSnowflakeIdGenerateService.generateId(), - emojiId = 0, + emoji = UnicodeEmoji("❤"), postId = post.id, actorId = user.id ) @@ -88,7 +89,7 @@ class APReactionServiceImplTest { apReactionServiceImpl.removeReaction( Reaction( id = TwitterSnowflakeIdGenerateService.generateId(), - emojiId = 0, + emoji = UnicodeEmoji("❤"), postId = post.id, actorId = user.id ) diff --git a/src/test/kotlin/dev/usbharu/hideout/activitypub/service/objects/note/APNoteServiceImplTest.kt b/src/test/kotlin/dev/usbharu/hideout/activitypub/service/objects/note/APNoteServiceImplTest.kt index cece7a5b..e5e1c957 100644 --- a/src/test/kotlin/dev/usbharu/hideout/activitypub/service/objects/note/APNoteServiceImplTest.kt +++ b/src/test/kotlin/dev/usbharu/hideout/activitypub/service/objects/note/APNoteServiceImplTest.kt @@ -73,6 +73,7 @@ class APNoteServiceImplTest { apResourceResolveService = mock(), postBuilder = Post.PostBuilder(CharacterLimit()), noteQueryService = noteQueryService, + mock(), mock() ) @@ -142,7 +143,8 @@ class APNoteServiceImplTest { apResourceResolveService = apResourceResolveService, postBuilder = Post.PostBuilder(CharacterLimit()), noteQueryService = noteQueryService, - mock() + mock(), + mock { } ) val actual = apNoteServiceImpl.fetchNote(url) @@ -190,6 +192,7 @@ class APNoteServiceImplTest { apResourceResolveService = apResourceResolveService, postBuilder = Post.PostBuilder(CharacterLimit()), noteQueryService = noteQueryService, + mock(), mock() ) @@ -240,6 +243,7 @@ class APNoteServiceImplTest { apResourceResolveService = mock(), postBuilder = postBuilder, noteQueryService = noteQueryService, + mock(), mock() ) @@ -292,6 +296,7 @@ class APNoteServiceImplTest { apResourceResolveService = mock(), postBuilder = postBuilder, noteQueryService = noteQueryService, + mock(), mock() ) diff --git a/src/test/kotlin/dev/usbharu/hideout/core/service/reaction/ReactionServiceImplTest.kt b/src/test/kotlin/dev/usbharu/hideout/core/service/reaction/ReactionServiceImplTest.kt index 8dc8f5ec..f3863d64 100644 --- a/src/test/kotlin/dev/usbharu/hideout/core/service/reaction/ReactionServiceImplTest.kt +++ b/src/test/kotlin/dev/usbharu/hideout/core/service/reaction/ReactionServiceImplTest.kt @@ -3,10 +3,10 @@ package dev.usbharu.hideout.core.service.reaction import dev.usbharu.hideout.activitypub.service.activity.like.APReactionService import dev.usbharu.hideout.application.service.id.TwitterSnowflakeIdGenerateService +import dev.usbharu.hideout.core.domain.model.emoji.UnicodeEmoji import dev.usbharu.hideout.core.domain.model.reaction.Reaction import dev.usbharu.hideout.core.domain.model.reaction.ReactionRepository import kotlinx.coroutines.test.runTest -import org.jetbrains.exposed.exceptions.ExposedSQLException import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith import org.mockito.InjectMocks @@ -32,59 +32,33 @@ class ReactionServiceImplTest { val post = PostBuilder.of() - whenever(reactionRepository.existByPostIdAndActorIdAndEmojiId(eq(post.id), eq(post.actorId), eq(0))).doReturn( + whenever(reactionRepository.existByPostIdAndActor(eq(post.id), eq(post.actorId))).doReturn( false ) val generateId = TwitterSnowflakeIdGenerateService.generateId() whenever(reactionRepository.generateId()).doReturn(generateId) - reactionServiceImpl.receiveReaction("❤", "example.com", post.actorId, post.id) + reactionServiceImpl.receiveReaction(UnicodeEmoji("❤"), post.actorId, post.id) - verify(reactionRepository, times(1)).save(eq(Reaction(generateId, 0, post.id, post.actorId))) + verify(reactionRepository, times(1)).save(eq(Reaction(generateId, UnicodeEmoji("❤"), post.id, post.actorId))) } - @Test - fun `receiveReaction リアクションが既に作成されていることを検知出来ずに例外が発生した場合は何もしない`() = runTest { - val post = PostBuilder.of() - - whenever(reactionRepository.existByPostIdAndActorIdAndEmojiId(eq(post.id), eq(post.actorId), eq(0))).doReturn( - false - ) - val generateId = TwitterSnowflakeIdGenerateService.generateId() - whenever( - reactionRepository.save( - eq( - Reaction( - id = generateId, - emojiId = 0, - postId = post.id, - actorId = post.actorId - ) - ) - ) - ).doAnswer { - throw ExposedSQLException( - null, - emptyList(), mock() - ) - } - whenever(reactionRepository.generateId()).doReturn(generateId) - - reactionServiceImpl.receiveReaction("❤", "example.com", post.actorId, post.id) - - verify(reactionRepository, times(1)).save(eq(Reaction(generateId, 0, post.id, post.actorId))) - } @Test - fun `receiveReaction リアクションが既に作成されている場合は何もしない`() = runTest() { + fun `receiveReaction リアクションが既に作成されている場合削除して新しく作成`() = runTest() { val post = PostBuilder.of() - whenever(reactionRepository.existByPostIdAndActorIdAndEmojiId(eq(post.id), eq(post.actorId), eq(0))).doReturn( + whenever(reactionRepository.existByPostIdAndActor(eq(post.id), eq(post.actorId))).doReturn( true ) - reactionServiceImpl.receiveReaction("❤", "example.com", post.actorId, post.id) + val generateId = TwitterSnowflakeIdGenerateService.generateId() - verify(reactionRepository, never()).save(any()) + whenever(reactionRepository.generateId()).doReturn(generateId) + + reactionServiceImpl.receiveReaction(UnicodeEmoji("❤"), post.actorId, post.id) + + verify(reactionRepository, times(1)).deleteByPostIdAndActorId(post.id, post.actorId) + verify(reactionRepository, times(1)).save(Reaction(generateId, UnicodeEmoji("❤"), post.id, post.actorId)) } @Test @@ -96,10 +70,10 @@ class ReactionServiceImplTest { val generateId = TwitterSnowflakeIdGenerateService.generateId() whenever(reactionRepository.generateId()).doReturn(generateId) - reactionServiceImpl.sendReaction("❤", post.actorId, post.id) + reactionServiceImpl.sendReaction(UnicodeEmoji("❤"), post.actorId, post.id) - verify(reactionRepository, times(1)).save(eq(Reaction(generateId, 0, post.id, post.actorId))) - verify(apReactionService, times(1)).reaction(eq(Reaction(generateId, 0, post.id, post.actorId))) + verify(reactionRepository, times(1)).save(eq(Reaction(generateId, UnicodeEmoji("❤"), post.id, post.actorId))) + verify(apReactionService, times(1)).reaction(eq(Reaction(generateId, UnicodeEmoji("❤"), post.id, post.actorId))) } @Test @@ -107,30 +81,30 @@ class ReactionServiceImplTest { val post = PostBuilder.of() val id = TwitterSnowflakeIdGenerateService.generateId() whenever(reactionRepository.findByPostIdAndActorIdAndEmojiId(eq(post.id), eq(post.actorId), eq(0))).doReturn( - Reaction(id, 0, post.id, post.actorId) + Reaction(id, UnicodeEmoji("❤"), post.id, post.actorId) ) val generateId = TwitterSnowflakeIdGenerateService.generateId() whenever(reactionRepository.generateId()).doReturn(generateId) - reactionServiceImpl.sendReaction("❤", post.actorId, post.id) + reactionServiceImpl.sendReaction(UnicodeEmoji("❤"), post.actorId, post.id) - verify(reactionRepository, times(1)).delete(eq(Reaction(id, 0, post.id, post.actorId))) - verify(reactionRepository, times(1)).save(eq(Reaction(generateId, 0, post.id, post.actorId))) - verify(apReactionService, times(1)).removeReaction(eq(Reaction(id, 0, post.id, post.actorId))) - verify(apReactionService, times(1)).reaction(eq(Reaction(generateId, 0, post.id, post.actorId))) + verify(reactionRepository, times(1)).delete(eq(Reaction(id, UnicodeEmoji("❤"), post.id, post.actorId))) + verify(reactionRepository, times(1)).save(eq(Reaction(generateId, UnicodeEmoji("❤"), post.id, post.actorId))) + verify(apReactionService, times(1)).removeReaction(eq(Reaction(id, UnicodeEmoji("❤"), post.id, post.actorId))) + verify(apReactionService, times(1)).reaction(eq(Reaction(generateId, UnicodeEmoji("❤"), post.id, post.actorId))) } @Test fun `removeReaction リアクションが存在する場合削除して配送`() = runTest { val post = PostBuilder.of() whenever(reactionRepository.findByPostIdAndActorIdAndEmojiId(eq(post.id), eq(post.actorId), eq(0))).doReturn( - Reaction(0, 0, post.id, post.actorId) + Reaction(0, UnicodeEmoji("❤"), post.id, post.actorId) ) reactionServiceImpl.removeReaction(post.actorId, post.id) - verify(reactionRepository, times(1)).delete(eq(Reaction(0, 0, post.id, post.actorId))) - verify(apReactionService, times(1)).removeReaction(eq(Reaction(0, 0, post.id, post.actorId))) + verify(reactionRepository, times(1)).delete(eq(Reaction(0, UnicodeEmoji("❤"), post.id, post.actorId))) + verify(apReactionService, times(1)).removeReaction(eq(Reaction(0, UnicodeEmoji("❤"), post.id, post.actorId))) } } diff --git a/src/test/kotlin/dev/usbharu/hideout/util/EmojiUtilTest.kt b/src/test/kotlin/dev/usbharu/hideout/util/EmojiUtilTest.kt new file mode 100644 index 00000000..a34b8aa2 --- /dev/null +++ b/src/test/kotlin/dev/usbharu/hideout/util/EmojiUtilTest.kt @@ -0,0 +1,41 @@ +package dev.usbharu.hideout.util + +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource + +class EmojiUtilTest { + + @Test + fun 絵文字を判定できる() { + val emoji = "❤" + val actual = EmojiUtil.isEmoji(emoji) + + assertThat(actual).isTrue() + } + + @Test + fun ただの文字を判定できる() { + val moji = "blobblinkhyper" + val actual = EmojiUtil.isEmoji(moji) + + assertThat(actual).isFalse() + } + + @ParameterizedTest + @ValueSource(strings = ["❤", "🌄", "🤗", "⛺", "🧑‍🤝‍🧑", "🖐🏿"]) + fun `絵文字判定`(s: String) { + val actual = EmojiUtil.isEmoji(s) + + assertThat(actual).isTrue() + } + + @ParameterizedTest + @ValueSource(strings = ["™", "㍂", "㌠"]) + fun `文字判定`(s: String) { + val actual = EmojiUtil.isEmoji(s) + + assertThat(actual).isFalse() + } +}