diff --git a/app/Actions/ActionsPost.php b/app/Actions/ActionsPost.php index 7016d27..e188cdd 100644 --- a/app/Actions/ActionsPost.php +++ b/app/Actions/ActionsPost.php @@ -148,4 +148,23 @@ class ActionsPost return $response; } + + public static function pin_post (Actor $actor, Note $note) + { + $client = new Client (); + + try + { + $response = $client->post ($actor->outbox, [ + "json" => [ + "type" => "Pin", + "object" => $note->note_id, + ] + ]); + } + catch (\Exception $e) + { + return ["error" => "Could not connect to server: " . $e->getMessage ()]; + } + } } diff --git a/app/Http/Controllers/AP/APActorController.php b/app/Http/Controllers/AP/APActorController.php index 39771ac..57a1ff8 100644 --- a/app/Http/Controllers/AP/APActorController.php +++ b/app/Http/Controllers/AP/APActorController.php @@ -6,12 +6,15 @@ use App\Models\User; use App\Models\Actor; use App\Models\Activity; use App\Models\Follow; +use App\Models\Note; +use App\Models\ProfilePin; + +use App\Types\TypeOrderedCollection; +use App\Types\TypeNote; use Illuminate\Http\Request; use App\Http\Controllers\Controller; -use App\Types\TypeOrderedCollection; - class APActorController extends Controller { public function user (User $user) @@ -31,7 +34,7 @@ class APActorController extends Controller $followers = Actor::whereIn ("id", $follower_ids->pluck ("actor")->toArray ()); $ordered_collection = new TypeOrderedCollection (); - $ordered_collection->collection = $followers->get ()->pluck ("actor")->toArray (); + $ordered_collection->collection = $followers->get ()->pluck ("actor_id")->toArray (); $ordered_collection->url = route ("ap.followers", $user->name); $ordered_collection->page_size = 10; @@ -49,7 +52,7 @@ class APActorController extends Controller $following = Actor::whereIn ("id", $following_ids->pluck ("object")->toArray ()); $ordered_collection = new TypeOrderedCollection (); - $ordered_collection->collection = $following->get ()->pluck ("object")->toArray (); + $ordered_collection->collection = $following->get ()->pluck ("actor_id")->toArray (); $ordered_collection->url = route ("ap.following", $user->name); $ordered_collection->page_size = 10; @@ -60,4 +63,28 @@ class APActorController extends Controller return response ()->json ($ordered_collection->build_response_main ())->header ("Content-Type", "application/activity+json"); } + + public function featured (User $user) + { + $featured_ids = ProfilePin::where ("actor_id", $user->actor->id)->pluck ("note_id")->toArray (); + $notes = Note::whereIn ("id", $featured_ids)->get (); + + $collection = []; + foreach ($notes as $note) + { + $collection[] = TypeNote::build_response ($note); + } + + $ordered_collection = new TypeOrderedCollection (); + $ordered_collection->collection = $collection; + $ordered_collection->url = route ("ap.featured", $user->name); + $ordered_collection->page_size = 10; + + if (request ()->has ("page")) { + $page = request ()->input ("page"); + return response ()->json ($ordered_collection->build_response_for_page ($page))->header ("Content-Type", "application/activity+json"); + } + + return response ()->json ($ordered_collection->build_response_main ())->header ("Content-Type", "application/activity+json"); + } } diff --git a/app/Http/Controllers/AP/APInstanceInboxController.php b/app/Http/Controllers/AP/APInstanceInboxController.php index 9c1aff0..8d5ef37 100644 --- a/app/Http/Controllers/AP/APInstanceInboxController.php +++ b/app/Http/Controllers/AP/APInstanceInboxController.php @@ -15,6 +15,7 @@ use App\Types\TypeActivity; use App\Types\TypeNote; use App\Http\Controllers\Controller; +use App\Models\ProfilePin; class APInstanceInboxController extends Controller { @@ -32,6 +33,14 @@ class APInstanceInboxController extends Controller return $this->handle_announce ($activity); break; + case "Add": + return $this->handle_add ($activity); + break; + + case "Remove": + return $this->handle_remove ($activity); + break; + case "Undo": return $this->handle_undo ($activity); break; @@ -87,6 +96,50 @@ class APInstanceInboxController extends Controller return response ()->json (["status" => "ok"]); } + public function handle_add ($activity) + { + $actor = TypeActor::actor_exists_or_obtain ($activity ["actor"]); + if (!$actor) + return response ()->json (["status" => "error"]); + + if ($activity["target"] != $actor->featured) + // For now we only support adding notes to the featured actor + return response ()->json (["error" => "not implemented"], 501); + + $note = TypeNote::note_exists ($activity ["object"]); + if (!$note) + $note = TypeNote::obtain_external ($activity ["object"]); + + $pin_exists = ProfilePin::where ("actor_id", $actor->id)->where ("note_id", $note->id)->first (); + if ($pin_exists) + return response ()->json (["status" => "ok"]); + + ProfilePin::create ([ + "actor_id" => $actor->id, + "note_id" => $note->id + ]); + + return response ()->json (["status" => "ok"]); + } + + public function handle_remove ($activity) + { + $actor = TypeActor::actor_exists_or_obtain ($activity ["actor"]); + if (!$actor) + return response ()->json (["status" => "error"]); + + if ($activity ["target"] != $actor->featured) + // For now we only support removing notes from the featured actor + return response ()->json (["error" => "not implemented"], 501); + + $note = TypeNote::note_exists ($activity ["object"]); + $pin_exists = ProfilePin::where ("actor_id", $actor->id)->where ("note_id", $note->id)->first (); + if (!$pin_exists) + return response ()->json (["status" => "ok"]); + + $pin_exists->delete (); + } + public function handle_undo ($activity) { return response ()->json (ActionsActivity::activity_undo($activity)); @@ -130,11 +183,11 @@ class APInstanceInboxController extends Controller public function handle_update ($activity) { - if (TypeActivity::activity_exists ($activity ["id"])) - return response ()->json (["status" => "ok"]); - - $activity ["activity_id"] = $activity ["id"]; - $new_activity = Activity::create ($activity); + if (!TypeActivity::activity_exists ($activity ["id"])) + { + $activity ["activity_id"] = $activity ["id"]; + $new_activity = Activity::create ($activity); + } $object = $activity ["object"]; diff --git a/app/Http/Controllers/AP/APOutboxController.php b/app/Http/Controllers/AP/APOutboxController.php index 4dbf746..b2164a1 100644 --- a/app/Http/Controllers/AP/APOutboxController.php +++ b/app/Http/Controllers/AP/APOutboxController.php @@ -23,6 +23,7 @@ use Illuminate\Http\Request; use Illuminate\Support\Facades\Log; use App\Http\Controllers\Controller; +use App\Models\ProfilePin; use Illuminate\Support\Facades\Storage; class APOutboxController extends Controller @@ -60,6 +61,10 @@ class APOutboxController extends Controller return $this->handle_boost ($user, $request->get ("object")); break; + case "Pin": + return $this->handle_pin ($user, $request->get ("object")); + break; + case "Post": return $this->handle_post ($user, $request); break; @@ -295,6 +300,46 @@ class APOutboxController extends Controller ]; } + public function handle_pin (User $user, $object) + { + $object = Note::where ("note_id", $object)->first (); + if (!$object) + return response ()->json ([ "error" => "object not found" ], 404); + + $actor = $user->actor ()->first (); + $already_pinned = $object->is_pinned ($actor); + if ($already_pinned) + { + $pin_activity = $already_pinned->activity; + $remove_activity = TypeActivity::craft_remove ($actor, $object->note_id, $actor->featured); + + $response = TypeActivity::post_to_instances ($remove_activity, $actor); + + $pin_exists = ProfilePin::where ("note_id", $object->id) + ->where ("actor_id", $actor->id) + ->first (); + if ($pin_exists) + $pin_exists->delete (); + + return [ + "success" => "unpinned" + ]; + } + + $pin_activity = TypeActivity::craft_add ($actor, $object->note_id, $actor->featured); + $pin = ProfilePin::create ([ + "activity_id" => $pin_activity->id, + "actor_id" => $actor->id, + "note_id" => $object->id, + ]); + + $response = TypeActivity::post_to_instances ($pin_activity, $actor); + + return [ + "success" => "pinned" + ]; + } + public function handle_post (User $user, $request) { $actor = $user->actor ()->first (); diff --git a/app/Http/Controllers/PostController.php b/app/Http/Controllers/PostController.php index 1fdd2a5..96d4513 100644 --- a/app/Http/Controllers/PostController.php +++ b/app/Http/Controllers/PostController.php @@ -96,6 +96,19 @@ class PostController extends Controller return back ()->with ("success", "Post boosted successfully."); } + public function pin (Note $note) + { + if (!auth ()->check ()) + return back ()->with ("error", "You need to be logged in to pin a post."); + + $user = auth ()->user (); + $actor = $user->actor ()->first (); + + $response = ActionsPost::pin_post ($actor, $note); + + return back ()->with ("success", "Post pinned successfully."); + } + public function delete (Note $note) { $actor = auth ()->user ()->actor ()->first (); diff --git a/app/Models/Actor.php b/app/Models/Actor.php index 3a3138d..196f02c 100644 --- a/app/Models/Actor.php +++ b/app/Models/Actor.php @@ -25,6 +25,8 @@ class Actor extends Model "followers", "liked", + "featured", + "featured_tags", "inbox", "outbox", @@ -55,6 +57,12 @@ class Actor extends Model return $this->belongsTo (User::class); } + public function get_pinned_posts () + { + $pinned = $this->hasMany (ProfilePin::class, "actor_id")->orderBy ("created_at", "desc")->get (); + return Note::whereIn ("id", $pinned->pluck ("note_id"))->get (); + } + public function get_posts () { $posts = $this->hasMany (Note::class, "actor_id")->orderBy ("created_at", "desc")->get (); diff --git a/app/Models/Note.php b/app/Models/Note.php index b008f60..d8634f8 100644 --- a/app/Models/Note.php +++ b/app/Models/Note.php @@ -61,4 +61,9 @@ class Note extends Model { return $this->hasMany (NoteAttachment::class); } + + public function is_pinned (Actor $actor) + { + return ProfilePin::where ("actor_id", $actor->id)->where ("note_id", $this->id)->first (); + } } diff --git a/app/Models/ProfilePin.php b/app/Models/ProfilePin.php new file mode 100644 index 0000000..a995b1d --- /dev/null +++ b/app/Models/ProfilePin.php @@ -0,0 +1,14 @@ +activity_id = env ("APP_URL") . "/activity/" . uniqid (); + $add_activity->type = "Add"; + $add_activity->actor = $actor->actor_id; + $add_activity->object = $object; + $add_activity->target = $target; + $add_activity->save (); + + return $add_activity; + } + + public static function craft_remove (Actor $actor, $object, $target) + { + $remove_activity = new Activity (); + $remove_activity->activity_id = env ("APP_URL") . "/activity/" . uniqid (); + $remove_activity->type = "Remove"; + $remove_activity->actor = $actor->actor_id; + $remove_activity->object = $object; + $remove_activity->target = $target; + $remove_activity->save (); + + return $remove_activity; + } + public static function get_private_key (Actor $actor) { return openssl_get_privatekey ($actor->private_key); @@ -217,6 +243,9 @@ class TypeActivity { public static function post_to_instances (Activity $activity, Actor $source) { + Log::info ("posting activity to instances"); + Log::info (json_encode (TypeActivity::craft_response ($activity))); + $instances = Instance::all (); foreach ($instances as $instance) { diff --git a/app/Types/TypeActor.php b/app/Types/TypeActor.php index 65bab90..17dbee0 100644 --- a/app/Types/TypeActor.php +++ b/app/Types/TypeActor.php @@ -5,6 +5,7 @@ namespace App\Types; use App\Models\User; use App\Models\Actor; use App\Models\Instance; +use App\Models\ProfilePin; use GuzzleHttp\Client; use Illuminate\Support\Facades\Log; @@ -43,6 +44,8 @@ class TypeActor { "followers" => $app_url . "/ap/v1/user/" . $user->name . "/followers", "liked" => $app_url . "/ap/v1/user/" . $user->name . "/liked", + "featured" => $app_url . "/ap/v1/user/" . $user->name . "/collections/featured", + "featured_tags" => $app_url . "/ap/v1/user/" . $user->name . "/collections/featured/tags", "inbox" => $app_url . "/ap/v1/user/" . $user->name . "/inbox", "outbox" => $app_url . "/ap/v1/user/" . $user->name . "/outbox", @@ -63,7 +66,17 @@ class TypeActor { $response = [ "@context" => [ "https://www.w3.org/ns/activitystreams", - "https://w3id.org/security/v1" + "https://w3id.org/security/v1", + [ + "featured" => [ + "@id" => "http://joinmastodon.org/ns#featured", + "@type" => "@id" + ], + "featuredTags" => [ + "@id" => "http://joinmastodon.org/ns#featuredTags", + "@type" => "@id" + ] + ] ], "id" => $actor->actor_id, "type" => $actor->type, @@ -72,6 +85,8 @@ class TypeActor { "followers" => $actor->followers, "liked" => $actor->liked, + "featured" => $actor->featured, + "featuredTags" => $actor->featured_tags, "inbox" => $actor->inbox, "outbox" => $actor->outbox, @@ -163,6 +178,8 @@ class TypeActor { $actor->followers = $request['followers'] ?? ''; $actor->liked = $request['liked'] ?? ''; + $actor->featured = $request['featured'] ?? ''; + $actor->featured_tags = $request['featuredTags'] ?? ''; $actor->inbox = $request['inbox'] ?? ''; $actor->outbox = $request['outbox'] ?? ''; @@ -188,6 +205,27 @@ class TypeActor { $instance->save (); } + $featured_items = TypeActor::actor_process_featured ($actor); + ProfilePin::where ("actor_id", $actor->id)->delete (); + + foreach ($featured_items as $item) + { + if ($item ["type"] == "Note") + { + $note = TypeNote::note_exists ($item ["id"]); + if (!$note) + $note = TypeNote::obtain_external ($item ["id"]); + + if (!$note) + continue; + + ProfilePin::create ([ + "actor_id" => $actor->id, + "note_id" => $note->id + ]); + } + } + return $actor; } @@ -317,4 +355,83 @@ class TypeActor { return null; return $actor; } + + public static function actor_process_featured (Actor $actor) + { + $pinned = []; + + if (!$actor->featured) + return $pinned; + + return TypeActor::actor_process_ordered_collection ($actor->featured); + } + + public static function actor_process_ordered_collection ($collection_link) + { + $items = []; + + try + { + $client = new Client (); + $response = $client->get ($collection_link, [ + "headers" => [ + "Accept" => "application/json" + ] + ]); + + $collection = json_decode ($response->getBody ()->getContents (), true); + + if (isset ($collection ["first"]) && isset ($collection ["last"])) + { + $first = $collection["first"]; + $last = $collection["last"]; + + $current_url = $first; + $current_page = 1; + do { + $items = array_merge ($items, TypeActor::actor_processed_order_collection_page ($current_url)); + + $current_page++; + $current_url = $collection_link . "?page=" . $current_page; + } while ($current_url != $last); + } + else + { + return $collection["orderedItems"]; + } + } + catch (\Exception $e) + { + Log::error ("TypeActor::actor_process_ordered_collection: " . $e->getMessage ()); + } + + return $items; + } + + public static function actor_processed_order_collection_page ($page_link) + { + $items = []; + + try + { + $client = new Client (); + $response = $client->get ($page_link, [ + "headers" => [ + "Accept" => "application/json" + ] + ]); + + $collection = json_decode ($response->getBody ()->getContents (), true); + foreach ($collection["orderedItems"] as $item) + { + $items[] = $item; + } + } + catch (\Exception $e) + { + Log::error ("TypeActor::actor_processed_order_collection_page: " . $e->getMessage ()); + } + + return $items; + } } diff --git a/app/Types/TypeNote.php b/app/Types/TypeNote.php index f8dc8a2..f9aa2e7 100644 --- a/app/Types/TypeNote.php +++ b/app/Types/TypeNote.php @@ -168,6 +168,11 @@ class TypeNote $note->in_reply_to = $parent->note_id; } } + + if (isset ($request ["replies"])) + { + // TODO: Handle replies + } } public static function create_from_request ($request, Activity $activity, Actor $actor) diff --git a/database/migrations/2025_01_08_002252_add_fields_to_actors_table.php b/database/migrations/2025_01_08_002252_add_fields_to_actors_table.php new file mode 100644 index 0000000..9552dc2 --- /dev/null +++ b/database/migrations/2025_01_08_002252_add_fields_to_actors_table.php @@ -0,0 +1,30 @@ +string ("featured")->nullable (); + $table->string ("featured_tags")->nullable (); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('actors', function (Blueprint $table) { + $table->dropColumn ("featured"); + $table->dropColumn ("featured_tags"); + }); + } +}; diff --git a/database/migrations/2025_01_08_003040_create_profile_pins_table.php b/database/migrations/2025_01_08_003040_create_profile_pins_table.php new file mode 100644 index 0000000..3915dd2 --- /dev/null +++ b/database/migrations/2025_01_08_003040_create_profile_pins_table.php @@ -0,0 +1,30 @@ +id(); + $table->foreignId ("activity_id")->nullable ()->constrained ()->onDelete ("cascade"); + $table->foreignId ("note_id")->constrained ()->onDelete ("cascade"); + $table->foreignId ("actor_id")->constrained ()->onDelete ("cascade"); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('profile_pins'); + } +}; diff --git a/resources/views/posts/show.blade.php b/resources/views/posts/show.blade.php index ff53c5e..0756f0c 100644 --- a/resources/views/posts/show.blade.php +++ b/resources/views/posts/show.blade.php @@ -40,6 +40,7 @@

{{ $actor->name }}'s Post

@if (auth ()->check () && auth ()->user ()->is ($actor->user)) +
@csrf @method("DELETE") @@ -48,6 +49,12 @@
+ +
+ @csrf + +
+
@endif @if ($note->in_reply_to) diff --git a/resources/views/users/profile.blade.php b/resources/views/users/profile.blade.php index 2627658..4e01cad 100644 --- a/resources/views/users/profile.blade.php +++ b/resources/views/users/profile.blade.php @@ -320,6 +320,19 @@
+ @if ($actor->get_pinned_posts ()->count () > 0) + + +

Pinned

+ @foreach ($actor->get_pinned_posts () as $post) + + @endforeach + +
+ +
+ @endif + @foreach ($actor->get_posts () as $post) diff --git a/routes/api.php b/routes/api.php index b1ae57e..c0ed3f8 100644 --- a/routes/api.php +++ b/routes/api.php @@ -23,6 +23,7 @@ Route::prefix ("/ap/v1")->group (function () { Route::post ("/user/{user:name}/outbox", [ APOutboxController::class, "outbox" ])->name ("ap.outbox"); Route::get ("/user/{user:name}/followers", [ APActorController::class, "followers" ])->name ("ap.followers"); Route::get ("/user/{user:name}/following", [ APActorController::class, "following" ])->name ("ap.following"); + Route::get ("/user/{user:name}/collections/featured", [ APActorController::class, "featured" ])->name ("ap.featured"); Route::get ("/user/{user:name}", [ APActorController::class, "user" ])->name ("ap.user"); // notes diff --git a/routes/web.php b/routes/web.php index 9b58aef..641f153 100644 --- a/routes/web.php +++ b/routes/web.php @@ -34,6 +34,7 @@ Route::middleware ("update_online")->group (function () { Route::post ("/post/{note}/edit", [ PostController::class, "update" ])->middleware ("auth"); Route::post ("/post/{note}/like", [ PostController::class, "like" ])->name ("posts.like")->middleware ("auth"); Route::post ("/post/{note}/boost", [ PostController::class, "boost" ])->name ("posts.boost")->middleware ("auth"); + Route::post ("/post/{note}/pin", [ PostController::class, "pin" ])->name ("posts.pin")->middleware ("auth"); Route::get ("/post/{note}", [ PostController::class, "show" ])->name ("posts.show"); Route::delete ("/post/{note}", [ PostController::class, "delete" ])->name ("posts.delete")->middleware ("auth");