Merge pull request #591 from usbharu/follow

簡易クライアントからフォローできるように
This commit is contained in:
usbharu 2024-09-07 23:33:42 +09:00 committed by GitHub
commit 6c83306f0d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 303 additions and 25 deletions

View File

@ -5,24 +5,20 @@ import dev.usbharu.hideout.core.application.timeline.AddTimelineRelationship
import dev.usbharu.hideout.core.application.timeline.UserAddTimelineRelationshipApplicationService
import dev.usbharu.hideout.core.domain.event.relationship.RelationshipEvent
import dev.usbharu.hideout.core.domain.event.relationship.RelationshipEventBody
import dev.usbharu.hideout.core.domain.model.timelinerelationship.TimelineRelationship
import dev.usbharu.hideout.core.domain.model.timelinerelationship.TimelineRelationshipId
import dev.usbharu.hideout.core.domain.model.timelinerelationship.Visible
import dev.usbharu.hideout.core.domain.model.userdetails.UserDetailRepository
import dev.usbharu.hideout.core.domain.shared.id.IdGenerateService
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Component
@Component
class TimelineRelationshipFollowSubscriber(
private val userAddTimelineRelationshipApplicationService: UserAddTimelineRelationshipApplicationService,
private val idGenerateService: IdGenerateService,
private val userDetailRepository: UserDetailRepository,
private val domainEventSubscriber: DomainEventSubscriber
) : Subscriber {
override fun init() {
domainEventSubscriber.subscribe<RelationshipEventBody>(RelationshipEvent.FOLLOW.eventName) {
domainEventSubscriber.subscribe<RelationshipEventBody>(RelationshipEvent.ACCEPT_FOLLOW.eventName) {
val relationship = it.body.getRelationship()
val userDetail = userDetailRepository.findByActorId(relationship.actorId.id)
?: throw InternalServerException("Userdetail ${relationship.actorId} not found by actorid.")
@ -34,12 +30,9 @@ class TimelineRelationshipFollowSubscriber(
@Suppress("UnsafeCallOnNullableType")
userAddTimelineRelationshipApplicationService.execute(
AddTimelineRelationship(
TimelineRelationship(
TimelineRelationshipId(idGenerateService.generateId()),
userDetail.homeTimelineId!!,
relationship.targetActorId,
Visible.FOLLOWERS
)
userDetail.homeTimelineId!!,
relationship.targetActorId,
Visible.FOLLOWERS
),
it.body.principal
)

View File

@ -0,0 +1,46 @@
package dev.usbharu.hideout.core.application.domainevent.subscribers
import dev.usbharu.hideout.core.application.timeline.RemoveTimelineRelationship
import dev.usbharu.hideout.core.application.timeline.UserRemoveTimelineRelationshipApplicationService
import dev.usbharu.hideout.core.domain.event.relationship.RelationshipEvent
import dev.usbharu.hideout.core.domain.event.relationship.RelationshipEventBody
import dev.usbharu.hideout.core.domain.model.timelinerelationship.TimelineRelationshipRepository
import dev.usbharu.hideout.core.domain.model.userdetails.UserDetailRepository
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Component
@Component
class TimelineRelationshipUnfollowSubscriber(
private val domainEventSubscriber: DomainEventSubscriber,
private val userRemoveTimelineRelationshipApplicationService: UserRemoveTimelineRelationshipApplicationService,
private val userDetailRepository: UserDetailRepository,
private val timelineRelationshipRepository: TimelineRelationshipRepository,
) : Subscriber {
override fun init() {
domainEventSubscriber.subscribe<RelationshipEventBody>(RelationshipEvent.UNFOLLOW.eventName) {
val relationship = it.body.getRelationship()
val userDetail = userDetailRepository.findByActorId(relationship.actorId.id) ?: throw IllegalStateException(
"UserDetail ${relationship.actorId} not found by actorId."
)
if (userDetail.homeTimelineId == null) {
logger.warn("HomeTimeline for ${userDetail.id} not found.")
return@subscribe
}
val timelineRelationship = timelineRelationshipRepository.findByTimelineIdAndActorId(
userDetail.homeTimelineId!!,
relationship.targetActorId
)
?: throw IllegalStateException("TimelineRelationship ${userDetail.homeTimelineId} to ${relationship.targetActorId} not found by timelineId and ActorId")
userRemoveTimelineRelationshipApplicationService.execute(
RemoveTimelineRelationship(timelineRelationship.id),
it.body.principal
)
}
}
companion object {
private val logger = LoggerFactory.getLogger(TimelineRelationshipUnfollowSubscriber::class.java)
}
}

View File

@ -0,0 +1,23 @@
package dev.usbharu.hideout.core.application.model
import dev.usbharu.hideout.core.domain.model.timeline.TimelineVisibility
data class Timeline(
val id: Long,
val userDetailId: Long,
val name: String,
val visibility: TimelineVisibility,
val isSystem: Boolean
) {
companion object {
fun of(timeline: dev.usbharu.hideout.core.domain.model.timeline.Timeline): Timeline {
return Timeline(
timeline.id.value,
timeline.userDetailId.id,
timeline.name.value,
timeline.visibility,
timeline.isSystem
)
}
}
}

View File

@ -1,7 +1,11 @@
package dev.usbharu.hideout.core.application.timeline
import dev.usbharu.hideout.core.domain.model.timelinerelationship.TimelineRelationship
import dev.usbharu.hideout.core.domain.model.actor.ActorId
import dev.usbharu.hideout.core.domain.model.timeline.TimelineId
import dev.usbharu.hideout.core.domain.model.timelinerelationship.Visible
data class AddTimelineRelationship(
val timelineRelationship: TimelineRelationship
val timelineId: TimelineId,
val actorId: ActorId,
val visible: Visible
)

View File

@ -0,0 +1,3 @@
package dev.usbharu.hideout.core.application.timeline
data class GetTimelines(val userDetailId: Long)

View File

@ -0,0 +1,5 @@
package dev.usbharu.hideout.core.application.timeline
import dev.usbharu.hideout.core.domain.model.timelinerelationship.TimelineRelationshipId
data class RemoveTimelineRelationship(val timelineRelationshipId: TimelineRelationshipId)

View File

@ -1,15 +1,22 @@
package dev.usbharu.hideout.core.application.timeline
import dev.usbharu.hideout.core.application.exception.PermissionDeniedException
import dev.usbharu.hideout.core.application.shared.LocalUserAbstractApplicationService
import dev.usbharu.hideout.core.application.shared.Transaction
import dev.usbharu.hideout.core.domain.model.support.principal.LocalUser
import dev.usbharu.hideout.core.domain.model.timeline.TimelineRepository
import dev.usbharu.hideout.core.domain.model.timelinerelationship.TimelineRelationship
import dev.usbharu.hideout.core.domain.model.timelinerelationship.TimelineRelationshipId
import dev.usbharu.hideout.core.domain.model.timelinerelationship.TimelineRelationshipRepository
import dev.usbharu.hideout.core.domain.shared.id.IdGenerateService
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
@Service
class UserAddTimelineRelationshipApplicationService(
private val timelineRelationshipRepository: TimelineRelationshipRepository,
private val timelineRepository: TimelineRepository,
private val idGenerateService: IdGenerateService,
transaction: Transaction
) :
LocalUserAbstractApplicationService<AddTimelineRelationship, Unit>(
@ -17,7 +24,21 @@ class UserAddTimelineRelationshipApplicationService(
logger
) {
override suspend fun internalExecute(command: AddTimelineRelationship, principal: LocalUser) {
timelineRelationshipRepository.save(command.timelineRelationship)
val timeline = timelineRepository.findById(command.timelineId)
?: throw IllegalArgumentException("Timeline ${command.timelineId} not found.")
if (timeline.userDetailId != principal.userDetailId) {
throw PermissionDeniedException()
}
timelineRelationshipRepository.save(
TimelineRelationship(
TimelineRelationshipId(idGenerateService.generateId()),
command.timelineId,
command.actorId,
command.visible
)
)
}
companion object {

View File

@ -0,0 +1,37 @@
package dev.usbharu.hideout.core.application.timeline
import dev.usbharu.hideout.core.application.model.Timeline
import dev.usbharu.hideout.core.application.shared.AbstractApplicationService
import dev.usbharu.hideout.core.application.shared.Transaction
import dev.usbharu.hideout.core.domain.model.support.principal.Principal
import dev.usbharu.hideout.core.domain.model.timeline.TimelineRepository
import dev.usbharu.hideout.core.domain.model.timeline.TimelineVisibility
import dev.usbharu.hideout.core.domain.model.userdetails.UserDetailId
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
@Service
class UserGetTimelinesApplicationService(transaction: Transaction, private val timelineRepository: TimelineRepository) :
AbstractApplicationService<GetTimelines, List<Timeline>>(
transaction,
logger
) {
override suspend fun internalExecute(command: GetTimelines, principal: Principal): List<Timeline> {
val userDetailId = UserDetailId(command.userDetailId)
val timelineVisibility = if (userDetailId == principal.userDetailId) {
listOf(TimelineVisibility.PUBLIC, TimelineVisibility.UNLISTED, TimelineVisibility.PRIVATE)
} else {
listOf(TimelineVisibility.PUBLIC)
}
val timelineList =
timelineRepository.findAllByUserDetailIdAndVisibilityIn(userDetailId, timelineVisibility)
return timelineList.map { Timeline.of(it) }
}
companion object {
private val logger = LoggerFactory.getLogger(UserGetTimelinesApplicationService::class.java)
}
}

View File

@ -0,0 +1,44 @@
package dev.usbharu.hideout.core.application.timeline
import dev.usbharu.hideout.core.application.exception.PermissionDeniedException
import dev.usbharu.hideout.core.application.shared.LocalUserAbstractApplicationService
import dev.usbharu.hideout.core.application.shared.Transaction
import dev.usbharu.hideout.core.domain.model.support.principal.LocalUser
import dev.usbharu.hideout.core.domain.model.timeline.TimelineRepository
import dev.usbharu.hideout.core.domain.model.timelinerelationship.TimelineRelationshipRepository
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
@Service
class UserRemoveTimelineRelationshipApplicationService(
transaction: Transaction,
private val timelineRelationshipRepository: TimelineRelationshipRepository,
private val timelineRepository: TimelineRepository
) :
LocalUserAbstractApplicationService<RemoveTimelineRelationship, Unit>(
transaction,
logger
) {
override suspend fun internalExecute(command: RemoveTimelineRelationship, principal: LocalUser) {
val timelineRelationship = (
timelineRelationshipRepository.findById(command.timelineRelationshipId)
?: throw IllegalArgumentException("TimelineRelationship ${command.timelineRelationshipId} not found.")
)
val timeline = (
timelineRepository.findById(timelineRelationship.timelineId)
?: throw IllegalArgumentException("Timeline ${timelineRelationship.timelineId} not found.")
)
if (timeline.userDetailId != principal.userDetailId) {
throw PermissionDeniedException()
}
timelineRelationshipRepository.delete(timelineRelationship)
}
companion object {
private val logger = LoggerFactory.getLogger(UserRemoveTimelineRelationshipApplicationService::class.java)
}
}

View File

@ -35,7 +35,8 @@ class RelationshipEventBody(
}
enum class RelationshipEvent(val eventName: String) {
FOLLOW("RelationshipFollow"),
ACCEPT_FOLLOW("RelationshipFollow"),
REJECT_FOLLOW("RelationshipRejectFollow"),
UNFOLLOW("RelationshipUnfollow"),
BLOCK("RelationshipBlock"),
UNBLOCK("RelationshipUnblock"),

View File

@ -44,15 +44,12 @@ class Relationship(
var mutingFollowRequest: Boolean = mutingFollowRequest
private set
fun follow() {
require(blocking.not())
following = true
addDomainEvent(RelationshipEventFactory(this).createEvent(RelationshipEvent.FOLLOW))
}
fun unfollow() {
following = false
addDomainEvent(RelationshipEventFactory(this).createEvent(RelationshipEvent.UNFOLLOW))
followRequesting = false
val relationshipEventFactory = RelationshipEventFactory(this)
addDomainEvent(relationshipEventFactory.createEvent(RelationshipEvent.UNFOLLOW))
addDomainEvent(relationshipEventFactory.createEvent(RelationshipEvent.UNFOLLOW_REQUEST))
}
fun block() {
@ -96,11 +93,15 @@ class Relationship(
}
fun acceptFollowRequest() {
follow()
require(blocking.not())
following = true
addDomainEvent(RelationshipEventFactory(this).createEvent(RelationshipEvent.ACCEPT_FOLLOW))
followRequesting = false
}
fun rejectFollowRequest() {
require(followRequesting)
addDomainEvent(RelationshipEventFactory(this).createEvent(RelationshipEvent.REJECT_FOLLOW))
followRequesting = false
}

View File

@ -1,5 +1,7 @@
package dev.usbharu.hideout.core.domain.model.timeline
import dev.usbharu.hideout.core.domain.model.userdetails.UserDetailId
interface TimelineRepository {
suspend fun save(timeline: Timeline): Timeline
suspend fun delete(timeline: Timeline)
@ -7,4 +9,8 @@ interface TimelineRepository {
suspend fun findByIds(ids: List<TimelineId>): List<Timeline>
suspend fun findById(id: TimelineId): Timeline?
suspend fun findAllByUserDetailIdAndVisibilityIn(
userDetailId: UserDetailId,
visibility: List<TimelineVisibility>
): List<Timeline>
}

View File

@ -8,7 +8,20 @@ class TimelineRelationship(
val timelineId: TimelineId,
val actorId: ActorId,
val visible: Visible
)
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as TimelineRelationship
return id == other.id
}
override fun hashCode(): Int {
return id.hashCode()
}
}
enum class Visible {
PUBLIC,

View File

@ -1,10 +1,13 @@
package dev.usbharu.hideout.core.domain.model.timelinerelationship
import dev.usbharu.hideout.core.domain.model.actor.ActorId
import dev.usbharu.hideout.core.domain.model.timeline.TimelineId
interface TimelineRelationshipRepository {
suspend fun save(timelineRelationship: TimelineRelationship): TimelineRelationship
suspend fun delete(timelineRelationship: TimelineRelationship)
suspend fun findByActorId(actorId: ActorId): List<TimelineRelationship>
suspend fun findById(timelineRelationshipId: TimelineRelationshipId): TimelineRelationship?
suspend fun findByTimelineIdAndActorId(timelineId: TimelineId, actorId: ActorId): TimelineRelationship?
}

View File

@ -45,6 +45,22 @@ class ExposedTimelineRelationshipRepository : AbstractRepository(), TimelineRela
}
}
override suspend fun findById(timelineRelationshipId: TimelineRelationshipId): TimelineRelationship? {
return query {
TimelineRelationships.selectAll().where {
TimelineRelationships.id eq timelineRelationshipId.value
}.singleOrNull()?.toTimelineRelationship()
}
}
override suspend fun findByTimelineIdAndActorId(timelineId: TimelineId, actorId: ActorId): TimelineRelationship? {
return query {
TimelineRelationships.selectAll().where {
TimelineRelationships.timelineId eq timelineId.value and (TimelineRelationships.actorId eq actorId.id)
}.singleOrNull()?.toTimelineRelationship()
}
}
companion object {
private val logger = LoggerFactory.getLogger(ExposedTimelineRelationshipRepository::class.java)
}

View File

@ -58,6 +58,17 @@ class ExposedTimelineRepository(override val domainEventPublisher: DomainEventPu
}
}
override suspend fun findAllByUserDetailIdAndVisibilityIn(
userDetailId: UserDetailId,
visibility: List<TimelineVisibility>
): List<Timeline> {
return query {
Timelines.selectAll().where {
Timelines.userDetailId eq userDetailId.id and (Timelines.visibility inList visibility.map { it.name })
}.map { it.toTimeline() }
}
}
companion object {
private val logger = LoggerFactory.getLogger(ExposedTimelineRepository::class.java.name)
}

View File

@ -3,16 +3,24 @@ package dev.usbharu.hideout.core.interfaces.web.user
import dev.usbharu.hideout.core.application.actor.GetActorDetail
import dev.usbharu.hideout.core.application.actor.GetActorDetailApplicationService
import dev.usbharu.hideout.core.application.instance.GetLocalInstanceApplicationService
import dev.usbharu.hideout.core.application.relationship.followrequest.FollowRequest
import dev.usbharu.hideout.core.application.relationship.followrequest.UserFollowRequestApplicationService
import dev.usbharu.hideout.core.application.relationship.get.GetRelationship
import dev.usbharu.hideout.core.application.relationship.get.GetRelationshipApplicationService
import dev.usbharu.hideout.core.application.relationship.unfollow.Unfollow
import dev.usbharu.hideout.core.application.relationship.unfollow.UserUnfollowApplicationService
import dev.usbharu.hideout.core.application.timeline.GetUserTimeline
import dev.usbharu.hideout.core.application.timeline.GetUserTimelineApplicationService
import dev.usbharu.hideout.core.domain.model.support.acct.Acct
import dev.usbharu.hideout.core.domain.model.support.page.Page
import dev.usbharu.hideout.core.domain.model.support.principal.Anonymous
import dev.usbharu.hideout.core.domain.model.support.principal.LocalUser
import dev.usbharu.hideout.core.infrastructure.springframework.SpringSecurityFormLoginPrincipalContextHolder
import org.springframework.stereotype.Controller
import org.springframework.ui.Model
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestParam
@Controller
@ -20,7 +28,11 @@ class UserController(
private val getLocalInstanceApplicationService: GetLocalInstanceApplicationService,
private val getUserDetailApplicationService: GetActorDetailApplicationService,
private val springSecurityFormLoginPrincipalContextHolder: SpringSecurityFormLoginPrincipalContextHolder,
private val getUserTimelineApplicationService: GetUserTimelineApplicationService
private val getUserTimelineApplicationService: GetUserTimelineApplicationService,
private val userFollowRequestApplicationService: UserFollowRequestApplicationService,
private val getActorDetailApplicationService: GetActorDetailApplicationService,
private val userUnfollowApplicationService: UserUnfollowApplicationService,
private val userGetRelationshipApplicationService: GetRelationshipApplicationService
) {
@GetMapping("/users/{name}")
suspend fun userById(
@ -38,6 +50,14 @@ class UserController(
"user",
actorDetail
)
val relationship =
if (principal is LocalUser) {
userGetRelationshipApplicationService.execute(GetRelationship(actorDetail.id), principal)
} else {
null
}
model.addAttribute("relationship", relationship)
model.addAttribute(
"userTimeline",
getUserTimelineApplicationService.execute(
@ -50,4 +70,24 @@ class UserController(
)
return "userById"
}
@PostMapping("/users/{name}/follow")
suspend fun follow(@PathVariable name: String): String {
val principal = springSecurityFormLoginPrincipalContextHolder.getPrincipal()
val actorDetail = getActorDetailApplicationService.execute(GetActorDetail(Acct.of(name), null), principal)
userFollowRequestApplicationService.execute(FollowRequest((actorDetail.id)), principal)
return "redirect:/users/$name"
}
@PostMapping("/users/{name}/unfollow")
suspend fun unfollow(@PathVariable name: String): String {
val principal = springSecurityFormLoginPrincipalContextHolder.getPrincipal()
val actorDetail = getActorDetailApplicationService.execute(GetActorDetail(Acct.of(name), null), principal)
userUnfollowApplicationService.execute(Unfollow((actorDetail.id)), principal)
return "redirect:/users/$name"
}
}

View File

@ -19,6 +19,17 @@
<h2 th:text="${user.screenName}"></h2>
</th:block>
<p th:text="'@'+${user.name} + '@' + ${user.host}"></p>
<th:block th:if="${relationship != null}">
<form method="post" th:action="@{/users/{name}/unfollow(name=${user.name+'@'+user.host})}"
th:if="${relationship.following}">
<input type="submit" value="Unfollow">
</form>
<form method="post" th:action="@{/users/{name}/follow(name=${user.name+'@'+user.host})}"
th:unless="${relationship.following}">
<input type="submit" value="Follow">
</form>
</th:block>
</div>
<div>
<p th:text="${user.description}"></p>