structural refactor and codebase improvements
This commit is contained in:
@@ -38,3 +38,23 @@ pub struct FederatedProfile {
|
||||
pub avatar_url: Option<String>,
|
||||
pub banner_url: Option<String>,
|
||||
}
|
||||
|
||||
/// A domain-blocklist entry as presented to admin surfaces. Mirrors what the
|
||||
/// federation adapter can supply; `blocked_at` is a pre-formatted string
|
||||
/// because that is what the underlying store returns and every consumer
|
||||
/// renders it verbatim.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BlockedDomainInfo {
|
||||
pub domain: String,
|
||||
pub reason: Option<String>,
|
||||
pub blocked_at: String,
|
||||
}
|
||||
|
||||
/// A remote actor the local instance follows, reduced to the two fields any
|
||||
/// consumer actually reads. Deliberately narrower than the federation
|
||||
/// library's actor type — widening it is a decision, not an oversight.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FollowedActorInfo {
|
||||
pub url: String,
|
||||
pub outbox_url: Option<String>,
|
||||
}
|
||||
|
||||
103
crates/domain/src/ports/federation.rs
Normal file
103
crates/domain/src/ports/federation.rs
Normal file
@@ -0,0 +1,103 @@
|
||||
use async_trait::async_trait;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
errors::DomainError,
|
||||
models::{BlockedDomainInfo, FollowedActorInfo},
|
||||
};
|
||||
|
||||
/// Serves ActivityPub documents for content negotiation. Presentation calls
|
||||
/// this when a peer sends `Accept: application/activity+json`.
|
||||
#[async_trait]
|
||||
pub trait ApDocumentPort: Send + Sync {
|
||||
async fn actor_json(&self, user_id: &str) -> Result<String, DomainError>;
|
||||
async fn followers_collection_json(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
page: Option<u32>,
|
||||
) -> Result<String, DomainError>;
|
||||
async fn following_collection_json(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
page: Option<u32>,
|
||||
) -> Result<String, DomainError>;
|
||||
}
|
||||
|
||||
/// Instance-wide domain blocklist administration. Presentation calls this from
|
||||
/// the admin API and the admin HTML pages; nothing outside presentation does.
|
||||
#[async_trait]
|
||||
pub trait InstanceBlocklistPort: Send + Sync {
|
||||
async fn get_blocked_domains(&self) -> Result<Vec<BlockedDomainInfo>, DomainError>;
|
||||
async fn add_blocked_domain(
|
||||
&self,
|
||||
domain: &str,
|
||||
reason: Option<&str>,
|
||||
) -> Result<(), DomainError>;
|
||||
async fn remove_blocked_domain(&self, domain: &str) -> Result<(), DomainError>;
|
||||
}
|
||||
|
||||
/// Pulling remote content in, and pushing local content out, after a follow
|
||||
/// is established. Worker-side only — no HTTP handler calls this.
|
||||
#[async_trait]
|
||||
pub trait ApBackfillPort: Send + Sync {
|
||||
async fn get_following(
|
||||
&self,
|
||||
local_user_id: Uuid,
|
||||
) -> Result<Vec<FollowedActorInfo>, DomainError>;
|
||||
async fn import_remote_outbox(
|
||||
&self,
|
||||
outbox_url: &str,
|
||||
actor_url: &str,
|
||||
) -> Result<(), DomainError>;
|
||||
async fn run_backfill_for_follower(
|
||||
&self,
|
||||
owner_user_id: Uuid,
|
||||
follower_inbox_url: String,
|
||||
) -> Result<(), DomainError>;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::ports::noop::{NoopApBackfill, NoopApDocument, NoopInstanceBlocklist};
|
||||
|
||||
#[tokio::test]
|
||||
async fn noop_document_returns_empty_strings() {
|
||||
let p = NoopApDocument;
|
||||
assert_eq!(p.actor_json("anything").await.unwrap(), "");
|
||||
assert_eq!(
|
||||
p.followers_collection_json(uuid::Uuid::nil(), None)
|
||||
.await
|
||||
.unwrap(),
|
||||
""
|
||||
);
|
||||
assert_eq!(
|
||||
p.following_collection_json(uuid::Uuid::nil(), Some(2))
|
||||
.await
|
||||
.unwrap(),
|
||||
""
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn noop_blocklist_returns_empty_and_ok() {
|
||||
let p = NoopInstanceBlocklist;
|
||||
assert!(p.get_blocked_domains().await.unwrap().is_empty());
|
||||
p.add_blocked_domain("evil.example", Some("spam"))
|
||||
.await
|
||||
.unwrap();
|
||||
p.remove_blocked_domain("evil.example").await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn noop_backfill_returns_empty_and_ok() {
|
||||
let p = NoopApBackfill;
|
||||
assert!(p.get_following(uuid::Uuid::nil()).await.unwrap().is_empty());
|
||||
p.import_remote_outbox("https://a/outbox", "https://a")
|
||||
.await
|
||||
.unwrap();
|
||||
p.run_backfill_for_follower(uuid::Uuid::nil(), "https://a/inbox".into())
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ use async_trait::async_trait;
|
||||
|
||||
use crate::{
|
||||
errors::DomainError,
|
||||
value_objects::{FollowStatus, SocialActor},
|
||||
value_objects::{FollowRelation, FollowStatus, SocialActor},
|
||||
};
|
||||
|
||||
#[async_trait]
|
||||
@@ -50,31 +50,29 @@ pub trait FollowCommand: Send + Sync {
|
||||
|
||||
#[async_trait]
|
||||
pub trait FollowQuery: Send + Sync {
|
||||
async fn get_following(
|
||||
&self,
|
||||
user_id: uuid::Uuid,
|
||||
base_url: &str,
|
||||
) -> Result<Vec<SocialActor>, DomainError>;
|
||||
async fn get_following(&self, user_id: uuid::Uuid) -> Result<Vec<SocialActor>, DomainError>;
|
||||
|
||||
async fn get_followers(
|
||||
&self,
|
||||
user_id: uuid::Uuid,
|
||||
base_url: &str,
|
||||
) -> Result<Vec<SocialActor>, DomainError>;
|
||||
async fn get_followers(&self, user_id: uuid::Uuid) -> Result<Vec<SocialActor>, DomainError>;
|
||||
|
||||
async fn get_pending_followers(
|
||||
&self,
|
||||
user_id: uuid::Uuid,
|
||||
base_url: &str,
|
||||
) -> Result<Vec<SocialActor>, DomainError>;
|
||||
|
||||
async fn get_pending_following(
|
||||
&self,
|
||||
user_id: uuid::Uuid,
|
||||
) -> Result<Vec<SocialActor>, DomainError>;
|
||||
|
||||
async fn count_following(&self, user_id: uuid::Uuid) -> Result<usize, DomainError>;
|
||||
|
||||
async fn count_followers(&self, user_id: uuid::Uuid) -> Result<usize, DomainError>;
|
||||
|
||||
async fn is_following(
|
||||
async fn count_pending_followers(&self, user_id: uuid::Uuid) -> Result<usize, DomainError>;
|
||||
|
||||
async fn get_relation(
|
||||
&self,
|
||||
follower_id: uuid::Uuid,
|
||||
viewer_id: uuid::Uuid,
|
||||
target_actor_url: &str,
|
||||
) -> Result<bool, DomainError>;
|
||||
) -> Result<FollowRelation, DomainError>;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ pub mod auth;
|
||||
pub mod diary;
|
||||
pub mod events;
|
||||
pub mod federated_profile;
|
||||
pub mod federation;
|
||||
pub mod follow;
|
||||
pub mod goals;
|
||||
pub mod image_fetcher;
|
||||
@@ -22,6 +23,7 @@ pub use auth::*;
|
||||
pub use diary::*;
|
||||
pub use events::*;
|
||||
pub use federated_profile::*;
|
||||
pub use federation::*;
|
||||
pub use follow::*;
|
||||
pub use goals::*;
|
||||
pub use image_fetcher::*;
|
||||
|
||||
@@ -2,7 +2,7 @@ use async_trait::async_trait;
|
||||
|
||||
use crate::{
|
||||
errors::DomainError,
|
||||
value_objects::{SocialActor, SocialIdentity, UserId},
|
||||
value_objects::{FollowRelation, SocialActor, SocialIdentity, UserId},
|
||||
};
|
||||
|
||||
// ── NoopRemoteWatchlistRepository ─────────────────────────────────────────────
|
||||
@@ -73,7 +73,7 @@ impl super::SocialCommand for NoopSocialCommand {
|
||||
pub struct NoopSocialQuery;
|
||||
|
||||
#[async_trait]
|
||||
impl super::SocialQuery for NoopSocialQuery {
|
||||
impl super::FollowGraphQuery for NoopSocialQuery {
|
||||
async fn get_following(&self, _: &UserId) -> Result<Vec<SocialActor>, DomainError> {
|
||||
Ok(vec![])
|
||||
}
|
||||
@@ -83,18 +83,32 @@ impl super::SocialQuery for NoopSocialQuery {
|
||||
async fn get_pending_followers(&self, _: &UserId) -> Result<Vec<SocialActor>, DomainError> {
|
||||
Ok(vec![])
|
||||
}
|
||||
async fn get_pending_following(&self, _: &UserId) -> Result<Vec<SocialActor>, DomainError> {
|
||||
Ok(vec![])
|
||||
}
|
||||
async fn count_following(&self, _: &UserId) -> Result<usize, DomainError> {
|
||||
Ok(0)
|
||||
}
|
||||
async fn count_followers(&self, _: &UserId) -> Result<usize, DomainError> {
|
||||
Ok(0)
|
||||
}
|
||||
async fn count_pending_followers(&self, _: &UserId) -> Result<usize, DomainError> {
|
||||
Ok(0)
|
||||
}
|
||||
async fn get_relation(
|
||||
&self,
|
||||
_: &UserId,
|
||||
_: &SocialIdentity,
|
||||
) -> Result<FollowRelation, DomainError> {
|
||||
Ok(FollowRelation::default())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl super::BlockQuery for NoopSocialQuery {
|
||||
async fn get_blocked(&self, _: &UserId) -> Result<Vec<SocialActor>, DomainError> {
|
||||
Ok(vec![])
|
||||
}
|
||||
async fn is_following(&self, _: &UserId, _: &SocialIdentity) -> Result<bool, DomainError> {
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
// ── NoopFederationAdminQuery ─────────────────────────────────────────────────
|
||||
@@ -110,3 +124,70 @@ impl super::FederationAdminQuery for NoopFederationAdminQuery {
|
||||
Ok(vec![])
|
||||
}
|
||||
}
|
||||
|
||||
// ── NoopApDocument ───────────────────────────────────────────────────────────
|
||||
|
||||
/// Stub used when federation is disabled — every operation is a no-op.
|
||||
pub struct NoopApDocument;
|
||||
|
||||
#[async_trait]
|
||||
impl super::ApDocumentPort for NoopApDocument {
|
||||
async fn actor_json(&self, _: &str) -> Result<String, DomainError> {
|
||||
Ok(String::new())
|
||||
}
|
||||
async fn followers_collection_json(
|
||||
&self,
|
||||
_: uuid::Uuid,
|
||||
_: Option<u32>,
|
||||
) -> Result<String, DomainError> {
|
||||
Ok(String::new())
|
||||
}
|
||||
async fn following_collection_json(
|
||||
&self,
|
||||
_: uuid::Uuid,
|
||||
_: Option<u32>,
|
||||
) -> Result<String, DomainError> {
|
||||
Ok(String::new())
|
||||
}
|
||||
}
|
||||
|
||||
// ── NoopInstanceBlocklist ────────────────────────────────────────────────────
|
||||
|
||||
/// Stub used when federation is disabled — every operation is a no-op.
|
||||
pub struct NoopInstanceBlocklist;
|
||||
|
||||
#[async_trait]
|
||||
impl super::InstanceBlocklistPort for NoopInstanceBlocklist {
|
||||
async fn get_blocked_domains(
|
||||
&self,
|
||||
) -> Result<Vec<crate::models::BlockedDomainInfo>, DomainError> {
|
||||
Ok(vec![])
|
||||
}
|
||||
async fn add_blocked_domain(&self, _: &str, _: Option<&str>) -> Result<(), DomainError> {
|
||||
Ok(())
|
||||
}
|
||||
async fn remove_blocked_domain(&self, _: &str) -> Result<(), DomainError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ── NoopApBackfill ───────────────────────────────────────────────────────────
|
||||
|
||||
/// Stub used when federation is disabled — every operation is a no-op.
|
||||
pub struct NoopApBackfill;
|
||||
|
||||
#[async_trait]
|
||||
impl super::ApBackfillPort for NoopApBackfill {
|
||||
async fn get_following(
|
||||
&self,
|
||||
_: uuid::Uuid,
|
||||
) -> Result<Vec<crate::models::FollowedActorInfo>, DomainError> {
|
||||
Ok(vec![])
|
||||
}
|
||||
async fn import_remote_outbox(&self, _: &str, _: &str) -> Result<(), DomainError> {
|
||||
Ok(())
|
||||
}
|
||||
async fn run_backfill_for_follower(&self, _: uuid::Uuid, _: String) -> Result<(), DomainError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ use crate::{
|
||||
DiaryEntry, FederationFlags, RemoteActorInfo, RemoteGoalEntry, RemoteWatchlistEntry,
|
||||
WatchlistWithMovie,
|
||||
},
|
||||
value_objects::{FollowTarget, MovieId, SocialActor, SocialIdentity, UserId},
|
||||
value_objects::{FollowRelation, FollowTarget, MovieId, SocialActor, SocialIdentity, UserId},
|
||||
};
|
||||
|
||||
// ── Unified social ports (ADR-0002) ─────────────────────────────────────────
|
||||
@@ -43,24 +43,31 @@ pub trait SocialCommand: Send + Sync {
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait SocialQuery: Send + Sync {
|
||||
pub trait FollowGraphQuery: Send + Sync {
|
||||
async fn get_following(&self, user: &UserId) -> Result<Vec<SocialActor>, DomainError>;
|
||||
|
||||
async fn get_followers(&self, user: &UserId) -> Result<Vec<SocialActor>, DomainError>;
|
||||
|
||||
async fn get_pending_followers(&self, user: &UserId) -> Result<Vec<SocialActor>, DomainError>;
|
||||
|
||||
async fn get_pending_following(&self, user: &UserId) -> Result<Vec<SocialActor>, DomainError>;
|
||||
|
||||
async fn count_following(&self, user: &UserId) -> Result<usize, DomainError>;
|
||||
|
||||
async fn count_followers(&self, user: &UserId) -> Result<usize, DomainError>;
|
||||
|
||||
async fn get_blocked(&self, user: &UserId) -> Result<Vec<SocialActor>, DomainError>;
|
||||
async fn count_pending_followers(&self, user: &UserId) -> Result<usize, DomainError>;
|
||||
|
||||
async fn is_following(
|
||||
async fn get_relation(
|
||||
&self,
|
||||
follower: &UserId,
|
||||
viewer: &UserId,
|
||||
target: &SocialIdentity,
|
||||
) -> Result<bool, DomainError>;
|
||||
) -> Result<FollowRelation, DomainError>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait BlockQuery: Send + Sync {
|
||||
async fn get_blocked(&self, user: &UserId) -> Result<Vec<SocialActor>, DomainError>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -119,3 +126,223 @@ pub trait LocalApContentQuery: Send + Sync {
|
||||
limit: usize,
|
||||
) -> Result<Vec<DiaryEntry>, DomainError>;
|
||||
}
|
||||
|
||||
/// Resolves a `FollowTarget` (a handle or an already-known identity) to the
|
||||
/// `SocialIdentity` that should be dispatched on — local vs. remote.
|
||||
///
|
||||
/// Split out from `SocialCommand::follow` because the decision must happen
|
||||
/// once, before dispatch, and both `LocalSocialService` and
|
||||
/// `CompositeSocialAdapter` need to observe its result rather than each
|
||||
/// re-deriving it (which would need `UserRepository` on the composite and
|
||||
/// duplicate the local/remote fallthrough logic).
|
||||
#[async_trait]
|
||||
pub trait FollowTargetResolver: Send + Sync {
|
||||
async fn resolve_target(&self, target: &FollowTarget) -> Result<SocialIdentity, DomainError>;
|
||||
}
|
||||
|
||||
/// Performs the local follow write for a target whose `SocialIdentity` a caller
|
||||
/// has *already* resolved via `FollowTargetResolver::resolve_target`.
|
||||
///
|
||||
/// Exists so `CompositeSocialAdapter` — which must call `resolve_target` first
|
||||
/// to decide local vs. remote dispatch — doesn't then hand the raw
|
||||
/// `FollowTarget` to `SocialCommand::follow` and pay for a second resolution of
|
||||
/// the same handle. That second resolution wasn't just wasteful: on a
|
||||
/// federation-ON deployment it could also change the answer (the target user
|
||||
/// deleted between the two calls, e.g.), turning a local follow into a bogus
|
||||
/// "federation is not enabled" error.
|
||||
///
|
||||
/// `LocalSocialService::follow` is `resolve_target_identity` then
|
||||
/// `follow_resolved` — the self-follow guard and the two-sided write live only
|
||||
/// in the latter, so a caller that already has the identity (the composite)
|
||||
/// and one that doesn't (federation-off `SocialCommand::follow`, wired
|
||||
/// directly) both end up running the exact same write path.
|
||||
#[async_trait]
|
||||
pub trait ResolvedFollow: Send + Sync {
|
||||
async fn follow_resolved(
|
||||
&self,
|
||||
follower: &UserId,
|
||||
target: &SocialIdentity,
|
||||
) -> Result<(), DomainError>;
|
||||
}
|
||||
|
||||
/// The subset of social behavior that needs no ActivityPub — everything a
|
||||
/// single-instance deployment can do with only its own database.
|
||||
///
|
||||
/// A marker supertrait with a blanket impl, so any type implementing all five
|
||||
/// parts is usable as `Arc<dyn LocalSocial>` without a separate registration.
|
||||
/// Same shape as `k_ap::FollowRepository` over its five follow traits.
|
||||
///
|
||||
/// `CompositeSocialAdapter` takes one of these for its local branches, which is
|
||||
/// what lets the `activitypub` crate stay unaware of `application`.
|
||||
pub trait LocalSocial:
|
||||
SocialCommand + FollowGraphQuery + BlockQuery + FollowTargetResolver + ResolvedFollow
|
||||
{
|
||||
}
|
||||
|
||||
impl<T: SocialCommand + FollowGraphQuery + BlockQuery + FollowTargetResolver + ResolvedFollow>
|
||||
LocalSocial for T
|
||||
{
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod local_social_tests {
|
||||
use super::*;
|
||||
use std::sync::Arc;
|
||||
|
||||
struct Stub;
|
||||
|
||||
#[async_trait]
|
||||
impl SocialCommand for Stub {
|
||||
async fn follow(
|
||||
&self,
|
||||
_: &crate::value_objects::UserId,
|
||||
_: &crate::value_objects::FollowTarget,
|
||||
) -> Result<(), DomainError> {
|
||||
Ok(())
|
||||
}
|
||||
async fn unfollow(
|
||||
&self,
|
||||
_: &crate::value_objects::UserId,
|
||||
_: &crate::value_objects::SocialIdentity,
|
||||
) -> Result<(), DomainError> {
|
||||
Ok(())
|
||||
}
|
||||
async fn accept_follow(
|
||||
&self,
|
||||
_: &crate::value_objects::UserId,
|
||||
_: &crate::value_objects::SocialIdentity,
|
||||
) -> Result<(), DomainError> {
|
||||
Ok(())
|
||||
}
|
||||
async fn reject_follow(
|
||||
&self,
|
||||
_: &crate::value_objects::UserId,
|
||||
_: &crate::value_objects::SocialIdentity,
|
||||
) -> Result<(), DomainError> {
|
||||
Ok(())
|
||||
}
|
||||
async fn remove_follower(
|
||||
&self,
|
||||
_: &crate::value_objects::UserId,
|
||||
_: &crate::value_objects::SocialIdentity,
|
||||
) -> Result<(), DomainError> {
|
||||
Ok(())
|
||||
}
|
||||
async fn block(
|
||||
&self,
|
||||
_: &crate::value_objects::UserId,
|
||||
_: &crate::value_objects::SocialIdentity,
|
||||
) -> Result<(), DomainError> {
|
||||
Ok(())
|
||||
}
|
||||
async fn unblock(
|
||||
&self,
|
||||
_: &crate::value_objects::UserId,
|
||||
_: &crate::value_objects::SocialIdentity,
|
||||
) -> Result<(), DomainError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl FollowGraphQuery for Stub {
|
||||
async fn get_following(
|
||||
&self,
|
||||
_: &crate::value_objects::UserId,
|
||||
) -> Result<Vec<crate::value_objects::SocialActor>, DomainError> {
|
||||
Ok(vec![])
|
||||
}
|
||||
async fn get_followers(
|
||||
&self,
|
||||
_: &crate::value_objects::UserId,
|
||||
) -> Result<Vec<crate::value_objects::SocialActor>, DomainError> {
|
||||
Ok(vec![])
|
||||
}
|
||||
async fn get_pending_followers(
|
||||
&self,
|
||||
_: &crate::value_objects::UserId,
|
||||
) -> Result<Vec<crate::value_objects::SocialActor>, DomainError> {
|
||||
Ok(vec![])
|
||||
}
|
||||
async fn get_pending_following(
|
||||
&self,
|
||||
_: &crate::value_objects::UserId,
|
||||
) -> Result<Vec<crate::value_objects::SocialActor>, DomainError> {
|
||||
Ok(vec![])
|
||||
}
|
||||
async fn count_following(
|
||||
&self,
|
||||
_: &crate::value_objects::UserId,
|
||||
) -> Result<usize, DomainError> {
|
||||
Ok(0)
|
||||
}
|
||||
async fn count_followers(
|
||||
&self,
|
||||
_: &crate::value_objects::UserId,
|
||||
) -> Result<usize, DomainError> {
|
||||
Ok(0)
|
||||
}
|
||||
async fn count_pending_followers(
|
||||
&self,
|
||||
_: &crate::value_objects::UserId,
|
||||
) -> Result<usize, DomainError> {
|
||||
Ok(0)
|
||||
}
|
||||
async fn get_relation(
|
||||
&self,
|
||||
_: &crate::value_objects::UserId,
|
||||
_: &crate::value_objects::SocialIdentity,
|
||||
) -> Result<crate::value_objects::FollowRelation, DomainError> {
|
||||
Err(DomainError::NotFound("stub".into()))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl BlockQuery for Stub {
|
||||
async fn get_blocked(
|
||||
&self,
|
||||
_: &crate::value_objects::UserId,
|
||||
) -> Result<Vec<crate::value_objects::SocialActor>, DomainError> {
|
||||
Ok(vec![])
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl FollowTargetResolver for Stub {
|
||||
async fn resolve_target(
|
||||
&self,
|
||||
target: &crate::value_objects::FollowTarget,
|
||||
) -> Result<SocialIdentity, DomainError> {
|
||||
match target {
|
||||
crate::value_objects::FollowTarget::Identity(id) => Ok(id.clone()),
|
||||
crate::value_objects::FollowTarget::Handle(handle) => Ok(SocialIdentity::Remote {
|
||||
actor_url: handle.clone(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ResolvedFollow for Stub {
|
||||
async fn follow_resolved(
|
||||
&self,
|
||||
_: &crate::value_objects::UserId,
|
||||
_: &SocialIdentity,
|
||||
) -> Result<(), DomainError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// The blanket impl must make any type implementing all five usable as
|
||||
/// `Arc<dyn LocalSocial>`, and `LocalSocial` must stay object-safe.
|
||||
#[test]
|
||||
fn blanket_impl_yields_a_trait_object() {
|
||||
let local: Arc<dyn LocalSocial> = Arc::new(Stub);
|
||||
// Reachable through each supertrait without a separate Arc.
|
||||
let _: &dyn SocialCommand = local.as_ref();
|
||||
let _: &dyn FollowGraphQuery = local.as_ref();
|
||||
let _: &dyn BlockQuery = local.as_ref();
|
||||
let _: &dyn FollowTargetResolver = local.as_ref();
|
||||
let _: &dyn ResolvedFollow = local.as_ref();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,12 +85,14 @@ impl MetadataClient for FakeMetadataClient {
|
||||
|
||||
pub struct FakeDiaryQuery {
|
||||
histories: Mutex<HashMap<Uuid, (Movie, Vec<Review>)>>,
|
||||
diary_page: Mutex<Option<Paginated<DiaryEntry>>>,
|
||||
}
|
||||
|
||||
impl FakeDiaryQuery {
|
||||
pub fn new() -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
histories: Mutex::new(HashMap::new()),
|
||||
diary_page: Mutex::new(None),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -100,6 +102,13 @@ impl FakeDiaryQuery {
|
||||
.unwrap()
|
||||
.insert(movie.id().value(), (movie, reviews));
|
||||
}
|
||||
|
||||
/// Configures what `query_diary` returns. Without this, `query_diary` keeps
|
||||
/// its original always-empty behavior — this is purely opt-in for tests that
|
||||
/// need to prove entries actually flow through a use case.
|
||||
pub fn set_diary_page(&self, page: Paginated<DiaryEntry>) {
|
||||
*self.diary_page.lock().unwrap() = Some(page);
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -108,12 +117,17 @@ impl DiaryQuery for FakeDiaryQuery {
|
||||
&self,
|
||||
_filter: &DiaryFilter,
|
||||
) -> Result<Paginated<DiaryEntry>, DomainError> {
|
||||
Ok(Paginated {
|
||||
items: vec![],
|
||||
total_count: 0,
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
})
|
||||
Ok(self
|
||||
.diary_page
|
||||
.lock()
|
||||
.unwrap()
|
||||
.clone()
|
||||
.unwrap_or(Paginated {
|
||||
items: vec![],
|
||||
total_count: 0,
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn query_activity_feed(
|
||||
|
||||
@@ -12,21 +12,23 @@ use crate::{
|
||||
errors::DomainError,
|
||||
models::{
|
||||
FederationFlags, Goal, ImportProfile, ImportSession, Movie, MovieFilter, MovieProfile,
|
||||
MovieSummary, ProfileField, RefreshSession, Review, User, UserSettings, UserSummary,
|
||||
WatchEvent, WatchEventStatus, WatchlistEntry, WatchlistWithMovie, WebhookToken,
|
||||
MovieSummary, ProfileField, RefreshSession, RemoteWatchlistEntry, Review, User,
|
||||
UserSettings, UserSummary, WatchEvent, WatchEventStatus, WatchlistEntry,
|
||||
WatchlistWithMovie, WebhookToken,
|
||||
collections::{PageParams, Paginated},
|
||||
},
|
||||
ports::{
|
||||
GoalCommand, GoalQuery, ImportProfileRepository, ImportSessionRepository, MovieCommand,
|
||||
MovieProfileRepository, MovieQuery, RefreshSessionRepository, ReviewRepository,
|
||||
SocialCommand, SocialQuery, UserFederationSettingsQuery, UserProfileFieldsRepository,
|
||||
UserRepository, UserSettingsRepository, WatchEventCommand, WatchEventQuery,
|
||||
WatchlistRepository, WebhookTokenRepository,
|
||||
BlockQuery, FollowGraphQuery, GoalCommand, GoalQuery, ImportProfileRepository,
|
||||
ImportSessionRepository, MovieCommand, MovieProfileRepository, MovieQuery,
|
||||
RefreshSessionRepository, RemoteWatchlistRepository, ReviewRepository, SocialCommand,
|
||||
UserFederationSettingsQuery, UserProfileFieldsRepository, UserRepository,
|
||||
UserSettingsRepository, WatchEventCommand, WatchEventQuery, WatchlistRepository,
|
||||
WebhookTokenRepository,
|
||||
},
|
||||
value_objects::{
|
||||
Email, ExternalMetadataId, GoalId, ImportProfileId, ImportSessionId, MovieId, MovieTitle,
|
||||
ReleaseYear, ReviewId, SocialActor, SocialIdentity, UserId, Username, WatchEventId,
|
||||
WebhookTokenId,
|
||||
Email, ExternalMetadataId, FollowRelation, FollowStatus, GoalId, ImportProfileId,
|
||||
ImportSessionId, MovieId, MovieTitle, ReleaseYear, ReviewId, SocialActor, SocialIdentity,
|
||||
UserId, Username, WatchEventId, WebhookTokenId,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -264,9 +266,13 @@ impl UserRepository for InMemoryUserRepository {
|
||||
|
||||
async fn update_profile(
|
||||
&self,
|
||||
_user_id: &UserId,
|
||||
_profile: &crate::models::UserProfile,
|
||||
user_id: &UserId,
|
||||
profile: &crate::models::UserProfile,
|
||||
) -> Result<(), DomainError> {
|
||||
let mut store = self.store.lock().unwrap();
|
||||
if let Some(user) = store.get_mut(&user_id.value()) {
|
||||
user.update_profile(profile.clone());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -339,6 +345,78 @@ impl WatchlistRepository for InMemoryWatchlistRepository {
|
||||
}
|
||||
}
|
||||
|
||||
// ── InMemoryRemoteWatchlistRepository ─────────────────────────────────────────
|
||||
|
||||
/// Unlike `NoopRemoteWatchlistRepository`, this actually stores what it's given —
|
||||
/// needed by tests that must prove a federated-watchlist read returns real seeded
|
||||
/// data, not just an empty default that happens to match by coincidence.
|
||||
pub struct InMemoryRemoteWatchlistRepository {
|
||||
store: Mutex<Vec<RemoteWatchlistEntry>>,
|
||||
}
|
||||
|
||||
impl InMemoryRemoteWatchlistRepository {
|
||||
pub fn new() -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
store: Mutex::new(Vec::new()),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn with_entries(entries: Vec<RemoteWatchlistEntry>) -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
store: Mutex::new(entries),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl RemoteWatchlistRepository for InMemoryRemoteWatchlistRepository {
|
||||
async fn save(&self, entry: RemoteWatchlistEntry) -> Result<(), DomainError> {
|
||||
self.store.lock().unwrap().push(entry);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn remove_by_ap_id(&self, ap_id: &str, actor_url: &str) -> Result<(), DomainError> {
|
||||
self.store
|
||||
.lock()
|
||||
.unwrap()
|
||||
.retain(|e| !(e.ap_id == ap_id && e.actor_url == actor_url));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_by_actor_url(
|
||||
&self,
|
||||
actor_url: &str,
|
||||
) -> Result<Vec<RemoteWatchlistEntry>, DomainError> {
|
||||
Ok(self
|
||||
.store
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|e| e.actor_url == actor_url)
|
||||
.cloned()
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn remove_all_by_actor(&self, actor_url: &str) -> Result<(), DomainError> {
|
||||
self.store
|
||||
.lock()
|
||||
.unwrap()
|
||||
.retain(|e| e.actor_url != actor_url);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Real adapters derive `uuid` from the actor URL via a SQL-side hash — not
|
||||
/// domain logic, so this fake doesn't replicate it. It simply returns
|
||||
/// everything seeded, which is all callers in this workspace need: tests seed
|
||||
/// exactly the entries for the owner under test.
|
||||
async fn get_by_derived_uuid(
|
||||
&self,
|
||||
_uuid: Uuid,
|
||||
) -> Result<Vec<RemoteWatchlistEntry>, DomainError> {
|
||||
Ok(self.store.lock().unwrap().clone())
|
||||
}
|
||||
}
|
||||
|
||||
// ── InMemoryGoalRepository ──────────────────────────────────────────────────
|
||||
|
||||
pub struct InMemoryGoalRepository {
|
||||
@@ -1033,7 +1111,7 @@ impl SocialCommand for InMemorySocialRepository {
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl SocialQuery for InMemorySocialRepository {
|
||||
impl FollowGraphQuery for InMemorySocialRepository {
|
||||
async fn get_following(&self, user: &UserId) -> Result<Vec<SocialActor>, DomainError> {
|
||||
let store = self.follows.lock().unwrap();
|
||||
Ok(store
|
||||
@@ -1069,6 +1147,15 @@ impl SocialQuery for InMemorySocialRepository {
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn get_pending_following(&self, user: &UserId) -> Result<Vec<SocialActor>, DomainError> {
|
||||
let store = self.follows.lock().unwrap();
|
||||
Ok(store
|
||||
.iter()
|
||||
.filter(|(f, _, state)| *f == user.value() && *state == FollowState::Pending)
|
||||
.map(|(_, t, _)| Self::identity_to_actor(t))
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn count_following(&self, user: &UserId) -> Result<usize, DomainError> {
|
||||
let store = self.follows.lock().unwrap();
|
||||
Ok(store
|
||||
@@ -1086,6 +1173,43 @@ impl SocialQuery for InMemorySocialRepository {
|
||||
.count())
|
||||
}
|
||||
|
||||
async fn count_pending_followers(&self, user: &UserId) -> Result<usize, DomainError> {
|
||||
let store = self.follows.lock().unwrap();
|
||||
let target = SocialIdentity::Local(user.clone());
|
||||
Ok(store
|
||||
.iter()
|
||||
.filter(|(_, t, state)| *t == target && *state == FollowState::Pending)
|
||||
.count())
|
||||
}
|
||||
|
||||
async fn get_relation(
|
||||
&self,
|
||||
viewer: &UserId,
|
||||
target: &SocialIdentity,
|
||||
) -> Result<FollowRelation, DomainError> {
|
||||
let store = self.follows.lock().unwrap();
|
||||
let viewer_identity = SocialIdentity::Local(viewer.clone());
|
||||
let state_to_status = |s: &FollowState| match s {
|
||||
FollowState::Pending => FollowStatus::Pending,
|
||||
FollowState::Accepted => FollowStatus::Accepted,
|
||||
};
|
||||
Ok(FollowRelation {
|
||||
following: store
|
||||
.iter()
|
||||
.find(|(f, t, _)| *f == viewer.value() && t == target)
|
||||
.map(|(_, _, s)| state_to_status(s)),
|
||||
followed_by: store
|
||||
.iter()
|
||||
.find(|(f, t, _)| {
|
||||
SocialIdentity::Local(UserId::from_uuid(*f)) == *target && *t == viewer_identity
|
||||
})
|
||||
.map(|(_, _, s)| state_to_status(s)),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl BlockQuery for InMemorySocialRepository {
|
||||
async fn get_blocked(&self, user: &UserId) -> Result<Vec<SocialActor>, DomainError> {
|
||||
let store = self.blocked.lock().unwrap();
|
||||
Ok(store
|
||||
@@ -1094,15 +1218,4 @@ impl SocialQuery for InMemorySocialRepository {
|
||||
.map(|(_, t)| Self::identity_to_actor(t))
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn is_following(
|
||||
&self,
|
||||
follower: &UserId,
|
||||
target: &SocialIdentity,
|
||||
) -> Result<bool, DomainError> {
|
||||
let store = self.follows.lock().unwrap();
|
||||
Ok(store.iter().any(|(f, t, state)| {
|
||||
*f == follower.value() && t == target && *state == FollowState::Accepted
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,14 +7,15 @@ use crate::{
|
||||
FeedEntry, FeedSortBy, FieldMapping, FileFormat, FollowingFilter, ImportError,
|
||||
ImportProfile, ImportSession, IndexableDocument, MovieProfile, MovieStats, ParsedFile,
|
||||
Person, PersonCredits, PersonEnrichmentData, PersonId, RefreshSession, RemoteActorInfo,
|
||||
ReviewHistory, SearchQuery, SearchResults, UserStats, UserTrends,
|
||||
ReviewHistory, SearchQuery, SearchResults, UserStats, UserTrends, WatchlistEntry,
|
||||
WatchlistWithMovie,
|
||||
collections::{PageParams, Paginated},
|
||||
},
|
||||
ports::{
|
||||
DiaryExporter, DiaryQuery, DocumentParser, ImportProfileRepository,
|
||||
ImportSessionRepository, MovieProfileRepository, PersonCommand, PersonQuery,
|
||||
PosterFetcherClient, RefreshSessionRepository, SearchCommand, SearchPort, StatsRepository,
|
||||
UserProfileFieldsRepository,
|
||||
UserProfileFieldsRepository, WatchlistRepository,
|
||||
},
|
||||
value_objects::{ImportProfileId, ImportSessionId, MovieId, PosterUrl, UserId},
|
||||
};
|
||||
@@ -280,6 +281,31 @@ impl DocumentParser for PanicDocumentParser {
|
||||
}
|
||||
}
|
||||
|
||||
pub struct PanicWatchlistRepository;
|
||||
|
||||
#[async_trait]
|
||||
impl WatchlistRepository for PanicWatchlistRepository {
|
||||
async fn add(&self, _: &WatchlistEntry) -> Result<(), DomainError> {
|
||||
panic!("PanicWatchlistRepository called")
|
||||
}
|
||||
async fn remove(&self, _: &UserId, _: &MovieId) -> Result<(), DomainError> {
|
||||
panic!("PanicWatchlistRepository called")
|
||||
}
|
||||
async fn remove_if_present(&self, _: &UserId, _: &MovieId) -> Result<bool, DomainError> {
|
||||
panic!("PanicWatchlistRepository called")
|
||||
}
|
||||
async fn get_for_user(
|
||||
&self,
|
||||
_: &UserId,
|
||||
_: &PageParams,
|
||||
) -> Result<Paginated<WatchlistWithMovie>, DomainError> {
|
||||
panic!("PanicWatchlistRepository called")
|
||||
}
|
||||
async fn contains(&self, _: &UserId, _: &MovieId) -> Result<bool, DomainError> {
|
||||
panic!("PanicWatchlistRepository called")
|
||||
}
|
||||
}
|
||||
|
||||
pub struct PanicRemoteWatchlistRepository;
|
||||
|
||||
#[async_trait]
|
||||
|
||||
@@ -200,3 +200,96 @@ fn watch_medium_display_round_trips() {
|
||||
assert_eq!(parsed, v);
|
||||
}
|
||||
}
|
||||
|
||||
// ── InstanceIdentity ────────────────────────────────────────────────────────
|
||||
|
||||
fn instance() -> InstanceIdentity {
|
||||
InstanceIdentity::new("https://md.example")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instance_normalizes_trailing_slash() {
|
||||
assert_eq!(
|
||||
InstanceIdentity::new("https://md.example/").base_url(),
|
||||
"https://md.example"
|
||||
);
|
||||
assert_eq!(
|
||||
InstanceIdentity::new("https://md.example").base_url(),
|
||||
"https://md.example"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instance_host_strips_scheme_and_path() {
|
||||
assert_eq!(instance().host(), "md.example");
|
||||
assert_eq!(
|
||||
InstanceIdentity::new("http://localhost:3000").host(),
|
||||
"localhost:3000"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instance_builds_actor_url_for_local_user() {
|
||||
let uid = UserId::from_uuid(uuid::Uuid::nil());
|
||||
assert_eq!(
|
||||
instance().actor_url_for(&uid),
|
||||
format!("https://md.example/users/{}", uuid::Uuid::nil())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instance_builds_handle_and_image_url() {
|
||||
assert_eq!(instance().handle_for("gabriel"), "@gabriel@md.example");
|
||||
assert_eq!(
|
||||
instance().image_url_for("avatars/a.webp"),
|
||||
"https://md.example/images/avatars/a.webp"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instance_identifies_own_actor_url_as_local() {
|
||||
let uid = UserId::from_uuid(uuid::Uuid::new_v4());
|
||||
let url = instance().actor_url_for(&uid);
|
||||
assert_eq!(instance().identify(&url), SocialIdentity::Local(uid));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instance_identifies_foreign_actor_url_as_remote() {
|
||||
let url = "https://other.example/users/bob";
|
||||
assert_eq!(
|
||||
instance().identify(url),
|
||||
SocialIdentity::Remote {
|
||||
actor_url: url.to_string()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instance_identifies_own_host_with_bad_uuid_as_remote() {
|
||||
let url = "https://md.example/users/not-a-uuid";
|
||||
assert_eq!(
|
||||
instance().identify(url),
|
||||
SocialIdentity::Remote {
|
||||
actor_url: url.to_string()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instance_actor_url_round_trips_both_variants() {
|
||||
let i = instance();
|
||||
let local = SocialIdentity::Local(UserId::from_uuid(uuid::Uuid::new_v4()));
|
||||
let remote = SocialIdentity::Remote {
|
||||
actor_url: "https://other.example/users/bob".into(),
|
||||
};
|
||||
assert_eq!(i.identify(&i.actor_url_of(&local)), local);
|
||||
assert_eq!(i.identify(&i.actor_url_of(&remote)), remote);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instance_rejects_prefix_collision() {
|
||||
// A different instance whose base_url merely starts the same must not read as local.
|
||||
let i = InstanceIdentity::new("https://md.example");
|
||||
let url = "https://md.example.evil.test/users/00000000-0000-0000-0000-000000000000";
|
||||
assert!(matches!(i.identify(url), SocialIdentity::Remote { .. }));
|
||||
}
|
||||
|
||||
62
crates/domain/src/value_objects/instance.rs
Normal file
62
crates/domain/src/value_objects/instance.rs
Normal file
@@ -0,0 +1,62 @@
|
||||
use super::{SocialIdentity, UserId};
|
||||
|
||||
/// This instance's own identity on the network. Owns every URL and handle
|
||||
/// derivation that used to be spelled with a bare `base_url: &str`.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct InstanceIdentity {
|
||||
base_url: String,
|
||||
}
|
||||
|
||||
impl InstanceIdentity {
|
||||
/// Normalizes away a trailing slash so every derived URL has exactly one.
|
||||
pub fn new(base_url: impl Into<String>) -> Self {
|
||||
Self {
|
||||
base_url: base_url.into().trim_end_matches('/').to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn base_url(&self) -> &str {
|
||||
&self.base_url
|
||||
}
|
||||
|
||||
/// Host and port, as it appears in a fediverse handle.
|
||||
pub fn host(&self) -> &str {
|
||||
self.base_url
|
||||
.split("://")
|
||||
.nth(1)
|
||||
.and_then(|s| s.split('/').next())
|
||||
.unwrap_or("localhost")
|
||||
}
|
||||
|
||||
pub fn actor_url_for(&self, user: &UserId) -> String {
|
||||
format!("{}/users/{}", self.base_url, user.value())
|
||||
}
|
||||
|
||||
pub fn handle_for(&self, username: &str) -> String {
|
||||
format!("@{}@{}", username, self.host())
|
||||
}
|
||||
|
||||
pub fn image_url_for(&self, path: &str) -> String {
|
||||
format!("{}/images/{}", self.base_url, path)
|
||||
}
|
||||
|
||||
pub fn actor_url_of(&self, identity: &SocialIdentity) -> String {
|
||||
match identity {
|
||||
SocialIdentity::Local(uid) => self.actor_url_for(uid),
|
||||
SocialIdentity::Remote { actor_url } => actor_url.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Inverse of `actor_url_of`: decides whether an actor URL is ours.
|
||||
pub fn identify(&self, actor_url: &str) -> SocialIdentity {
|
||||
let prefix = format!("{}/users/", self.base_url);
|
||||
if let Some(uuid_str) = actor_url.strip_prefix(&prefix)
|
||||
&& let Ok(uuid) = uuid::Uuid::parse_str(uuid_str)
|
||||
{
|
||||
return SocialIdentity::Local(UserId::from_uuid(uuid));
|
||||
}
|
||||
SocialIdentity::Remote {
|
||||
actor_url: actor_url.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
mod ids;
|
||||
mod instance;
|
||||
mod movie;
|
||||
mod review;
|
||||
mod social;
|
||||
mod user;
|
||||
|
||||
pub use ids::*;
|
||||
pub use instance::*;
|
||||
pub use movie::*;
|
||||
pub use review::*;
|
||||
pub use social::*;
|
||||
|
||||
@@ -7,18 +7,6 @@ pub enum SocialIdentity {
|
||||
}
|
||||
|
||||
impl SocialIdentity {
|
||||
pub fn from_actor_url(actor_url: &str, base_url: &str) -> Self {
|
||||
let prefix = format!("{}/users/", base_url);
|
||||
if let Some(uuid_str) = actor_url.strip_prefix(&prefix)
|
||||
&& let Ok(uuid) = uuid::Uuid::parse_str(uuid_str)
|
||||
{
|
||||
return Self::Local(UserId::from_uuid(uuid));
|
||||
}
|
||||
Self::Remote {
|
||||
actor_url: actor_url.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_local(&self) -> bool {
|
||||
matches!(self, Self::Local(_))
|
||||
}
|
||||
@@ -26,19 +14,6 @@ impl SocialIdentity {
|
||||
pub fn is_remote(&self) -> bool {
|
||||
matches!(self, Self::Remote { .. })
|
||||
}
|
||||
|
||||
pub fn format_local_handle(username: &str, base_url: &str) -> String {
|
||||
let host = Self::host_from_base_url(base_url);
|
||||
format!("@{}@{}", username, host)
|
||||
}
|
||||
|
||||
pub fn host_from_base_url(base_url: &str) -> &str {
|
||||
base_url
|
||||
.split("://")
|
||||
.nth(1)
|
||||
.and_then(|s| s.split('/').next())
|
||||
.unwrap_or("localhost")
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -61,3 +36,11 @@ pub struct SocialActor {
|
||||
pub display_name: Option<String>,
|
||||
pub avatar_url: Option<String>,
|
||||
}
|
||||
|
||||
/// Both directions of the follow edge between a viewer and a target.
|
||||
/// `None` means no edge at all; `Some(Pending)` means requested but not accepted.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||
pub struct FollowRelation {
|
||||
pub following: Option<FollowStatus>,
|
||||
pub followed_by: Option<FollowStatus>,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user