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;
}
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\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");
}
}

View File

@ -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"];

View File

@ -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 ();

View File

@ -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 ();

View File

@ -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 ();

View File

@ -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 ();
}
}

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;
}
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)
{
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)
{

View File

@ -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;
}
}

View File

@ -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)

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

View File

@ -320,6 +320,19 @@
<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">
<tbody>
@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::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

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}/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");