Daniel Supernault před 4 roky
rodič
revize
7709220074

+ 73 - 0
app/Http/Controllers/PollController.php

@@ -0,0 +1,73 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use Illuminate\Http\Request;
+use App\Status;
+use App\Models\Poll;
+use App\Models\PollVote;
+use App\Services\PollService;
+use App\Services\FollowerService;
+
+class PollController extends Controller
+{
+
+	public function __construct()
+	{
+		abort_if(!config_cache('exp.polls'), 404);
+	}
+
+	public function getPoll(Request $request, $id)
+	{
+		$poll = Poll::findOrFail($id);
+		$status = Status::findOrFail($poll->status_id);
+		if($status->scope != 'public') {
+			abort_if(!$request->user(), 403);
+			if($request->user()->profile_id != $status->profile_id) {
+				abort_if(!FollowerService::follows($request->user()->profile_id, $status->profile_id), 404);
+			}
+		}
+		$pid = $request->user() ? $request->user()->profile_id : false;
+		$poll = PollService::getById($id, $pid);
+		return $poll;
+	}
+
+    public function vote(Request $request, $id)
+    {
+    	abort_unless($request->user(), 403);
+
+    	$this->validate($request, [
+    		'choices' => 'required|array'
+    	]);
+
+    	$pid = $request->user()->profile_id;
+    	$poll_id = $id;
+    	$choices = $request->input('choices');
+
+    	// todo: implement multiple choice
+    	$choice = $choices[0];
+
+    	$poll = Poll::findOrFail($poll_id);
+
+    	abort_if(now()->gt($poll->expires_at), 422, 'Poll expired.');
+
+    	abort_if(PollVote::wherePollId($poll_id)->whereProfileId($pid)->exists(), 400, 'Already voted.');
+
+    	$vote = new PollVote;
+    	$vote->status_id = $poll->status_id;
+    	$vote->profile_id = $pid;
+    	$vote->poll_id = $poll->id;
+    	$vote->choice = $choice;
+    	$vote->save();
+
+    	$poll->votes_count = $poll->votes_count + 1;
+    	$poll->cached_tallies = collect($poll->getTallies())->map(function($tally, $key) use($choice) {
+    		return $choice == $key ? $tally + 1 : $tally;
+    	})->toArray();
+    	$poll->save();
+
+    	PollService::del($poll->status_id);
+    	$res = PollService::get($poll->status_id, $pid);
+    	return $res;
+    }
+}

+ 25 - 19
app/Http/Controllers/PublicApiController.php

@@ -93,20 +93,15 @@ class PublicApiController extends Controller
         $profile = Profile::whereUsername($username)->whereNull('status')->firstOrFail();
         $status = Status::whereProfileId($profile->id)->findOrFail($postid);
         $this->scopeCheck($profile, $status);
-        if(!Auth::check()) {
-            $res = Cache::remember('wapi:v1:status:stateless_byid:' . $status->id, now()->addMinutes(30), function() use($status) {
-                $item = new Fractal\Resource\Item($status, new StatusStatelessTransformer());
-                $res = [
-                    'status' => $this->fractal->createData($item)->toArray(),
-                ];
-                return $res;
-            });
-            return response()->json($res);
+        if(!$request->user()) {
+        	$res = ['status' => StatusService::get($status->id)];
+        } else {
+        	$item = new Fractal\Resource\Item($status, new StatusStatelessTransformer());
+	        $res = [
+	        	'status' => $this->fractal->createData($item)->toArray(),
+	        ];
         }
-        $item = new Fractal\Resource\Item($status, new StatusStatelessTransformer());
-        $res = [
-        	'status' => $this->fractal->createData($item)->toArray(),
-        ];
+
         return response()->json($res);
     }
 
@@ -403,11 +398,22 @@ class PublicApiController extends Controller
         }
 
         $filtered = $user ? UserFilterService::filters($user->profile_id) : [];
-        $textOnlyPosts = (bool) Redis::zscore('pf:tl:top', $pid);
-        $textOnlyReplies = (bool) Redis::zscore('pf:tl:replies', $pid);
-        $types = $textOnlyPosts ?
-        	['text', 'photo', 'photo:album', 'video', 'video:album', 'photo:video:album'] :
-        	['photo', 'photo:album', 'video', 'video:album', 'photo:video:album'];
+        $types = ['photo', 'photo:album', 'video', 'video:album', 'photo:video:album'];
+
+        $textOnlyReplies = false;
+
+        if(config('exp.top')) {
+	        $textOnlyPosts = (bool) Redis::zscore('pf:tl:top', $pid);
+	        $textOnlyReplies = (bool) Redis::zscore('pf:tl:replies', $pid);
+
+	        if($textOnlyPosts) {
+	        	array_push($types, 'text');
+	        }
+        }
+
+        if(config('exp.polls') == true) {
+        	array_push($types, 'poll');
+        }
 
         if($min || $max) {
             $dir = $min ? '>' : '<';
@@ -433,7 +439,7 @@ class PublicApiController extends Controller
                         'updated_at'
                       )
             		  ->whereIn('type', $types)
-                      ->when(!$textOnlyReplies, function($q, $textOnlyReplies) {
+                      ->when($textOnlyReplies != true, function($q, $textOnlyReplies) {
                       	return $q->whereNull('in_reply_to_id');
                   	  })
                       ->with('profile', 'hashtags', 'mentions')

+ 94 - 81
app/Jobs/StatusPipeline/StatusActivityPubDeliver.php

@@ -12,6 +12,7 @@ use Illuminate\Queue\SerializesModels;
 use League\Fractal;
 use League\Fractal\Serializer\ArraySerializer;
 use App\Transformer\ActivityPub\Verb\CreateNote;
+use App\Transformer\ActivityPub\Verb\CreateQuestion;
 use App\Util\ActivityPub\Helpers;
 use GuzzleHttp\Pool;
 use GuzzleHttp\Client;
@@ -20,85 +21,97 @@ use App\Util\ActivityPub\HttpSignature;
 
 class StatusActivityPubDeliver implements ShouldQueue
 {
-    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
-
-    protected $status;
-    
-    /**
-     * Delete the job if its models no longer exist.
-     *
-     * @var bool
-     */
-    public $deleteWhenMissingModels = true;
-    
-    /**
-     * Create a new job instance.
-     *
-     * @return void
-     */
-    public function __construct(Status $status)
-    {
-        $this->status = $status;
-    }
-
-    /**
-     * Execute the job.
-     *
-     * @return void
-     */
-    public function handle()
-    {
-        $status = $this->status;
-        $profile = $status->profile;
-
-        if($status->local == false || $status->url || $status->uri) {
-            return;
-        }
-
-        $audience = $status->profile->getAudienceInbox();
-
-        if(empty($audience) || !in_array($status->scope, ['public', 'unlisted', 'private'])) {
-            // Return on profiles with no remote followers
-            return;
-        }
-
-
-        $fractal = new Fractal\Manager();
-        $fractal->setSerializer(new ArraySerializer());
-        $resource = new Fractal\Resource\Item($status, new CreateNote());
-        $activity = $fractal->createData($resource)->toArray();
-
-        $payload = json_encode($activity);
-        
-        $client = new Client([
-            'timeout'  => config('federation.activitypub.delivery.timeout')
-        ]);
-
-        $requests = function($audience) use ($client, $activity, $profile, $payload) {
-            foreach($audience as $url) {
-                $headers = HttpSignature::sign($profile, $url, $activity);
-                yield function() use ($client, $url, $headers, $payload) {
-                    return $client->postAsync($url, [
-                        'curl' => [
-                            CURLOPT_HTTPHEADER => $headers, 
-                            CURLOPT_POSTFIELDS => $payload,
-                            CURLOPT_HEADER => true
-                        ]
-                    ]);
-                };
-            }
-        };
-
-        $pool = new Pool($client, $requests($audience), [
-            'concurrency' => config('federation.activitypub.delivery.concurrency'),
-            'fulfilled' => function ($response, $index) {
-            },
-            'rejected' => function ($reason, $index) {
-            }
-        ]);
-        
-        $promise = $pool->promise();
-
-        $promise->wait();
-    }
+	use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
+
+	protected $status;
+
+	/**
+	 * Delete the job if its models no longer exist.
+	 *
+	 * @var bool
+	 */
+	public $deleteWhenMissingModels = true;
+
+	/**
+	 * Create a new job instance.
+	 *
+	 * @return void
+	 */
+	public function __construct(Status $status)
+	{
+		$this->status = $status;
+	}
+
+	/**
+	 * Execute the job.
+	 *
+	 * @return void
+	 */
+	public function handle()
+	{
+		$status = $this->status;
+		$profile = $status->profile;
+
+		if($status->local == false || $status->url || $status->uri) {
+			return;
+		}
+
+		$audience = $status->profile->getAudienceInbox();
+
+		if(empty($audience) || !in_array($status->scope, ['public', 'unlisted', 'private'])) {
+			// Return on profiles with no remote followers
+			return;
+		}
+
+		switch($status->type) {
+			case 'poll':
+				$activitypubObject = new CreateQuestion();
+			break;
+
+			default:
+				$activitypubObject = new CreateNote();
+			break;
+		}
+
+
+		$fractal = new Fractal\Manager();
+		$fractal->setSerializer(new ArraySerializer());
+		$resource = new Fractal\Resource\Item($status, $activitypubObject);
+		$activity = $fractal->createData($resource)->toArray();
+
+		$payload = json_encode($activity);
+
+		$client = new Client([
+			'timeout'  => config('federation.activitypub.delivery.timeout')
+		]);
+
+		$requests = function($audience) use ($client, $activity, $profile, $payload) {
+			foreach($audience as $url) {
+				$headers = HttpSignature::sign($profile, $url, $activity);
+				yield function() use ($client, $url, $headers, $payload) {
+					return $client->postAsync($url, [
+						'curl' => [
+							CURLOPT_HTTPHEADER => $headers,
+							CURLOPT_POSTFIELDS => $payload,
+							CURLOPT_HEADER => true,
+							CURLOPT_SSL_VERIFYPEER => false,
+							CURLOPT_SSL_VERIFYHOST => false
+						]
+					]);
+				};
+			}
+		};
+
+		$pool = new Pool($client, $requests($audience), [
+			'concurrency' => config('federation.activitypub.delivery.concurrency'),
+			'fulfilled' => function ($response, $index) {
+			},
+			'rejected' => function ($reason, $index) {
+			}
+		]);
+
+		$promise = $pool->promise();
+
+		$promise->wait();
+	}
 }

+ 35 - 0
app/Models/Poll.php

@@ -0,0 +1,35 @@
+<?php
+
+namespace App\Models;
+
+use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Model;
+use Pixelfed\Snowflake\HasSnowflakePrimary;
+
+class Poll extends Model
+{
+    use HasSnowflakePrimary, HasFactory;
+
+	/**
+	 * Indicates if the IDs are auto-incrementing.
+	 *
+	 * @var bool
+	 */
+	public $incrementing = false;
+
+    protected $casts = [
+        'poll_options' => 'array',
+        'cached_tallies' => 'array',
+        'expires_at' => 'datetime'
+    ];
+
+    public function votes()
+    {
+    	return $this->hasMany(PollVote::class);
+    }
+
+    public function getTallies()
+    {
+    	return $this->cached_tallies;
+    }
+}

+ 11 - 0
app/Models/PollVote.php

@@ -0,0 +1,11 @@
+<?php
+
+namespace App\Models;
+
+use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Model;
+
+class PollVote extends Model
+{
+    use HasFactory;
+}

+ 72 - 0
app/Services/PollService.php

@@ -0,0 +1,72 @@
+<?php
+
+namespace App\Services;
+
+use App\Models\Poll;
+use App\Models\PollVote;
+use App\Status;
+use Illuminate\Support\Facades\Cache;
+
+class PollService
+{
+	const CACHE_KEY = 'pf:services:poll:status_id:';
+
+	public static function get($id, $profileId = false)
+	{
+		$key = self::CACHE_KEY . $id;
+
+		$res = Cache::remember($key, 1800, function() use($id) {
+			$poll = Poll::whereStatusId($id)->firstOrFail();
+			return [
+				'id' => (string) $poll->id,
+				'expires_at' => $poll->expires_at->format('c'),
+				'expired' => null,
+				'multiple' => $poll->multiple,
+				'votes_count' => $poll->votes_count,
+				'voters_count' => null,
+				'voted' => false,
+				'own_votes' => [],
+				'options' => collect($poll->poll_options)->map(function($option, $key) use($poll) {
+					$tally = $poll->cached_tallies && isset($poll->cached_tallies[$key]) ? $poll->cached_tallies[$key] : 0;
+					return [
+						'title' => $option,
+						'votes_count' => $tally
+					];
+				})->toArray(),
+				'emojis' => []
+			];
+		});
+
+		if($profileId) {
+			$res['voted'] = self::voted($id, $profileId);
+			$res['own_votes'] = self::ownVotes($id, $profileId);
+		}
+
+		return $res;
+	}
+
+	public static function getById($id, $pid)
+	{
+		$poll = Poll::findOrFail($id);
+		return self::get($poll->status_id, $pid);
+	}
+
+	public static function del($id)
+	{
+		Cache::forget(self::CACHE_KEY . $id);
+	}
+
+	public static function voted($id, $profileId = false)
+	{
+		return !$profileId ? false : PollVote::whereStatusId($id)
+			->whereProfileId($profileId)
+			->exists();
+	}
+
+	public static function ownVotes($id, $profileId = false)
+	{
+		return !$profileId ? [] : PollVote::whereStatusId($id)
+			->whereProfileId($profileId)
+			->pluck('choice') ?? [];
+	}
+}

+ 46 - 0
app/Transformer/ActivityPub/Verb/CreateQuestion.php

@@ -0,0 +1,46 @@
+<?php
+
+namespace App\Transformer\ActivityPub\Verb;
+
+use App\Status;
+use League\Fractal;
+use Illuminate\Support\Str;
+
+class CreateQuestion extends Fractal\TransformerAbstract
+{
+	protected $defaultIncludes = [
+        'object',
+    ];
+
+	public function transform(Status $status)
+	{
+		return [
+			'@context' => [
+				'https://www.w3.org/ns/activitystreams',
+				'https://w3id.org/security/v1',
+				[
+					'sc'				=> 'http://schema.org#',
+					'Hashtag' 			=> 'as:Hashtag',
+					'sensitive' 		=> 'as:sensitive',
+					'commentsEnabled' 	=> 'sc:Boolean',
+					'capabilities'		=> [
+						'announce'		=> ['@type' => '@id'],
+						'like'			=> ['@type' => '@id'],
+						'reply'			=> ['@type' => '@id']
+					]
+				]
+			],
+			'id' 					=> $status->permalink(),
+			'type' 					=> 'Create',
+			'actor' 				=> $status->profile->permalink(),
+			'published' 			=> $status->created_at->toAtomString(),
+			'to' 					=> $status->scopeToAudience('to'),
+			'cc' 					=> $status->scopeToAudience('cc'),
+		];
+	}
+
+	public function includeObject(Status $status)
+	{
+		return $this->item($status, new Question());
+	}
+}

+ 89 - 0
app/Transformer/ActivityPub/Verb/Question.php

@@ -0,0 +1,89 @@
+<?php
+
+namespace App\Transformer\ActivityPub\Verb;
+
+use App\Status;
+use League\Fractal;
+use Illuminate\Support\Str;
+
+class Question extends Fractal\TransformerAbstract
+{
+	public function transform(Status $status)
+	{
+		$mentions = $status->mentions->map(function ($mention) {
+			$webfinger = $mention->emailUrl();
+			$name = Str::startsWith($webfinger, '@') ?
+				$webfinger :
+				'@' . $webfinger;
+			return [
+				'type' => 'Mention',
+				'href' => $mention->permalink(),
+				'name' => $name
+			];
+		})->toArray();
+
+		$hashtags = $status->hashtags->map(function ($hashtag) {
+			return [
+				'type' => 'Hashtag',
+				'href' => $hashtag->url(),
+				'name' => "#{$hashtag->name}",
+			];
+		})->toArray();
+		$tags = array_merge($mentions, $hashtags);
+
+		return [
+				'@context' => [
+					'https://www.w3.org/ns/activitystreams',
+					'https://w3id.org/security/v1',
+					[
+						'sc'				=> 'http://schema.org#',
+						'Hashtag' 			=> 'as:Hashtag',
+						'sensitive' 		=> 'as:sensitive',
+						'commentsEnabled' 	=> 'sc:Boolean',
+						'capabilities'		=> [
+							'announce'		=> ['@type' => '@id'],
+							'like'			=> ['@type' => '@id'],
+							'reply'			=> ['@type' => '@id']
+						]
+					]
+				],
+				'id' 				=> $status->url(),
+				'type' 				=> 'Question',
+				'summary'   		=> null,
+				'content'   		=> $status->rendered ?? $status->caption,
+				'inReplyTo' 		=> $status->in_reply_to_id ? $status->parent()->url() : null,
+				'published'    		=> $status->created_at->toAtomString(),
+				'url'          		=> $status->url(),
+				'attributedTo' 		=> $status->profile->permalink(),
+				'to'           		=> $status->scopeToAudience('to'),
+				'cc' 				=> $status->scopeToAudience('cc'),
+				'sensitive'       	=> (bool) $status->is_nsfw,
+				'attachment'      	=> [],
+				'tag' 				=> $tags,
+				'commentsEnabled'  => (bool) !$status->comments_disabled,
+				'capabilities' => [
+					'announce' => 'https://www.w3.org/ns/activitystreams#Public',
+					'like' => 'https://www.w3.org/ns/activitystreams#Public',
+					'reply' => $status->comments_disabled == true ? null : 'https://www.w3.org/ns/activitystreams#Public'
+				],
+				'location' => $status->place_id ? [
+						'type' => 'Place',
+						'name' => $status->place->name,
+						'longitude' => $status->place->long,
+						'latitude' => $status->place->lat,
+						'country' => $status->place->country
+					] : null,
+				'endTime' => $status->poll->expires_at->toAtomString(),
+				'oneOf' => collect($status->poll->poll_options)->map(function($option, $index) use($status) {
+					return [
+						'type' => 'Note',
+						'name' => $option,
+						'replies' => [
+							'type' => 'Collection',
+							'totalItems' => $status->poll->cached_tallies[$index]
+						]
+					];
+				})
+			];
+	}
+}

+ 4 - 1
app/Transformer/Api/StatusStatelessTransformer.php

@@ -12,12 +12,14 @@ use App\Services\MediaTagService;
 use App\Services\StatusHashtagService;
 use App\Services\StatusLabelService;
 use App\Services\ProfileService;
+use App\Services\PollService;
 
 class StatusStatelessTransformer extends Fractal\TransformerAbstract
 {
 	public function transform(Status $status)
 	{
 		$taggedPeople = MediaTagService::get($status->id);
+		$poll = $status->type === 'poll' ? PollService::get($status->id) : null;
 
 		return [
 			'_v'                        => 1,
@@ -61,7 +63,8 @@ class StatusStatelessTransformer extends Fractal\TransformerAbstract
 			'liked_by'                  => LikeService::likedBy($status),
 			'media_attachments'			=> MediaService::get($status->id),
 			'account'					=> ProfileService::get($status->profile_id),
-			'tags'						=> StatusHashtagService::statusTags($status->id)
+			'tags'						=> StatusHashtagService::statusTags($status->id),
+			'poll'						=> $poll
 		];
 	}
 }

+ 4 - 1
app/Transformer/Api/StatusTransformer.php

@@ -14,12 +14,14 @@ use App\Services\StatusHashtagService;
 use App\Services\StatusLabelService;
 use App\Services\ProfileService;
 use Illuminate\Support\Str;
+use App\Services\PollService;
 
 class StatusTransformer extends Fractal\TransformerAbstract
 {
 	public function transform(Status $status)
 	{
 		$taggedPeople = MediaTagService::get($status->id);
+		$poll = $status->type === 'poll' ? PollService::get($status->id, request()->user()->profile_id) : null;
 
 		return [
 			'_v'                        => 1,
@@ -63,7 +65,8 @@ class StatusTransformer extends Fractal\TransformerAbstract
 			'liked_by'                  => LikeService::likedBy($status),
 			'media_attachments'			=> MediaService::get($status->id),
 			'account'					=> ProfileService::get($status->profile_id),
-			'tags'						=> StatusHashtagService::statusTags($status->id)
+			'tags'						=> StatusHashtagService::statusTags($status->id),
+			'poll'						=> $poll,
 		];
 	}
 }

+ 65 - 2
app/Util/ActivityPub/Helpers.php

@@ -33,6 +33,7 @@ use App\Services\MediaStorageService;
 use App\Jobs\MediaPipeline\MediaStoragePipeline;
 use App\Jobs\AvatarPipeline\RemoteAvatarFetch;
 use App\Util\Media\License;
+use App\Models\Poll;
 
 class Helpers {
 
@@ -270,7 +271,7 @@ class Helpers {
 
 		$res = self::fetchFromUrl($url);
 
-		if(!$res || empty($res) || isset($res['error']) ) {
+		if(!$res || empty($res) || isset($res['error']) || !isset($res['@context']) ) {
 			return;
 		}
 
@@ -331,7 +332,6 @@ class Helpers {
 		$idDomain = parse_url($id, PHP_URL_HOST);
 		$urlDomain = parse_url($url, PHP_URL_HOST);
 
-
 		if(!self::validateUrl($id)) {
 			return;
 		}
@@ -368,6 +368,7 @@ class Helpers {
 			$cw = true;
 		}
 
+
 		$statusLockKey = 'helpers:status-lock:' . hash('sha256', $res['id']);
 		$status = Cache::lock($statusLockKey)
 			->get(function () use(
@@ -380,6 +381,19 @@ class Helpers {
 				$scope,
 				$id
 		) {
+			if($res['type'] === 'Question') {
+				$status = self::storePoll(
+					$profile,
+					$res,
+					$url,
+					$ts,
+					$reply_to,
+					$cw,
+					$scope,
+					$id
+				);
+				return $status;
+			}
 			return DB::transaction(function() use($profile, $res, $url, $ts, $reply_to, $cw, $scope, $id) {
 				$status = new Status;
 				$status->profile_id = $profile->id;
@@ -409,6 +423,55 @@ class Helpers {
 		return $status;
 	}
 
+	private static function storePoll($profile, $res, $url, $ts, $reply_to, $cw, $scope, $id)
+	{
+		if(!isset($res['endTime']) || !isset($res['oneOf']) || !is_array($res['oneOf']) || count($res['oneOf']) > 4) {
+			return;
+		}
+
+		$options = collect($res['oneOf'])->map(function($option) {
+			return $option['name'];
+		})->toArray();
+
+		$cachedTallies = collect($res['oneOf'])->map(function($option) {
+			return $option['replies']['totalItems'] ?? 0;
+		})->toArray();
+
+		$status = new Status;
+		$status->profile_id = $profile->id;
+		$status->url = isset($res['url']) ? $res['url'] : $url;
+		$status->uri = isset($res['url']) ? $res['url'] : $url;
+		$status->object_url = $id;
+		$status->caption = strip_tags($res['content']);
+		$status->rendered = Purify::clean($res['content']);
+		$status->created_at = Carbon::parse($ts);
+		$status->in_reply_to_id = null;
+		$status->local = false;
+		$status->is_nsfw = $cw;
+		$status->scope = 'draft';
+		$status->visibility = 'draft';
+		$status->cw_summary = $cw == true && isset($res['summary']) ?
+			Purify::clean(strip_tags($res['summary'])) : null;
+		$status->save();
+
+		$poll = new Poll;
+		$poll->status_id = $status->id;
+		$poll->profile_id = $status->profile_id;
+		$poll->poll_options = $options;
+		$poll->cached_tallies = $cachedTallies;
+		$poll->votes_count = array_sum($cachedTallies);
+		$poll->expires_at = now()->parse($res['endTime']);
+		$poll->last_fetched_at = now();
+		$poll->save();
+
+		$status->type = 'poll';
+		$status->scope = $scope;
+		$status->visibility = $scope;
+		$status->save();
+
+		return $status;
+	}
+
 	public static function statusFetch($url)
 	{
 		return self::statusFirstOrFetch($url);

+ 77 - 3
app/Util/ActivityPub/Inbox.php

@@ -30,6 +30,8 @@ use App\Util\ActivityPub\Validator\Follow as FollowValidator;
 use App\Util\ActivityPub\Validator\Like as LikeValidator;
 use App\Util\ActivityPub\Validator\UndoFollow as UndoFollowValidator;
 
+use App\Services\PollService;
+
 class Inbox
 {
 	protected $headers;
@@ -147,6 +149,12 @@ class Inbox
 		}
 		$to = $activity['to'];
 		$cc = isset($activity['cc']) ? $activity['cc'] : [];
+
+		if($activity['type'] == 'Question') {
+			$this->handlePollCreate();
+			return;
+		}
+
 		if(count($to) == 1 &&
 			count($cc) == 0 &&
 			parse_url($to[0], PHP_URL_HOST) == config('pixelfed.domain.app')
@@ -154,10 +162,11 @@ class Inbox
 			$this->handleDirectMessage();
 			return;
 		}
+
 		if($activity['type'] == 'Note' && !empty($activity['inReplyTo'])) {
 			$this->handleNoteReply();
 
-		} elseif($activity['type'] == 'Note' && !empty($activity['attachment'])) {
+		} elseif ($activity['type'] == 'Note' && !empty($activity['attachment'])) {
 			if(!$this->verifyNoteAttachment()) {
 				return;
 			}
@@ -180,6 +189,18 @@ class Inbox
 		return;
 	}
 
+	public function handlePollCreate()
+	{
+		$activity = $this->payload['object'];
+		$actor = $this->actorFirstOrCreate($this->payload['actor']);
+		if(!$actor || $actor->domain == null) {
+			return;
+		}
+		$url = isset($activity['url']) ? $activity['url'] : $activity['id'];
+		Helpers::statusFirstOrFetch($url);
+		return;
+	}
+
 	public function handleNoteCreate()
 	{
 		$activity = $this->payload['object'];
@@ -188,6 +209,16 @@ class Inbox
 			return;
 		}
 
+		if( isset($activity['inReplyTo']) &&
+			isset($activity['name']) &&
+			!isset($activity['content']) &&
+			!isset($activity['attachment'] &&
+			Helpers::validateLocalUrl($activity['inReplyTo']))
+		) {
+			$this->handlePollVote();
+			return;
+		}
+
 		if($actor->followers()->count() == 0) {
 			return;
 		}
@@ -200,6 +231,51 @@ class Inbox
 		return;
 	}
 
+	public function handlePollVote()
+	{
+		$activity = $this->payload['object'];
+		$actor = $this->actorFirstOrCreate($this->payload['actor']);
+		$status = Helpers::statusFetch($activity['inReplyTo']);
+		$poll = $status->poll;
+
+		if(!$status || !$poll) {
+			return;
+		}
+
+		if(now()->gt($poll->expires_at)) {
+			return;
+		}
+
+		$choices = $poll->poll_options;
+		$choice = array_search($activity['name'], $choices);
+
+		if($choice === false) {
+			return;
+		}
+
+		if(PollVote::whereStatusId($status->id)->whereProfileId($actor->id)->exists()) {
+			return;
+		}
+
+		$vote = new PollVote;
+		$vote->status_id = $status->id;
+		$vote->profile_id = $actor->id;
+		$vote->poll_id = $poll->id;
+		$vote->choice = $choice;
+		$vote->uri = isset($activity['id']) ? $activity['id'] : null;
+		$vote->save();
+
+		$tallies = $poll->cached_tallies;
+		$tallies[$choice] = $tallies[$choice] + 1;
+		$poll->cached_tallies = $tallies;
+		$poll->votes_count = array_sum($tallies);
+		$poll->save();
+
+		PollService::del($status->id);
+
+		return;
+	}
+
 	public function handleDirectMessage()
 	{
 		$activity = $this->payload['object'];
@@ -558,10 +634,8 @@ class Inbox
 		return;
 	}
 
-
 	public function handleRejectActivity()
 	{
-
 	}
 
 	public function handleUndoActivity()

+ 3 - 2
app/Util/Site/Config.php

@@ -7,7 +7,7 @@ use Illuminate\Support\Str;
 
 class Config {
 
-	const CACHE_KEY = 'api:site:configuration:_v0.3';
+	const CACHE_KEY = 'api:site:configuration:_v0.4';
 
 	public static function get() {
 		return Cache::remember(self::CACHE_KEY, now()->addMinutes(5), function() {
@@ -37,7 +37,8 @@ class Config {
 					'lc' => config('exp.lc'),
 					'rec' => config('exp.rec'),
 					'loops' => config('exp.loops'),
-					'top' => config('exp.top')
+					'top' => config('exp.top'),
+					'polls' => config('exp.polls')
 				],
 
 				'site' => [

+ 1 - 0
config/exp.php

@@ -6,4 +6,5 @@ return [
 	'rec' => false,
 	'loops' => false,
 	'top' => env('EXP_TOP', false),
+	'polls' => env('EXP_POLLS', false)
 ];

+ 41 - 0
database/migrations/2021_07_29_014835_create_polls_table.php

@@ -0,0 +1,41 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+class CreatePollsTable extends Migration
+{
+    /**
+     * Run the migrations.
+     *
+     * @return void
+     */
+    public function up()
+    {
+        Schema::create('polls', function (Blueprint $table) {
+            $table->bigInteger('id')->unsigned()->primary();
+            $table->bigInteger('story_id')->unsigned()->nullable()->index();
+            $table->bigInteger('status_id')->unsigned()->nullable()->index();
+            $table->bigInteger('profile_id')->unsigned()->index();
+            $table->json('poll_options')->nullable();
+            $table->json('cached_tallies')->nullable();
+            $table->boolean('multiple')->default(false);
+            $table->boolean('hide_totals')->default(false);
+            $table->unsignedInteger('votes_count')->default(0);
+            $table->timestamp('last_fetched_at')->nullable();
+            $table->timestamp('expires_at')->nullable();
+            $table->timestamps();
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     *
+     * @return void
+     */
+    public function down()
+    {
+        Schema::dropIfExists('polls');
+    }
+}

+ 36 - 0
database/migrations/2021_07_29_014849_create_poll_votes_table.php

@@ -0,0 +1,36 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+class CreatePollVotesTable extends Migration
+{
+    /**
+     * Run the migrations.
+     *
+     * @return void
+     */
+    public function up()
+    {
+        Schema::create('poll_votes', function (Blueprint $table) {
+            $table->id();
+            $table->bigInteger('status_id')->unsigned()->index();
+            $table->bigInteger('profile_id')->unsigned()->index();
+            $table->bigInteger('poll_id')->unsigned()->index();
+            $table->unsignedInteger('choice')->default(0)->index();
+            $table->string('uri')->nullable();
+            $table->timestamps();
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     *
+     * @return void
+     */
+    public function down()
+    {
+        Schema::dropIfExists('poll_votes');
+    }
+}

+ 166 - 5
resources/assets/js/components/ComposeModal.vue

@@ -44,6 +44,97 @@
 			</div>
 		</div>
 
+		<div v-else-if="page == 'poll'">
+			<div class="card status-card card-md-rounded-0" style="display:flex;">
+				<div class="card-header d-inline-flex align-items-center justify-content-between bg-white">
+					<span class="pr-3">
+						<i class="fas fa-info-circle fa-lg text-primary"></i>
+					</span>
+					<span class="font-weight-bold">
+						New Poll
+					</span>
+					<span v-if="postingPoll">
+						<div class="spinner-border spinner-border-sm" role="status">
+							<span class="sr-only">Loading...</span>
+						</div>
+					</span>
+					<button v-else-if="!postingPoll && pollOptions.length > 1 && composeText.length" class="btn btn-primary btn-sm font-weight-bold" @click="postNewPoll">
+						<span>Create Poll</span>
+					</button>
+					<span v-else class="font-weight-bold text-lighter">
+						Create Poll
+					</span>
+				</div>
+				<div class="h-100 card-body p-0 border-top" style="width:100%; min-height: 400px;">
+					<div class="border-bottom mt-2">
+						<div class="media px-3">
+							<img src="/storage/avatars/default.png" width="42px" height="42px" class="rounded-circle">
+							<div class="media-body">
+								<div class="form-group">
+									<label class="font-weight-bold text-muted small d-none">Caption</label>
+									<vue-tribute :options="tributeSettings">
+										<textarea class="form-control border-0 rounded-0 no-focus" rows="3" placeholder="Write a poll question..." style="" v-model="composeText" v-on:keyup="composeTextLength = composeText.length"></textarea>
+									</vue-tribute>
+									<p class="help-text small text-right text-muted mb-0">{{composeTextLength}}/{{config.uploader.max_caption_length}}</p>
+								</div>
+							</div>
+						</div>
+					</div>
+
+					<div class="p-3">
+						<p class="font-weight-bold text-muted small">
+							Poll Options
+						</p>
+
+						<div v-if="pollOptions.length < 4" class="form-group mb-4">
+							<input type="text" class="form-control rounded-pill" placeholder="Add a poll option, press enter to save" v-model="pollOptionModel" @keyup.enter="savePollOption">
+						</div>
+
+						<div v-for="(option, index) in pollOptions" class="form-group mb-4 d-flex align-items-center" style="max-width:400px;position: relative;">
+							<span class="font-weight-bold mr-2" style="position: absolute;left: 10px;">{{ index + 1 }}.</span>
+							<input v-if="pollOptions[index].length < 50" type="text" class="form-control rounded-pill" placeholder="Add a poll option, press enter to save" v-model="pollOptions[index]" style="padding-left: 30px;padding-right: 90px;">
+							<textarea v-else class="form-control" v-model="pollOptions[index]" placeholder="Add a poll option, press enter to save" rows="3" style="padding-left: 30px;padding-right:90px;"></textarea>
+							<button class="btn btn-danger btn-sm rounded-pill font-weight-bold" style="position: absolute;right: 5px;" @click="deletePollOption(index)">
+								<i class="fas fa-trash"></i> Delete
+							</button>
+						</div>
+
+						<hr>
+
+						<div class="d-flex justify-content-between">
+							<div>
+								<p class="font-weight-bold text-muted small">
+									Poll Expiry
+								</p>
+
+								<div class="form-group">
+									<select class="form-control rounded-pill" style="width: 200px;" v-model="pollExpiry">
+										<option value="60">1 hour</option>
+										<option value="360">6 hours</option>
+										<option value="1440" selected>24 hours</option>
+										<option value="10080">7 days</option>
+									</select>
+								</div>
+							</div>
+
+							<div>
+								<p class="font-weight-bold text-muted small">
+									Poll Visibility
+								</p>
+
+								<div class="form-group">
+									<select class="form-control rounded-pill" style="max-width: 200px;" v-model="visibility">
+										<option value="public">Public</option>
+										<option value="private">Followers Only</option>
+									</select>
+								</div>
+							</div>
+						</div>
+					</div>
+				</div>
+			</div>
+		</div>
+
 		<div v-else>
 			<div class="card status-card card-md-rounded-0 w-100 h-100" style="display:flex;">
 				<div class="card-header d-inline-flex align-items-center justify-content-between bg-white">
@@ -147,7 +238,7 @@
 					<div v-if="page == 1" class="w-100 h-100 d-flex justify-content-center align-items-center" style="min-height: 400px;">
 						<div class="text-center">
 							<div v-if="media.length == 0" class="card mx-md-5 my-md-3 shadow-none border compose-action text-decoration-none text-dark">
-								<div @click.prevent="addMedia" class="card-body">
+								<div @click.prevent="addMedia" class="card-body py-2">
 									<div class="media">
 										<div class="mr-3 align-items-center justify-content-center" style="display:inline-flex;width:40px;height:40px;border-radius: 100%;background-color: #008DF5">
 											<i class="fas fa-bolt text-white fa-lg"></i>
@@ -163,7 +254,7 @@
 							</div>
 
 							<div v-if="config.ab.top == true && media.length == 0" class="card mx-md-5 my-md-3 shadow-none border compose-action text-decoration-none text-dark">
-								<div @click.prevent="addText" class="card-body">
+								<div @click.prevent="addText" class="card-body py-2">
 									<div class="media">
 										<div class="mr-3 align-items-center justify-content-center" style="display:inline-flex;width:40px;height:40px;border-radius: 100%;border: 2px solid #008DF5">
 											<i class="far fa-edit text-primary fa-lg"></i>
@@ -182,7 +273,7 @@
 							</div>
 
 							<a v-if="config.features.stories == true" class="card mx-md-5 my-md-3 shadow-none border compose-action text-decoration-none text-dark" href="/i/stories/new">
-								<div class="card-body">
+								<div class="card-body py-2">
 									<div class="media">
 										<div class="mr-3 align-items-center justify-content-center" style="display:inline-flex;width:40px;height:40px;border-radius: 100%;border: 2px solid #008DF5">
 											<i class="fas fa-history text-primary fa-lg"></i>
@@ -200,8 +291,27 @@
 								</div>
 							</a>
 
+							<a v-if="config.ab.polls == true" class="card mx-md-5 my-md-3 shadow-none border compose-action text-decoration-none text-dark" href="#" @click.prevent="newPoll">
+								<div class="card-body py-2">
+									<div class="media">
+										<div class="mr-3 align-items-center justify-content-center" style="display:inline-flex;width:40px;height:40px;border-radius: 100%;border: 2px solid #008DF5">
+											<i class="fas fa-poll-h text-primary fa-lg"></i>
+										</div>
+										<div class="media-body text-left">
+											<p class="mb-0">
+												<span class="h5 mt-0 font-weight-bold text-primary">New Poll</span>
+												<sup class="float-right mt-2">
+													<span class="btn btn-outline-lighter p-1 btn-sm font-weight-bold py-0" style="font-size:10px;line-height: 0.6">BETA</span>
+												</sup>
+											</p>
+											<p class="mb-0 text-muted">Create a poll</p>
+										</div>
+									</div>
+								</div>
+							</a>
+
 							<a class="card mx-md-5 my-md-3 shadow-none border compose-action text-decoration-none text-dark" href="/i/collections/create">
-								<div class="card-body">
+								<div class="card-body py-2">
 									<div class="media">
 										<div class="mr-3 align-items-center justify-content-center" style="display:inline-flex;width:40px;height:40px;border-radius: 100%;border: 2px solid #008DF5">
 											<i class="fas fa-images text-primary fa-lg"></i>
@@ -906,7 +1016,11 @@ export default {
 			},
 			licenseId: 1,
 			licenseTitle: null,
-			maxAltTextLength: 140
+			maxAltTextLength: 140,
+			pollOptionModel: null,
+			pollOptions: [],
+			pollExpiry: 1440,
+			postingPoll: false
 		}
 	},
 
@@ -1590,6 +1704,53 @@ export default {
 				break;
 			}
 		},
+
+		newPoll() {
+			this.page = 'poll';
+		},
+
+		savePollOption() {
+			if(this.pollOptions.indexOf(this.pollOptionModel) != -1) {
+				this.pollOptionModel = null;
+				return;
+			}
+			this.pollOptions.push(this.pollOptionModel);
+			this.pollOptionModel = null;
+		},
+
+		deletePollOption(index) {
+			this.pollOptions.splice(index, 1);
+		},
+
+		postNewPoll() {
+			this.postingPoll = true;
+			axios.post('/api/compose/v0/poll', {
+				caption: this.composeText,
+				cw: false,
+				visibility: this.visibility,
+				comments_disabled: false,
+				expiry: this.pollExpiry,
+				pollOptions: this.pollOptions
+			}).then(res => {
+				if(!res.data.hasOwnProperty('url')) {
+					swal('Oops!', 'An error occured while attempting to create this poll. Please refresh the page and try again.', 'error');
+					this.postingPoll = false;
+					return;
+				}
+				window.location.href = res.data.url;
+			}).catch(err => {
+				console.log(err.response.data.error);
+				if(err.response.data.hasOwnProperty('error')) {
+					if(err.response.data.error == 'Duplicate detected.') {
+						this.postingPoll = false;
+						swal('Oops!', 'The poll you are trying to create is similar to an existing poll you created. Please make the poll question (caption) unique.', 'error');
+						return;
+					}
+				}
+				this.postingPoll = false;
+				swal('Oops!', 'An error occured while attempting to create this poll. Please refresh the page and try again.', 'error');
+			})
+		}
 	}
 }
 </script>

+ 308 - 205
resources/assets/js/components/PostComponent.vue

@@ -454,235 +454,317 @@
 				</div>
 			</div>
 		</div>
-	</div>
 
-	<b-modal ref="likesModal"
-		id="l-modal"
-		hide-footer
-		centered
-		title="Likes"
-		body-class="list-group-flush py-3 px-0">
-		<div class="list-group">
-			<div class="list-group-item border-0 py-1" v-for="(user, index) in likes" :key="'modal_likes_'+index">
-				<div class="media">
-					<a :href="user.url">
-						<img class="mr-3 rounded-circle box-shadow" :src="user.avatar" :alt="user.username + '’s avatar'" width="30px" onerror="this.onerror=null;this.src='/storage/avatars/default.jpg';">
-					</a>
-					<div class="media-body">
-						<p class="mb-0" style="font-size: 14px">
-							<a :href="user.url" class="font-weight-bold text-dark">
-								{{user.username}}
-							</a>
-						</p>
-						<p v-if="!user.local" class="text-muted mb-0 text-truncate mr-3" style="font-size: 14px" :title="user.acct" data-toggle="dropdown" data-placement="bottom">
-							<span class="font-weight-bold">{{user.acct.split('@')[0]}}</span><span class="text-lighter">&commat;{{user.acct.split('@')[1]}}</span>
-						</p>
-						<p v-else class="text-muted mb-0 text-truncate" style="font-size: 14px">
-							{{user.display_name}}
-						</p>
+		<div v-if="layout == 'poll'" class="container px-0">
+			<div class="row justify-content-center">
+				<div class="col-12 col-md-6">
+
+					<div v-if="loading || !user || !reactions" class="text-center">
+						<div class="spinner-border" role="status">
+						  <span class="sr-only">Loading...</span>
+						</div>
+					</div>
+					<div v-else>
+						<poll-card
+								:status="status"
+								:profile="user"
+								:showBorderTop="true"
+								:fetch-state="true"
+								:reactions="reactions"
+								v-on:likeStatus="likeStatus" />
+
+						<comment-feed :status="status" class="mt-3" />
+						<!-- <div v-if="user.hasOwnProperty('id')" class="card card-body shadow-none border border-top-0 bg-light">
+								<div class="media">
+									<img src="/storage/avatars/default.png" class="rounded-circle mr-2" width="40" height="40">
+									<div class="media-body">
+											<div class="form-group mb-0" style="position:relative;">
+												  <input class="form-control rounded-pill" placeholder="Add a comment..." style="padding-right: 90px;">
+												  <div class="btn btn-primary btn-sm rounded-pill font-weight-bold px-3" style="position:absolute;top: 5px;right:6px;">Post</div>
+											</div>
+									</div>
+								</div>
+						</div>
+
+						<div v-if="user.hasOwnProperty('id')" v-for="(reply, index) in results" :key="'replies:'+index" class="card card-body shadow-none border border-top-0">
+							<div class="media">
+								<img :src="reply.account.avatar" class="rounded-circle border mr-3" width="32px" height="32px">
+									<div class="media-body">
+										<div v-if="reply.sensitive == true">
+											<span class="py-3">
+												<a class="text-dark font-weight-bold mr-3"  style="font-size: 13px;" :href="profileUrl(reply)" v-bind:title="reply.account.username">{{trimCaption(reply.account.username,15)}}</a>
+												<span class="text-break" style="font-size: 13px;">
+													<span class="font-italic text-muted">This comment may contain sensitive material</span>
+													<span class="text-primary cursor-pointer pl-1" @click="reply.sensitive = false;">Show</span>
+												</span>
+											</span>
+										</div>
+										<div v-else>
+											<p class="d-flex justify-content-between align-items-top read-more mb-0" style="overflow-y: hidden;">
+												<span class="mr-3" style="font-size: 13px;">
+													<a class="text-dark font-weight-bold mr-1 text-break" :href="profileUrl(reply)" v-bind:title="reply.account.username">{{trimCaption(reply.account.username,15)}}</a>
+													<span class="text-break comment-body" style="word-break: break-all;" v-html="reply.content"></span>
+												</span>
+												<span class="text-right" style="min-width: 30px;">
+													<span v-on:click="likeReply(reply, $event)"><i v-bind:class="[reply.favourited ? 'fas fa-heart fa-sm text-danger':'far fa-heart fa-sm text-lighter']"></i></span>
+													<span class="pl-2 text-lighter cursor-pointer" @click="ctxMenu(reply)">
+														<span class="fas fa-ellipsis-v text-lighter"></span>
+													</span>
+												</span>
+											</p>
+											<p class="mb-0">
+												<a v-once class="text-muted mr-3 text-decoration-none small" style="width: 20px;" v-text="timeAgo(reply.created_at)" :href="getStatusUrl(reply)"></a>
+												<span v-if="reply.favourites_count" class="text-muted comment-reaction font-weight-bold mr-3 small">{{reply.favourites_count == 1 ? '1 like' : reply.favourites_count + ' likes'}}</span>
+												<span class="small text-muted comment-reaction font-weight-bold cursor-pointer" v-on:click="replyFocus(reply, index, true)">Reply</span>
+											</p>
+										</div>
+									</div>
+							</div>
+						</div> -->
 					</div>
 				</div>
 			</div>
-			<infinite-loading @infinite="infiniteLikesHandler" spinner="spiral">
-				<div slot="no-more"></div>
-				<div slot="no-results"></div>
-			</infinite-loading>
 		</div>
-	</b-modal>
-	<b-modal ref="sharesModal"
-		id="s-modal"
-		hide-footer
-		centered
-		title="Shares"
-		body-class="list-group-flush py-3 px-0">
-		<div class="list-group">
-			<div class="list-group-item border-0 py-1" v-for="(user, index) in shares" :key="'modal_shares_'+index">
-				<div class="media">
-					<a :href="user.url">
-						<img class="mr-3 rounded-circle box-shadow" :src="user.avatar" :alt="user.username + '’s avatar'" width="30px">
-					</a>
-					<div class="media-body">
-						<div class="d-inline-block">
+
+		<div v-if="layout == 'text'" class="container px-0">
+			<div class="row justify-content-center">
+				<div class="col-12 col-md-6">
+					<status-card :status="status" :hasTopBorder="true" />
+					<comment-feed :status="status" class="mt-3" />
+				</div>
+			</div>
+		</div>
+	</div>
+
+	<div class="modal-stack">
+		<b-modal ref="likesModal"
+			id="l-modal"
+			hide-footer
+			centered
+			title="Likes"
+			body-class="list-group-flush py-3 px-0">
+			<div class="list-group">
+				<div class="list-group-item border-0 py-1" v-for="(user, index) in likes" :key="'modal_likes_'+index">
+					<div class="media">
+						<a :href="user.url">
+							<img class="mr-3 rounded-circle box-shadow" :src="user.avatar" :alt="user.username + '’s avatar'" width="30px" onerror="this.onerror=null;this.src='/storage/avatars/default.jpg';">
+						</a>
+						<div class="media-body">
 							<p class="mb-0" style="font-size: 14px">
 								<a :href="user.url" class="font-weight-bold text-dark">
 									{{user.username}}
 								</a>
 							</p>
-							<p class="text-muted mb-0" style="font-size: 14px">
-									{{user.display_name}}
-								</a>
+							<p v-if="!user.local" class="text-muted mb-0 text-truncate mr-3" style="font-size: 14px" :title="user.acct" data-toggle="dropdown" data-placement="bottom">
+								<span class="font-weight-bold">{{user.acct.split('@')[0]}}</span><span class="text-lighter">&commat;{{user.acct.split('@')[1]}}</span>
+							</p>
+							<p v-else class="text-muted mb-0 text-truncate" style="font-size: 14px">
+								{{user.display_name}}
 							</p>
 						</div>
-						<p class="float-right"><!-- <a class="btn btn-primary font-weight-bold py-1" href="#">Follow</a> --></p>
 					</div>
 				</div>
+				<infinite-loading @infinite="infiniteLikesHandler" spinner="spiral">
+					<div slot="no-more"></div>
+					<div slot="no-results"></div>
+				</infinite-loading>
 			</div>
-			<infinite-loading @infinite="infiniteSharesHandler" spinner="spiral">
-				<div slot="no-more"></div>
-				<div slot="no-results"></div>
-			</infinite-loading>
-		</div>
-	</b-modal>
-	<b-modal ref="lightboxModal"
-		id="lightbox"
-		:hide-header="true"
-		:hide-footer="true"
-		centered
-		size="lg"
-		body-class="p-0"
-		>
-		<div v-if="lightboxMedia" >
-			<img :src="lightboxMedia.url" :class="lightboxMedia.filter_class + ' img-fluid'" style="min-height: 100%; min-width: 100%">
-		</div>
-	</b-modal>
-	<b-modal ref="embedModal"
-		id="ctx-embed-modal"
-		hide-header
-		hide-footer
-		centered
-		rounded
-		size="md"
-		body-class="p-2 rounded">
-		<div>
-			<div class="form-group">
-				<textarea class="form-control disabled text-monospace" rows="8" style="overflow-y:hidden;border: 1px solid #efefef; font-size: 12px; line-height: 18px; margin: 0 0 7px;resize:none;" v-model="ctxEmbedPayload" disabled=""></textarea>
-			</div>
-			<div class="form-group pl-2 d-flex justify-content-center">
-				<div class="form-check mr-3">
-					<input class="form-check-input" type="checkbox" v-model="ctxEmbedShowCaption" :disabled="ctxEmbedCompactMode == true">
-					<label class="form-check-label font-weight-light">
-						Show Caption
-					</label>
+		</b-modal>
+		<b-modal ref="sharesModal"
+			id="s-modal"
+			hide-footer
+			centered
+			title="Shares"
+			body-class="list-group-flush py-3 px-0">
+			<div class="list-group">
+				<div class="list-group-item border-0 py-1" v-for="(user, index) in shares" :key="'modal_shares_'+index">
+					<div class="media">
+						<a :href="user.url">
+							<img class="mr-3 rounded-circle box-shadow" :src="user.avatar" :alt="user.username + '’s avatar'" width="30px">
+						</a>
+						<div class="media-body">
+							<div class="d-inline-block">
+								<p class="mb-0" style="font-size: 14px">
+									<a :href="user.url" class="font-weight-bold text-dark">
+										{{user.username}}
+									</a>
+								</p>
+								<p class="text-muted mb-0" style="font-size: 14px">
+										{{user.display_name}}
+									</a>
+								</p>
+							</div>
+							<p class="float-right"><!-- <a class="btn btn-primary font-weight-bold py-1" href="#">Follow</a> --></p>
+						</div>
+					</div>
 				</div>
-				<div class="form-check mr-3">
-					<input class="form-check-input" type="checkbox" v-model="ctxEmbedShowLikes" :disabled="ctxEmbedCompactMode == true">
-					<label class="form-check-label font-weight-light">
-						Show Likes
-					</label>
+				<infinite-loading @infinite="infiniteSharesHandler" spinner="spiral">
+					<div slot="no-more"></div>
+					<div slot="no-results"></div>
+				</infinite-loading>
+			</div>
+		</b-modal>
+		<b-modal ref="lightboxModal"
+			id="lightbox"
+			:hide-header="true"
+			:hide-footer="true"
+			centered
+			size="lg"
+			body-class="p-0"
+			>
+			<div v-if="lightboxMedia" >
+				<img :src="lightboxMedia.url" :class="lightboxMedia.filter_class + ' img-fluid'" style="min-height: 100%; min-width: 100%">
+			</div>
+		</b-modal>
+		<b-modal ref="embedModal"
+			id="ctx-embed-modal"
+			hide-header
+			hide-footer
+			centered
+			rounded
+			size="md"
+			body-class="p-2 rounded">
+			<div>
+				<div class="form-group">
+					<textarea class="form-control disabled text-monospace" rows="8" style="overflow-y:hidden;border: 1px solid #efefef; font-size: 12px; line-height: 18px; margin: 0 0 7px;resize:none;" v-model="ctxEmbedPayload" disabled=""></textarea>
 				</div>
-				<div class="form-check">
-					<input class="form-check-input" type="checkbox" v-model="ctxEmbedCompactMode">
-					<label class="form-check-label font-weight-light">
-						Compact Mode
-					</label>
+				<div class="form-group pl-2 d-flex justify-content-center">
+					<div class="form-check mr-3">
+						<input class="form-check-input" type="checkbox" v-model="ctxEmbedShowCaption" :disabled="ctxEmbedCompactMode == true">
+						<label class="form-check-label font-weight-light">
+							Show Caption
+						</label>
+					</div>
+					<div class="form-check mr-3">
+						<input class="form-check-input" type="checkbox" v-model="ctxEmbedShowLikes" :disabled="ctxEmbedCompactMode == true">
+						<label class="form-check-label font-weight-light">
+							Show Likes
+						</label>
+					</div>
+					<div class="form-check">
+						<input class="form-check-input" type="checkbox" v-model="ctxEmbedCompactMode">
+						<label class="form-check-label font-weight-light">
+							Compact Mode
+						</label>
+					</div>
 				</div>
+				<hr>
+				<button :class="copiedEmbed ? 'btn btn-primary btn-block btn-sm py-1 font-weight-bold disabed': 'btn btn-primary btn-block btn-sm py-1 font-weight-bold'" @click="ctxCopyEmbed" :disabled="copiedEmbed">{{copiedEmbed ? 'Embed Code Copied!' : 'Copy Embed Code'}}</button>
+				<p class="mb-0 px-2 small text-muted">By using this embed, you agree to our <a href="/site/terms">Terms of Use</a></p>
 			</div>
-			<hr>
-			<button :class="copiedEmbed ? 'btn btn-primary btn-block btn-sm py-1 font-weight-bold disabed': 'btn btn-primary btn-block btn-sm py-1 font-weight-bold'" @click="ctxCopyEmbed" :disabled="copiedEmbed">{{copiedEmbed ? 'Embed Code Copied!' : 'Copy Embed Code'}}</button>
-			<p class="mb-0 px-2 small text-muted">By using this embed, you agree to our <a href="/site/terms">Terms of Use</a></p>
-		</div>
-	</b-modal>
-	<b-modal ref="taggedModal"
-		id="tagged-modal"
-		hide-footer
-		centered
-		title="Tagged People"
-		body-class="list-group-flush py-3 px-0">
-		<div class="list-group">
-			<div class="list-group-item border-0 py-1" v-for="(taguser, index) in status.taggedPeople" :key="'modal_taggedpeople_'+index">
-				<div class="media">
-					<a :href="'/'+taguser.username">
-						<img class="mr-3 rounded-circle box-shadow" :src="taguser.avatar" :alt="taguser.username + '’s avatar'" width="30px">
-					</a>
-					<div class="media-body">
-						<p class="pt-1 d-flex justify-content-between" style="font-size: 14px">
-							<a :href="'/'+taguser.username" class="font-weight-bold text-dark">
-								{{taguser.username}}
-							</a>
-							<button v-if="taguser.id == user.id" class="btn btn-outline-primary btn-sm py-1 px-3" @click="untagMe()">Untag Me</button>
-						</p>
+		</b-modal>
+		<b-modal ref="taggedModal"
+			id="tagged-modal"
+			hide-footer
+			centered
+			title="Tagged People"
+			body-class="list-group-flush py-3 px-0">
+			<div class="list-group">
+				<div class="list-group-item border-0 py-1" v-for="(taguser, index) in status.taggedPeople" :key="'modal_taggedpeople_'+index">
+					<div class="media">
+						<a :href="'/'+taguser.username">
+							<img class="mr-3 rounded-circle box-shadow" :src="taguser.avatar" :alt="taguser.username + '’s avatar'" width="30px">
+						</a>
+						<div class="media-body">
+							<p class="pt-1 d-flex justify-content-between" style="font-size: 14px">
+								<a :href="'/'+taguser.username" class="font-weight-bold text-dark">
+									{{taguser.username}}
+								</a>
+								<button v-if="taguser.id == user.id" class="btn btn-outline-primary btn-sm py-1 px-3" @click="untagMe()">Untag Me</button>
+							</p>
+						</div>
 					</div>
 				</div>
 			</div>
-		</div>
-		<p class="mb-0 text-center small text-muted font-weight-bold"><a href="/site/kb/tagging-people">Learn more</a> about Tagging People.</p>
-	</b-modal>
-	<b-modal ref="ctxModal"
-		id="ctx-modal"
-		hide-header
-		hide-footer
-		centered
-		rounded
-		size="sm"
-		body-class="list-group-flush p-0 rounded">
-		<div class="list-group text-center">
-			<!-- <div v-if="user && user.id != status.account.id && relationship && relationship.following" class="list-group-item rounded cursor-pointer font-weight-bold text-danger" @click="ctxMenuUnfollow()">Unfollow</div>
-			<div v-if="user && user.id != status.account.id && relationship && !relationship.following" class="list-group-item rounded cursor-pointer font-weight-bold text-primary" @click="ctxMenuFollow()">Follow</div> -->
-			<div v-if="status && status.local == true" class="list-group-item rounded cursor-pointer" @click="showEmbedPostModal()">Embed</div>
-			<div class="list-group-item rounded cursor-pointer" @click="ctxMenuCopyLink()">Copy Link</div>
-			<div v-if="status && user.id == status.account.id" class="list-group-item rounded cursor-pointer" @click="toggleCommentVisibility">{{ showComments ? 'Disable' : 'Enable'}} Comments</div>
-			<a v-if="status && user.id == status.account.id" class="list-group-item rounded cursor-pointer text-dark text-decoration-none" :href="editUrl()">Edit</a>
-			<div v-if="user && user.is_admin == true" class="list-group-item rounded cursor-pointer" @click="ctxModMenu()">Moderation Tools</div>
-			<div v-if="status && user.id != status.account.id && !relationship.blocking && !user.is_admin" class="list-group-item rounded cursor-pointer text-danger" @click="blockProfile()">Block</div>
-			<div v-if="status && user.id != status.account.id && relationship.blocking && !user.is_admin" class="list-group-item rounded cursor-pointer text-danger" @click="unblockProfile()">Unblock</div>
-			<a v-if="user && user.id != status.account.id && !user.is_admin" class="list-group-item rounded cursor-pointer text-danger text-decoration-none" :href="reportUrl()">Report</a>
-			<div v-if="status && user.id == status.account.id && status.visibility != 'archived'" class="list-group-item rounded cursor-pointer text-danger" @click="archivePost(status)">Archive</div>
-			<div v-if="status && user.id == status.account.id && status.visibility == 'archived'" class="list-group-item rounded cursor-pointer text-danger" @click="unarchivePost(status)">Unarchive</div>
-			<div v-if="status && (user.is_admin || user.id == status.account.id)" class="list-group-item rounded cursor-pointer text-danger" @click="deletePost(ctxMenuStatus)">Delete</div>
-			<div class="list-group-item rounded cursor-pointer text-lighter" @click="closeCtxMenu()">Cancel</div>
-		</div>
-	</b-modal>
-	<b-modal ref="ctxModModal"
-		id="ctx-mod-modal"
-		hide-header
-		hide-footer
-		centered
-		rounded
-		size="sm"
-		body-class="list-group-flush p-0 rounded">
-		<div class="list-group text-center">
-			<div class="list-group-item rounded cursor-pointer" @click="toggleCommentVisibility">{{ showComments ? 'Disable' : 'Enable'}} Comments</div>
-
-			<div class="list-group-item rounded cursor-pointer" @click="moderatePost('unlist')">Unlist from Timelines</div>
-			<div v-if="status.sensitive" class="list-group-item rounded cursor-pointer" @click="moderatePost('remcw')">Remove Content Warning</div>
-			<div v-else class="list-group-item rounded cursor-pointer" @click="moderatePost('addcw')">Add Content Warning</div>
-			<div class="list-group-item rounded cursor-pointer text-lighter" @click="ctxModMenuClose()">Cancel</div>
-		</div>
-	</b-modal>
-	<b-modal ref="replyModal"
-		id="ctx-reply-modal"
-		hide-footer
-		centered
-		rounded
-		:title-html="replyingToUsername ? 'Reply to <span class=text-dark>' + replyingToUsername + '</span>' : ''"
-		title-tag="p"
-		title-class="font-weight-bold text-muted"
-		size="md"
-		body-class="p-2 rounded">
-		<div>
-			<vue-tribute :options="tributeSettings">
-				<textarea class="form-control" rows="4" style="border: none; font-size: 18px; resize: none; white-space: pre-wrap;outline: none;" placeholder="Reply here ..." v-model="replyText">
-				</textarea>
-			</vue-tribute>
-
-			<div class="border-top border-bottom my-2">
-				<ul class="nav align-items-center emoji-reactions" style="overflow-x: scroll;flex-wrap: unset;">
-					<li class="nav-item" v-on:click="emojiReaction(status)" v-for="e in emoji">{{e}}</li>
-				</ul>
+			<p class="mb-0 text-center small text-muted font-weight-bold"><a href="/site/kb/tagging-people">Learn more</a> about Tagging People.</p>
+		</b-modal>
+		<b-modal ref="ctxModal"
+			id="ctx-modal"
+			hide-header
+			hide-footer
+			centered
+			rounded
+			size="sm"
+			body-class="list-group-flush p-0 rounded">
+			<div class="list-group text-center">
+				<!-- <div v-if="user && user.id != status.account.id && relationship && relationship.following" class="list-group-item rounded cursor-pointer font-weight-bold text-danger" @click="ctxMenuUnfollow()">Unfollow</div>
+				<div v-if="user && user.id != status.account.id && relationship && !relationship.following" class="list-group-item rounded cursor-pointer font-weight-bold text-primary" @click="ctxMenuFollow()">Follow</div> -->
+				<div v-if="status && status.local == true" class="list-group-item rounded cursor-pointer" @click="showEmbedPostModal()">Embed</div>
+				<div class="list-group-item rounded cursor-pointer" @click="ctxMenuCopyLink()">Copy Link</div>
+				<div v-if="status && user.id == status.account.id" class="list-group-item rounded cursor-pointer" @click="toggleCommentVisibility">{{ showComments ? 'Disable' : 'Enable'}} Comments</div>
+				<a v-if="status && user.id == status.account.id" class="list-group-item rounded cursor-pointer text-dark text-decoration-none" :href="editUrl()">Edit</a>
+				<div v-if="user && user.is_admin == true" class="list-group-item rounded cursor-pointer" @click="ctxModMenu()">Moderation Tools</div>
+				<div v-if="status && user.id != status.account.id && !relationship.blocking && !user.is_admin" class="list-group-item rounded cursor-pointer text-danger" @click="blockProfile()">Block</div>
+				<div v-if="status && user.id != status.account.id && relationship.blocking && !user.is_admin" class="list-group-item rounded cursor-pointer text-danger" @click="unblockProfile()">Unblock</div>
+				<a v-if="user && user.id != status.account.id && !user.is_admin" class="list-group-item rounded cursor-pointer text-danger text-decoration-none" :href="reportUrl()">Report</a>
+				<div v-if="status && user.id == status.account.id && status.visibility != 'archived'" class="list-group-item rounded cursor-pointer text-danger" @click="archivePost(status)">Archive</div>
+				<div v-if="status && user.id == status.account.id && status.visibility == 'archived'" class="list-group-item rounded cursor-pointer text-danger" @click="unarchivePost(status)">Unarchive</div>
+				<div v-if="status && (user.is_admin || user.id == status.account.id)" class="list-group-item rounded cursor-pointer text-danger" @click="deletePost(ctxMenuStatus)">Delete</div>
+				<div class="list-group-item rounded cursor-pointer text-lighter" @click="closeCtxMenu()">Cancel</div>
+			</div>
+		</b-modal>
+		<b-modal ref="ctxModModal"
+			id="ctx-mod-modal"
+			hide-header
+			hide-footer
+			centered
+			rounded
+			size="sm"
+			body-class="list-group-flush p-0 rounded">
+			<div class="list-group text-center">
+				<div class="list-group-item rounded cursor-pointer" @click="toggleCommentVisibility">{{ showComments ? 'Disable' : 'Enable'}} Comments</div>
+
+				<div class="list-group-item rounded cursor-pointer" @click="moderatePost('unlist')">Unlist from Timelines</div>
+				<div v-if="status.sensitive" class="list-group-item rounded cursor-pointer" @click="moderatePost('remcw')">Remove Content Warning</div>
+				<div v-else class="list-group-item rounded cursor-pointer" @click="moderatePost('addcw')">Add Content Warning</div>
+				<div class="list-group-item rounded cursor-pointer text-lighter" @click="ctxModMenuClose()">Cancel</div>
 			</div>
-			<div class="d-flex justify-content-between align-items-center">
-				<div>
-					<span class="pl-2 small text-muted font-weight-bold text-monospace">
-						<span :class="[replyText.length > config.uploader.max_caption_length ? 'text-danger':'text-dark']">{{replyText.length > config.uploader.max_caption_length ? config.uploader.max_caption_length - replyText.length : replyText.length}}</span>/{{config.uploader.max_caption_length}}
-					</span>
+		</b-modal>
+		<b-modal ref="replyModal"
+			id="ctx-reply-modal"
+			hide-footer
+			centered
+			rounded
+			:title-html="replyingToUsername ? 'Reply to <span class=text-dark>' + replyingToUsername + '</span>' : ''"
+			title-tag="p"
+			title-class="font-weight-bold text-muted"
+			size="md"
+			body-class="p-2 rounded">
+			<div>
+				<vue-tribute :options="tributeSettings">
+					<textarea class="form-control" rows="4" style="border: none; font-size: 18px; resize: none; white-space: pre-wrap;outline: none;" placeholder="Reply here ..." v-model="replyText">
+					</textarea>
+				</vue-tribute>
+
+				<div class="border-top border-bottom my-2">
+					<ul class="nav align-items-center emoji-reactions" style="overflow-x: scroll;flex-wrap: unset;">
+						<li class="nav-item" v-on:click="emojiReaction(status)" v-for="e in emoji">{{e}}</li>
+					</ul>
 				</div>
-				<div class="d-flex align-items-center">
-					<div class="custom-control custom-switch mr-3">
-						<input type="checkbox" class="custom-control-input" id="replyModalCWSwitch" v-model="replySensitive">
-						<label :class="[replySensitive ? 'custom-control-label font-weight-bold text-dark':'custom-control-label text-lighter']" for="replyModalCWSwitch">Mark as NSFW</label>
+				<div class="d-flex justify-content-between align-items-center">
+					<div>
+						<span class="pl-2 small text-muted font-weight-bold text-monospace">
+							<span :class="[replyText.length > config.uploader.max_caption_length ? 'text-danger':'text-dark']">{{replyText.length > config.uploader.max_caption_length ? config.uploader.max_caption_length - replyText.length : replyText.length}}</span>/{{config.uploader.max_caption_length}}
+						</span>
+					</div>
+					<div class="d-flex align-items-center">
+						<div class="custom-control custom-switch mr-3">
+							<input type="checkbox" class="custom-control-input" id="replyModalCWSwitch" v-model="replySensitive">
+							<label :class="[replySensitive ? 'custom-control-label font-weight-bold text-dark':'custom-control-label text-lighter']" for="replyModalCWSwitch">Mark as NSFW</label>
+						</div>
+						<!-- <select class="custom-select custom-select-sm my-0 mr-2">
+							<option value="public" selected="">Public</option>
+							<option value="unlisted">Unlisted</option>
+							<option value="followers">Followers Only</option>
+						</select> -->
+						<button class="btn btn-primary btn-sm py-2 px-4 lead text-uppercase font-weight-bold" v-on:click.prevent="postReply()" :disabled="replyText.length == 0">
+						{{replySending == true ? 'POSTING' : 'POST'}}
+					</button>
 					</div>
-					<!-- <select class="custom-select custom-select-sm my-0 mr-2">
-						<option value="public" selected="">Public</option>
-						<option value="unlisted">Unlisted</option>
-						<option value="followers">Followers Only</option>
-					</select> -->
-					<button class="btn btn-primary btn-sm py-2 px-4 lead text-uppercase font-weight-bold" v-on:click.prevent="postReply()" :disabled="replyText.length == 0">
-					{{replySending == true ? 'POSTING' : 'POST'}}
-				</button>
 				</div>
 			</div>
-		</div>
-	</b-modal>
+		</b-modal>
+	</div>
 </div>
 </template>
 
@@ -766,7 +848,10 @@
 </style>
 
 <script>
-import VueTribute from 'vue-tribute'
+import VueTribute from 'vue-tribute';
+import PollCard from './partials/PollCard.vue';
+import CommentFeed from './partials/CommentFeed.vue';
+import StatusCard from './partials/StatusCard.vue';
 
 pixelfed.postComponent = {};
 
@@ -785,7 +870,10 @@ export default {
 		],
 
 		components: {
-				VueTribute
+				VueTribute,
+				PollCard,
+				CommentFeed,
+				StatusCard
 		},
 
 		data() {
@@ -944,6 +1032,12 @@ export default {
 					let self = this;
 					axios.get('/api/v2/profile/'+this.statusUsername+'/status/'+this.statusId)
 						.then(response => {
+								if(response.data.status.pf_type == 'poll') {
+									self.layout = 'poll';
+								}
+								if(response.data.status.pf_type == 'text') {
+									self.layout = 'text';
+								}
 								self.status = response.data.status;
 								self.media = self.status.media_attachments;
 								self.likesPage = 2;
@@ -1780,8 +1874,17 @@ export default {
 				.then(res => {
 					this.$refs.ctxModal.hide();
 				});
-			}
+			},
 
+			statusLike(s) {
+				this.reactions.liked = !!this.reactions.liked;
+			},
+
+			trimCaption(caption, len = 60) {
+				return _.truncate(caption, {
+					length: len
+				});
+			},
 		},
 }
 </script>

+ 13 - 1
resources/assets/js/components/RemotePost.vue

@@ -19,6 +19,14 @@
 							:recommended="false"
 							v-on:comment-focus="commentFocus" />
 
+							<comment-feed :status="status" class="mt-3" />
+
+			</div>
+
+			<div v-if="status.pf_type === 'poll'" class="col-12 col-md-6 offset-md-3">
+					<poll-card :status="status" :profile="profile" :fetch-state="true"/>
+
+					<comment-feed :status="status" class="mt-3" />
 
 			</div>
 
@@ -545,6 +553,8 @@ pixelfed.postComponent = {};
 
 import StatusCard from './partials/StatusCard.vue';
 import CommentCard from './partials/CommentCard.vue';
+import PollCard from './partials/PollCard.vue';
+import CommentFeed from './partials/CommentFeed.vue';
 
 export default {
 		props: [
@@ -560,7 +570,9 @@ export default {
 
 		components: {
 			StatusCard,
-			CommentCard
+			CommentCard,
+			CommentFeed,
+			PollCard
 		},
 
 		data() {

+ 286 - 0
resources/assets/js/components/partials/CommentFeed.vue

@@ -0,0 +1,286 @@
+<template>
+	<div>
+		<div v-if="loaded">
+			<div v-if="showReplyForm" class="card card-body shadow-none border bg-light">
+				<div class="media">
+					<img :src="profile.avatar" class="rounded-circle border mr-3" width="32px" height="32px">
+					<div class="media-body">
+						<div class="reply-form form-group mb-0">
+							<input v-if="!composeText || composeText.length < 40" class="form-control rounded-pill" placeholder="Add a comment..." v-model="composeText">
+							<textarea v-else class="form-control" placeholder="Add a comment..." v-model="composeText" rows="4"></textarea>
+							<div v-if="composeText && composeText.length" class="btn btn-primary btn-sm rounded-pill font-weight-bold px-3" @click="submitComment">
+								<span v-if="postingComment">
+									<div class="spinner-border spinner-border-sm" role="status">
+										<span class="sr-only">Loading...</span>
+									</div>
+								</span>
+								<span v-else>Post</span>
+							</div>
+						</div>
+
+						<div v-if="composeText" class="reply-options" v-model="visibility">
+							<select class="form-control form-control-sm rounded-pill font-weight-bold">
+								<option value="public">Public</option>
+								<option value="private">Followers Only</option>
+							</select>
+							<div class="custom-control custom-switch">
+								<input type="checkbox" class="custom-control-input" id="sensitive" v-model="sensitive">
+								<label class="custom-control-label font-weight-bold text-lighter" for="sensitive">
+									<span class="d-none d-md-inline-block">Sensitive/</span>NSFW
+								</label>
+							</div>
+							<span class="text-muted font-weight-bold small">
+								{{ composeText.length }} / 500
+							</span>
+						</div>
+					</div>
+				</div>
+			</div>
+
+			<div class="d-none card card-body shadow-none border rounded-0 border-top-0 bg-light">
+				<div class="d-flex justify-content-between align-items-center">
+					<p class="font-weight-bold text-muted mb-0 mr-md-5">
+						<i class="fas fa-comment mr-1"></i>
+						{{ formatCount(pagination.total) }}
+					</p>
+					<h4 class="font-weight-bold mb-0 text-lighter">Comments</h4>
+					<div class="form-group mb-0">
+						<select class="form-control form-control-sm">
+							<option>New</option>
+							<option>Oldest</option>
+						</select>
+					</div>
+				</div>
+			</div>
+
+			<status-card v-for="(reply, index) in feed" :key="'replies:'+index" :status="reply" size="small" />
+
+			<div v-if="pagination.links.hasOwnProperty('next')" class="card card-body shadow-none rounded-0 border border-top-0 py-3">
+				<button v-if="loadingMoreComments" class="btn btn-primary" disabled>
+					<div class="spinner-border spinner-border-sm" role="status">
+						<span class="sr-only">Loading...</span>
+					</div>
+				</button>
+				<button v-else class="btn btn-primary font-weight-bold" @click="loadMoreComments">Load more comments</button>
+			</div>
+
+			<context-menu
+				v-if="ctxStatus && profile"
+				ref="cMenu"
+				:status="ctxStatus"
+				:profile="profile"
+				v-on:status-delete="statusDeleted" />
+
+		</div>
+		<div v-else>
+		</div>
+	</div>
+</template>
+
+<script type="text/javascript">
+	import ContextMenu from './ContextMenu.vue';
+	import StatusCard from './StatusCard.vue';
+
+	export default {
+		props: {
+			status: {
+				type: Object,
+			},
+
+			currentProfile: {
+				type: Object
+			},
+
+			showReplyForm: {
+				type: Boolean,
+				default: true
+			}
+		},
+
+		components: {
+			"context-menu": ContextMenu,
+			"status-card": StatusCard
+		},
+
+		data() {
+			return {
+				loaded: false,
+				profile: undefined,
+				feed: [],
+				pagination: undefined,
+				ctxStatus: false,
+				composeText: null,
+				visibility: 'public',
+				sensitive: false,
+				postingComment: false,
+				loadingMoreComments: false,
+				page: 2
+			}
+		},
+
+		beforeMount() {
+			this.fetchProfile();
+		},
+
+		mounted() {
+			// if(this.currentProfile && !this.currentProfile.hasOwnProperty('id')) {
+			// } else {
+			// 	this.profile = this.currentProfile;
+			// }
+		},
+
+		methods: {
+			fetchProfile() {
+				axios.get('/api/pixelfed/v1/accounts/verify_credentials').then(res => {
+					this.profile = res.data;
+				});
+				this.fetchComments();
+			},
+
+			fetchComments() {
+				let url = '/api/v2/comments/'+this.status.account.id+'/status/'+this.status.id;
+				axios.get(url)
+					.then(res => {
+						this.feed = res.data.data;
+						this.pagination = res.data.meta.pagination;
+						this.loaded = true;
+					}).catch(error => {
+						this.loaded = true;
+						if(!error.response) {
+
+						} else {
+							switch(error.response.status) {
+								case 401:
+									$('.postCommentsLoader .lds-ring')
+										.attr('style','width:100%')
+										.addClass('pt-4 font-weight-bold text-muted')
+										.text('Please login to view.');
+								break;
+
+								default:
+									$('.postCommentsLoader .lds-ring')
+										.attr('style','width:100%')
+										.addClass('pt-4 font-weight-bold text-muted')
+										.text('An error occurred, cannot fetch comments. Please try again later.');
+								break;
+							}
+						}
+					});
+			},
+
+			trimCaption(caption) {
+				return caption;
+			},
+
+			profileUrl(status) {
+				return status.url;
+			},
+
+			statusUrl(status) {
+				return status.url;
+			},
+
+			replyFocus() {
+
+			},
+
+			likeReply() {
+
+			},
+
+			timeAgo(ts) {
+				return App.util.format.timeAgo(ts);
+			},
+
+			statusDeleted() {
+
+			},
+
+			ctxMenu(index) {
+				this.ctxStatus = this.feed[index];
+				setTimeout(() => {
+					this.$refs.cMenu.open();
+				}, 300);
+			},
+
+			submitComment() {
+				this.postingComment = true;
+
+				let data = {
+					item: this.status.id,
+					comment: this.composeText,
+					sensitive: this.sensitive
+				}
+
+				let self = this;
+
+				axios.post('/i/comment', data)
+				.then(res => {
+					self.composeText = null;
+					let entity = res.data.entity;
+					self.postingComment = false;
+					self.feed.unshift(entity);
+					self.pagination.total++;
+				}).catch(err => {
+					swal('Oops!', 'An error occured, please try again later.', 'error');
+					self.postingComment = false;
+				})
+			},
+
+			formatCount(i) {
+				return App.util.format.count(i);
+			},
+
+			loadMoreComments() {
+				let self = this;
+				this.loadingMoreComments = true;
+				let url = '/api/v2/comments/'+this.status.account.id+'/status/'+this.status.id;
+				axios.get(url, {
+					params: {
+						page: this.page
+					}
+				}).then(res => {
+					self.feed.push(...res.data.data);
+					self.pagination = res.data.meta.pagination;
+					self.loadingMoreComments = false;
+					self.page++;
+				}).catch(error => {
+					self.loadingMoreComments = false;
+				});
+			}
+		}
+	}
+</script>
+
+<style lang="scss" scoped>
+	.reply-form {
+		position:relative;
+
+		input {
+			padding-right: 90px;
+		}
+
+		textarea {
+			padding-right: 80px;
+			align-items: center;
+		}
+
+		.btn {
+			position:absolute;
+			top: 50%;
+			transform: translateY(-50%);
+			right: 6px;
+		}
+	}
+
+	.reply-options {
+		display: flex;
+		justify-content: space-between;
+		align-items: center;
+		margin-top: 15px;
+
+		.form-control {
+			max-width: 140px;
+		}
+	}
+</style>

+ 327 - 0
resources/assets/js/components/partials/PollCard.vue

@@ -0,0 +1,327 @@
+<template>
+<div>
+	<div class="card shadow-none border rounded-0" :class="{'border-top-0': !showBorderTop}">
+		<div class="card-body">
+			<div class="media">
+				<img class="rounded-circle box-shadow mr-2" :src="status.account.avatar" width="32px" height="32px" alt="avatar">
+				<div class="media-body">
+					<div class="pl-2 d-flex align-items-top">
+						<a class="username font-weight-bold text-dark text-decoration-none text-break" href="#">
+							{{status.account.acct}}
+						</a>
+						<span class="px-1 text-lighter">
+							·
+						</span>
+						<a class="font-weight-bold text-lighter" :href="statusUrl(status)">
+							{{shortTimestamp(status.created_at)}}
+						</a>
+						<span class="d-none d-md-block px-1 text-lighter">
+							·
+						</span>
+						<span class="d-none d-md-block px-1 text-primary font-weight-bold">
+							<i class="fas fa-poll-h"></i> Poll <sup class="text-lighter">BETA</sup>
+						</span>
+						<span class="d-none d-md-block px-1 text-lighter">
+							·
+						</span>
+						<span class="d-none d-md-block px-1 text-lighter font-weight-bold">
+							<span v-if="status.poll.expired">
+								Closed
+							</span>
+							<span v-else>
+								Closes in {{ shortTimestampAhead(status.poll.expires_at) }}
+							</span>
+						</span>
+						<span class="text-right" style="flex-grow:1;">
+							<button class="btn btn-link text-dark py-0" type="button" @click="ctxMenu()">
+								<span class="fas fa-ellipsis-h text-lighter"></span>
+								<span class="sr-only">Post Menu</span>
+							</button>
+						</span>
+					</div>
+					<div class="pl-2">
+						<div class="poll py-3">
+
+							<div class="pt-2 text-break d-flex align-items-center mb-3" style="font-size: 17px;">
+								<span class="btn btn-primary px-2 py-1">
+									<i class="fas fa-poll-h fa-lg"></i>
+								</span>
+
+								<span class="font-weight-bold ml-3" v-html="status.content"></span>
+							</div>
+
+							<div class="mb-2">
+								<div v-if="tab === 'vote'">
+									<p v-for="(option, index) in status.poll.options">
+										<button
+											class="btn btn-block lead rounded-pill"
+											:class="[ index == selectedIndex ? 'btn-primary' : 'btn-outline-primary' ]"
+											@click="selectOption(index)"
+											:disabled="!authenticated">
+											{{ option.title }}
+										</button>
+									</p>
+
+									<p v-if="selectedIndex != null" class="text-right">
+										<button class="btn btn-primary btn-sm font-weight-bold px-3" @click="submitVote()">Vote</button>
+									</p>
+								</div>
+								<div v-else-if="tab === 'voted'">
+									<div v-for="(option, index) in status.poll.options" class="mb-3">
+										<button
+											class="btn btn-block lead rounded-pill"
+											:class="[ index == selectedIndex ? 'btn-primary' : 'btn-outline-secondary' ]"
+											disabled>
+											{{ option.title }}
+										</button>
+										<div class="font-weight-bold">
+											<span class="text-muted">{{ calculatePercentage(option) }}%</span>
+											<span class="small text-lighter">({{option.votes_count}} {{option.votes_count == 1 ? 'vote' : 'votes'}})</span>
+										</div>
+									</div>
+								</div>
+								<div v-else-if="tab === 'results'">
+									<div v-for="(option, index) in status.poll.options" class="mb-3">
+										<button
+											class="btn btn-outline-secondary btn-block lead rounded-pill"
+											disabled>
+											{{ option.title }}
+										</button>
+										<div class="font-weight-bold">
+											<span class="text-muted">{{ calculatePercentage(option) }}%</span>
+											<span class="small text-lighter">({{option.votes_count}} {{option.votes_count == 1 ? 'vote' : 'votes'}})</span>
+										</div>
+									</div>
+								</div>
+							</div>
+
+							<div>
+								<p class="mb-0 small text-lighter font-weight-bold d-flex justify-content-between">
+									<span>{{ status.poll.votes_count }} votes</span>
+									<a v-if="tab != 'results' && authenticated && !activeRefreshTimeout & status.poll.expired != true && status.poll.voted" class="text-lighter" @click.prevent="refreshResults()" href="#">Refresh Results</a>
+									<span v-if="tab != 'results' && authenticated && refreshingResults" class="text-lighter">
+										<div class="spinner-border spinner-border-sm" role="status">
+											<span class="sr-only">Loading...</span>
+										</div>
+									</span>
+								</p>
+							</div>
+							<div>
+								<span class="d-block d-md-none small text-lighter font-weight-bold">
+									<span v-if="status.poll.expired">
+										Closed
+									</span>
+									<span v-else>
+										Closes in {{ shortTimestampAhead(status.poll.expires_at) }}
+									</span>
+								</span>
+							</div>
+						</div>
+					</div>
+				</div>
+			</div>
+		</div>
+	</div>
+
+	<context-menu
+		ref="contextMenu"
+		:status="status"
+		:profile="profile"
+		v-on:status-delete="statusDeleted"
+	/>
+</div>
+</template>
+
+<script type="text/javascript">
+	import ContextMenu from './ContextMenu.vue';
+
+	export default {
+		props: {
+			reactions: {
+				type: Object
+			},
+
+			status: {
+				type: Object
+			},
+
+			profile: {
+				type: Object
+			},
+
+			showBorderTop: {
+				type: Boolean,
+				default: false
+			},
+
+			fetchState: {
+				type: Boolean,
+				default: false
+			}
+		},
+
+		components: {
+			"context-menu": ContextMenu
+		},
+
+		data() {
+			return {
+				authenticated: false,
+				tab: 'vote',
+				selectedIndex: null,
+				refreshTimeout: undefined,
+				activeRefreshTimeout: false,
+				refreshingResults: false
+			}
+		},
+
+		mounted() {
+
+			if(this.fetchState) {
+				axios.get('/api/v1/polls/' + this.status.poll.id)
+				.then(res => {
+					this.status.poll = res.data;
+					if(res.data.voted) {
+						this.selectedIndex = res.data.own_votes[0];
+						this.tab = 'voted';
+					}
+					this.status.poll.expired = new Date(this.status.poll.expires_at) < new Date();
+					if(this.status.poll.expired) {
+						this.tab = this.status.poll.voted ? 'voted' : 'results';
+					}
+				})
+			} else {
+				if(this.status.poll.voted) {
+					this.tab = 'voted';
+				}
+				this.status.poll.expired = new Date(this.status.poll.expires_at) < new Date();
+				if(this.status.poll.expired) {
+					this.tab = this.status.poll.voted ? 'voted' : 'results';
+				}
+				if(this.status.poll.own_votes.length) {
+					this.selectedIndex = this.status.poll.own_votes[0];
+				}
+			}
+			this.authenticated = $('body').hasClass('loggedIn');
+		},
+
+		methods: {
+			selectOption(index) {
+				event.currentTarget.blur();
+				this.selectedIndex = index;
+				// if(this.options[index].selected) {
+				// 	this.selectedIndex = null;
+				// 	this.options[index].selected = false;
+				// 	return;
+				// }
+
+				// this.options = this.options.map(o => {
+				// 	o.selected = false;
+				// 	return o;
+				// });
+
+				// this.options[index].selected = true;
+				// this.selectedIndex = index;
+				// this.options[index].score = 100;
+			},
+
+			submitVote() {
+				// todo: send vote
+
+				axios.post('/api/v1/polls/'+this.status.poll.id+'/votes', {
+					'choices': [
+						this.selectedIndex
+					]
+				}).then(res => {
+					console.log(res.data);
+					this.status.poll = res.data;
+				});
+				this.tab = 'voted';
+			},
+
+			viewResultsTab() {
+				this.tab = 'results';
+			},
+
+			viewPollTab() {
+				this.tab = this.selectedIndex != null ? 'voted' : 'vote';
+			},
+
+			formatCount(count) {
+				return App.util.format.count(count);
+			},
+
+			statusUrl(status) {
+				if(status.local == true) {
+					return status.url;
+				}
+
+				return '/i/web/post/_/' + status.account.id + '/' + status.id;
+			},
+
+			profileUrl(status) {
+				if(status.local == true) {
+					return status.account.url;
+				}
+
+				return '/i/web/profile/_/' + status.account.id;
+			},
+
+			timestampFormat(timestamp) {
+				let ts = new Date(timestamp);
+				return ts.toDateString() + ' ' + ts.toLocaleTimeString();
+			},
+
+			shortTimestamp(ts) {
+				return window.App.util.format.timeAgo(ts);
+			},
+
+			shortTimestampAhead(ts) {
+				return window.App.util.format.timeAhead(ts);
+			},
+
+			refreshResults() {
+				this.activeRefreshTimeout = true;
+				this.refreshingResults = true;
+				axios.get('/api/v1/polls/' + this.status.poll.id)
+				.then(res => {
+					this.status.poll = res.data;
+					if(this.status.poll.voted) {
+						this.selectedIndex = this.status.poll.own_votes[0];
+						this.tab = 'voted';
+						this.setActiveRefreshTimeout();
+						this.refreshingResults = false;
+					}
+				}).catch(err => {
+					swal('Oops!', 'An error occured while fetching the latest poll results. Please try again later.', 'error');
+					this.setActiveRefreshTimeout();
+					this.refreshingResults = false;
+				});
+			},
+
+			setActiveRefreshTimeout() {
+				let self = this;
+				this.refreshTimeout = setTimeout(function() {
+					self.activeRefreshTimeout = false;
+				}, 30000);
+			},
+
+			statusDeleted(status) {
+				this.$emit('status-delete', status);
+			},
+
+			ctxMenu() {
+				this.$refs.contextMenu.open();
+			},
+
+			likeStatus() {
+				this.$emit('likeStatus', this.status);
+			},
+
+			calculatePercentage(option) {
+				let status = this.status;
+				return status.poll.votes_count == 0 ? 0 : Math.round((option.votes_count / status.poll.votes_count) * 100);
+			}
+		}
+	}
+</script>

+ 46 - 7
resources/assets/js/components/partials/StatusCard.vue

@@ -1,6 +1,7 @@
 <template>
-	<div>
-		<div v-if="status.pf_type === 'text'" class="card shadow-none border border-top-0 rounded-0">
+	<div class="status-card-component"
+		 :class="{ 'status-card-sm': size === 'small' }">
+		<div v-if="status.pf_type === 'text'" :class="{ 'border-top-0': !hasTopBorder }" class="card shadow-none border rounded-0">
 			<div class="card-body">
 				<div class="media">
 					<img class="rounded-circle box-shadow mr-2" :src="status.account.avatar" width="32px" height="32px" onerror="this.onerror=null;this.src='/storage/avatars/default.png?v=2'" alt="avatar">
@@ -18,7 +19,7 @@
 							</a>
 							<span class="text-right" style="flex-grow:1;">
 								<button class="btn btn-link text-dark py-0" type="button" @click="ctxMenu()">
-									<span class="fas fa-ellipsis-v text-lighter"></span>
+									<span class="fas fa-ellipsis-h text-lighter"></span>
 									<span class="sr-only">Post Menu</span>
 								</button>
 							</span>
@@ -27,10 +28,10 @@
 
 							<details v-if="status.sensitive">
 								<summary class="mb-2 font-weight-bold text-muted">Content Warning</summary>
-								<p v-html="status.content" class="pt-2 text-break" style="font-size: 17px;"></p>
+								<p v-html="status.content" class="pt-2 text-break status-content"></p>
 							</details>
 
-							<p v-else v-html="status.content" class="pt-2 text-break" style="font-size: 17px;"></p>
+							<p v-else v-html="status.content" class="pt-2 text-break status-content"></p>
 
 							<p class="mb-0">
 								<i class="fa-heart fa-lg cursor-pointer mr-3"
@@ -49,6 +50,10 @@
 			</div>
 		</div>
 
+		<div v-else-if="status.pf_type === 'poll'">
+			<poll-card :status="status" :profile="profile" v-on:status-delete="statusDeleted" />
+		</div>
+
 		<div v-else class="card rounded-0 border-top-0 status-card card-md-rounded-0 shadow-none border">
 			<div v-if="status" class="card-header d-inline-flex align-items-center bg-white">
 				<div>
@@ -179,6 +184,7 @@
 
 <script type="text/javascript">
 	import ContextMenu from './ContextMenu.vue';
+	import PollCard from './PollCard.vue';
 
 	export default {
 		props: {
@@ -194,11 +200,23 @@
 			reactionBar: {
 				type: Boolean,
 				default: true
+			},
+
+			hasTopBorder: {
+				type: Boolean,
+				default: false
+			},
+
+			size: {
+				type: String,
+				validator: (val) => ['regular', 'small'].includes(val),
+				default: 'regular'
 			}
 		},
 
 		components: {
-			"context-menu": ContextMenu
+			"context-menu": ContextMenu,
+			"poll-card": PollCard
 		},
 
 		data() {
@@ -302,8 +320,9 @@
 					item: status.id
 				}).then(res => {
 					status.favourites_count = res.data.count;
+					status.favourited = !!status.favourited;
 				}).catch(err => {
-					status.favourited = !status.favourited;
+					status.favourited = !!status.favourited;
 					status.favourites_count = count;
 					swal('Error', 'Something went wrong, please try again later.', 'error');
 				});
@@ -367,3 +386,23 @@
 		}
 	}
 </script>
+
+<style lang="scss">
+	.status-card-component {
+		.status-content {
+			font-size: 17px;
+		}
+
+		&.status-card-sm {
+			.status-content {
+				font-size: 14px;
+			}
+
+			.fa-lg {
+				font-size: unset;
+				line-height: unset;
+				vertical-align: unset;
+			}
+		}
+	}
+</style>

+ 3 - 0
routes/web.php

@@ -105,6 +105,8 @@ Route::domain(config('pixelfed.domain.app'))->middleware(['validemail', 'twofact
 		Route::get('search', 'SearchController@searchAPI');
 		Route::get('nodeinfo/2.0.json', 'FederationController@nodeinfo');
 		Route::post('status/view', 'StatusController@storeView');
+		Route::get('v1/polls/{id}', 'PollController@getPoll');
+		Route::post('v1/polls/{id}/votes', 'PollController@vote');
 
 		Route::group(['prefix' => 'compose'], function() {
 			Route::group(['prefix' => 'v0'], function() {
@@ -120,6 +122,7 @@ Route::domain(config('pixelfed.domain.app'))->middleware(['validemail', 'twofact
 				Route::post('/publish/text', 'ComposeController@storeText');
 				Route::get('/media/processing', 'ComposeController@mediaProcessingCheck');
 				Route::get('/settings', 'ComposeController@composeSettings');
+				Route::post('/poll', 'ComposeController@createPoll');
 			});
 		});