implemented pinned posts

This commit is contained in:
Ghostie 2025-01-07 21:19:38 -05:00
parent f67907176f
commit 1bf6eb7d94
17 changed files with 427 additions and 10 deletions

View File

@ -148,4 +148,23 @@ class ActionsPost
return $response; 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 ()];
}
}
} }

View File

@ -6,12 +6,15 @@ use App\Models\User;
use App\Models\Actor; use App\Models\Actor;
use App\Models\Activity; use App\Models\Activity;
use App\Models\Follow; 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 Illuminate\Http\Request;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Types\TypeOrderedCollection;
class APActorController extends Controller class APActorController extends Controller
{ {
public function user (User $user) public function user (User $user)
@ -31,7 +34,7 @@ class APActorController extends Controller
$followers = Actor::whereIn ("id", $follower_ids->pluck ("actor")->toArray ()); $followers = Actor::whereIn ("id", $follower_ids->pluck ("actor")->toArray ());
$ordered_collection = new TypeOrderedCollection (); $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->url = route ("ap.followers", $user->name);
$ordered_collection->page_size = 10; $ordered_collection->page_size = 10;
@ -49,7 +52,7 @@ class APActorController extends Controller
$following = Actor::whereIn ("id", $following_ids->pluck ("object")->toArray ()); $following = Actor::whereIn ("id", $following_ids->pluck ("object")->toArray ());
$ordered_collection = new TypeOrderedCollection (); $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->url = route ("ap.following", $user->name);
$ordered_collection->page_size = 10; $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"); 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");
}
} }

View File

@ -15,6 +15,7 @@ use App\Types\TypeActivity;
use App\Types\TypeNote; use App\Types\TypeNote;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\ProfilePin;
class APInstanceInboxController extends Controller class APInstanceInboxController extends Controller
{ {
@ -32,6 +33,14 @@ class APInstanceInboxController extends Controller
return $this->handle_announce ($activity); return $this->handle_announce ($activity);
break; break;
case "Add":
return $this->handle_add ($activity);
break;
case "Remove":
return $this->handle_remove ($activity);
break;
case "Undo": case "Undo":
return $this->handle_undo ($activity); return $this->handle_undo ($activity);
break; break;
@ -87,6 +96,50 @@ class APInstanceInboxController extends Controller
return response ()->json (["status" => "ok"]); 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) public function handle_undo ($activity)
{ {
return response ()->json (ActionsActivity::activity_undo($activity)); return response ()->json (ActionsActivity::activity_undo($activity));
@ -130,11 +183,11 @@ class APInstanceInboxController extends Controller
public function handle_update ($activity) public function handle_update ($activity)
{ {
if (TypeActivity::activity_exists ($activity ["id"])) if (!TypeActivity::activity_exists ($activity ["id"]))
return response ()->json (["status" => "ok"]); {
$activity ["activity_id"] = $activity ["id"]; $activity ["activity_id"] = $activity ["id"];
$new_activity = Activity::create ($activity); $new_activity = Activity::create ($activity);
}
$object = $activity ["object"]; $object = $activity ["object"];

View File

@ -23,6 +23,7 @@ use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\ProfilePin;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
class APOutboxController extends Controller class APOutboxController extends Controller
@ -60,6 +61,10 @@ class APOutboxController extends Controller
return $this->handle_boost ($user, $request->get ("object")); return $this->handle_boost ($user, $request->get ("object"));
break; break;
case "Pin":
return $this->handle_pin ($user, $request->get ("object"));
break;
case "Post": case "Post":
return $this->handle_post ($user, $request); return $this->handle_post ($user, $request);
break; 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) public function handle_post (User $user, $request)
{ {
$actor = $user->actor ()->first (); $actor = $user->actor ()->first ();

View File

@ -96,6 +96,19 @@ class PostController extends Controller
return back ()->with ("success", "Post boosted successfully."); 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) public function delete (Note $note)
{ {
$actor = auth ()->user ()->actor ()->first (); $actor = auth ()->user ()->actor ()->first ();

View File

@ -25,6 +25,8 @@ class Actor extends Model
"followers", "followers",
"liked", "liked",
"featured",
"featured_tags",
"inbox", "inbox",
"outbox", "outbox",
@ -55,6 +57,12 @@ class Actor extends Model
return $this->belongsTo (User::class); 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 () public function get_posts ()
{ {
$posts = $this->hasMany (Note::class, "actor_id")->orderBy ("created_at", "desc")->get (); $posts = $this->hasMany (Note::class, "actor_id")->orderBy ("created_at", "desc")->get ();

View File

@ -61,4 +61,9 @@ class Note extends Model
{ {
return $this->hasMany (NoteAttachment::class); return $this->hasMany (NoteAttachment::class);
} }
public function is_pinned (Actor $actor)
{
return ProfilePin::where ("actor_id", $actor->id)->where ("note_id", $this->id)->first ();
}
} }

14
app/Models/ProfilePin.php Normal file
View File

@ -0,0 +1,14 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class ProfilePin extends Model
{
protected $fillable = [
"activity_id",
"note_id",
"actor_id"
];
}

View File

@ -145,6 +145,32 @@ class TypeActivity {
return $announce_activity; return $announce_activity;
} }
public static function craft_add (Actor $actor, $object, $target)
{
$add_activity = new Activity ();
$add_activity->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) public static function get_private_key (Actor $actor)
{ {
return openssl_get_privatekey ($actor->private_key); return openssl_get_privatekey ($actor->private_key);
@ -217,6 +243,9 @@ class TypeActivity {
public static function post_to_instances (Activity $activity, Actor $source) 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 (); $instances = Instance::all ();
foreach ($instances as $instance) foreach ($instances as $instance)
{ {

View File

@ -5,6 +5,7 @@ namespace App\Types;
use App\Models\User; use App\Models\User;
use App\Models\Actor; use App\Models\Actor;
use App\Models\Instance; use App\Models\Instance;
use App\Models\ProfilePin;
use GuzzleHttp\Client; use GuzzleHttp\Client;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
@ -43,6 +44,8 @@ class TypeActor {
"followers" => $app_url . "/ap/v1/user/" . $user->name . "/followers", "followers" => $app_url . "/ap/v1/user/" . $user->name . "/followers",
"liked" => $app_url . "/ap/v1/user/" . $user->name . "/liked", "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", "inbox" => $app_url . "/ap/v1/user/" . $user->name . "/inbox",
"outbox" => $app_url . "/ap/v1/user/" . $user->name . "/outbox", "outbox" => $app_url . "/ap/v1/user/" . $user->name . "/outbox",
@ -63,7 +66,17 @@ class TypeActor {
$response = [ $response = [
"@context" => [ "@context" => [
"https://www.w3.org/ns/activitystreams", "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, "id" => $actor->actor_id,
"type" => $actor->type, "type" => $actor->type,
@ -72,6 +85,8 @@ class TypeActor {
"followers" => $actor->followers, "followers" => $actor->followers,
"liked" => $actor->liked, "liked" => $actor->liked,
"featured" => $actor->featured,
"featuredTags" => $actor->featured_tags,
"inbox" => $actor->inbox, "inbox" => $actor->inbox,
"outbox" => $actor->outbox, "outbox" => $actor->outbox,
@ -163,6 +178,8 @@ class TypeActor {
$actor->followers = $request['followers'] ?? ''; $actor->followers = $request['followers'] ?? '';
$actor->liked = $request['liked'] ?? ''; $actor->liked = $request['liked'] ?? '';
$actor->featured = $request['featured'] ?? '';
$actor->featured_tags = $request['featuredTags'] ?? '';
$actor->inbox = $request['inbox'] ?? ''; $actor->inbox = $request['inbox'] ?? '';
$actor->outbox = $request['outbox'] ?? ''; $actor->outbox = $request['outbox'] ?? '';
@ -188,6 +205,27 @@ class TypeActor {
$instance->save (); $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; return $actor;
} }
@ -317,4 +355,83 @@ class TypeActor {
return null; return null;
return $actor; 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;
}
} }

View File

@ -168,6 +168,11 @@ class TypeNote
$note->in_reply_to = $parent->note_id; $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) public static function create_from_request ($request, Activity $activity, Actor $actor)

View File

@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('actors', function (Blueprint $table) {
$table->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");
});
}
};

View File

@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('profile_pins', function (Blueprint $table) {
$table->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');
}
};

View File

@ -40,6 +40,7 @@
<div class="col right"> <div class="col right">
<h1 class="title">{{ $actor->name }}'s Post</h1> <h1 class="title">{{ $actor->name }}'s Post</h1>
@if (auth ()->check () && auth ()->user ()->is ($actor->user)) @if (auth ()->check () && auth ()->user ()->is ($actor->user))
<div class="buttons" style="display: flex; gap: 5px;">
<form action="#" method="POST"> <form action="#" method="POST">
@csrf @csrf
@method("DELETE") @method("DELETE")
@ -48,6 +49,12 @@
</a> </a>
<button type="submit">Delete</button> <button type="submit">Delete</button>
</form> </form>
<form action="{{ route ('posts.pin', [ 'note' => $note ]) }}" method="POST">
@csrf
<button type="submit">{{ $note->is_pinned ($actor) ? "Unpin" : "Pin" }}</button>
</form>
</div>
@endif @endif
@if ($note->in_reply_to) @if ($note->in_reply_to)

View File

@ -320,6 +320,19 @@
<br> <br>
@if ($actor->get_pinned_posts ()->count () > 0)
<table class="comments-table" cellspacing="0" cellpadding="3" bordercollor="#ffffff" border="1">
<tbody>
<p><b>Pinned</b></p>
@foreach ($actor->get_pinned_posts () as $post)
<x-comment_block :post="$post" />
@endforeach
</tbody>
</table>
<hr>
@endif
<table class="comments-table" cellspacing="0" cellpadding="3" bordercollor="#ffffff" border="1"> <table class="comments-table" cellspacing="0" cellpadding="3" bordercollor="#ffffff" border="1">
<tbody> <tbody>
@foreach ($actor->get_posts () as $post) @foreach ($actor->get_posts () as $post)

View File

@ -23,6 +23,7 @@ Route::prefix ("/ap/v1")->group (function () {
Route::post ("/user/{user:name}/outbox", [ APOutboxController::class, "outbox" ])->name ("ap.outbox"); 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}/followers", [ APActorController::class, "followers" ])->name ("ap.followers");
Route::get ("/user/{user:name}/following", [ APActorController::class, "following" ])->name ("ap.following"); 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"); Route::get ("/user/{user:name}", [ APActorController::class, "user" ])->name ("ap.user");
// notes // notes

View File

@ -34,6 +34,7 @@ Route::middleware ("update_online")->group (function () {
Route::post ("/post/{note}/edit", [ PostController::class, "update" ])->middleware ("auth"); 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}/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}/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::get ("/post/{note}", [ PostController::class, "show" ])->name ("posts.show");
Route::delete ("/post/{note}", [ PostController::class, "delete" ])->name ("posts.delete")->middleware ("auth"); Route::delete ("/post/{note}", [ PostController::class, "delete" ])->name ("posts.delete")->middleware ("auth");