diff --git a/app/Http/Controllers/PollController.php b/app/Http/Controllers/PollController.php new file mode 100644 index 000000000..21acd283e --- /dev/null +++ b/app/Http/Controllers/PollController.php @@ -0,0 +1,73 @@ +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; + } +} diff --git a/app/Http/Controllers/PublicApiController.php b/app/Http/Controllers/PublicApiController.php index 566d2504a..bd96e774e 100644 --- a/app/Http/Controllers/PublicApiController.php +++ b/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') diff --git a/app/Jobs/StatusPipeline/StatusActivityPubDeliver.php b/app/Jobs/StatusPipeline/StatusActivityPubDeliver.php index af65aed75..759f5c72c 100644 --- a/app/Jobs/StatusPipeline/StatusActivityPubDeliver.php +++ b/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; + 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; - } + protected $status; - /** - * Execute the job. - * - * @return void - */ - public function handle() - { - $status = $this->status; - $profile = $status->profile; + /** + * Delete the job if its models no longer exist. + * + * @var bool + */ + public $deleteWhenMissingModels = true; - if($status->local == false || $status->url || $status->uri) { - return; - } + /** + * Create a new job instance. + * + * @return void + */ + public function __construct(Status $status) + { + $this->status = $status; + } - $audience = $status->profile->getAudienceInbox(); + /** + * Execute the job. + * + * @return void + */ + public function handle() + { + $status = $this->status; + $profile = $status->profile; - if(empty($audience) || !in_array($status->scope, ['public', 'unlisted', 'private'])) { - // Return on profiles with no remote followers - return; - } + 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, new CreateNote()); - $activity = $fractal->createData($resource)->toArray(); + $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') - ]); + $payload = json_encode($activity); - $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 - ] - ]); - }; - } - }; + $client = new Client([ + 'timeout' => config('federation.activitypub.delivery.timeout') + ]); - $pool = new Pool($client, $requests($audience), [ - 'concurrency' => config('federation.activitypub.delivery.concurrency'), - 'fulfilled' => function ($response, $index) { - }, - 'rejected' => function ($reason, $index) { - } - ]); - - $promise = $pool->promise(); + $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 + ] + ]); + }; + } + }; - $promise->wait(); - } + $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(); + } } diff --git a/app/Models/Poll.php b/app/Models/Poll.php new file mode 100644 index 000000000..2b65162c0 --- /dev/null +++ b/app/Models/Poll.php @@ -0,0 +1,35 @@ + 'array', + 'cached_tallies' => 'array', + 'expires_at' => 'datetime' + ]; + + public function votes() + { + return $this->hasMany(PollVote::class); + } + + public function getTallies() + { + return $this->cached_tallies; + } +} diff --git a/app/Models/PollVote.php b/app/Models/PollVote.php new file mode 100644 index 000000000..c6aae7fa9 --- /dev/null +++ b/app/Models/PollVote.php @@ -0,0 +1,11 @@ +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') ?? []; + } +} diff --git a/app/Transformer/ActivityPub/Verb/CreateQuestion.php b/app/Transformer/ActivityPub/Verb/CreateQuestion.php new file mode 100644 index 000000000..a1aaccdc2 --- /dev/null +++ b/app/Transformer/ActivityPub/Verb/CreateQuestion.php @@ -0,0 +1,46 @@ + [ + '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()); + } +} diff --git a/app/Transformer/ActivityPub/Verb/Question.php b/app/Transformer/ActivityPub/Verb/Question.php new file mode 100644 index 000000000..fd78ce2ff --- /dev/null +++ b/app/Transformer/ActivityPub/Verb/Question.php @@ -0,0 +1,89 @@ +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] + ] + ]; + }) + ]; + } +} diff --git a/app/Transformer/Api/StatusStatelessTransformer.php b/app/Transformer/Api/StatusStatelessTransformer.php index e3352bcaa..67bd5c72f 100644 --- a/app/Transformer/Api/StatusStatelessTransformer.php +++ b/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 ]; } } diff --git a/app/Transformer/Api/StatusTransformer.php b/app/Transformer/Api/StatusTransformer.php index f2fd4a2cb..1aca5398d 100644 --- a/app/Transformer/Api/StatusTransformer.php +++ b/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, ]; } } diff --git a/app/Util/ActivityPub/Helpers.php b/app/Util/ActivityPub/Helpers.php index bc2dd57b2..9859bec6a 100644 --- a/app/Util/ActivityPub/Helpers.php +++ b/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); diff --git a/app/Util/ActivityPub/Inbox.php b/app/Util/ActivityPub/Inbox.php index 18f911bfd..920f6d80a 100644 --- a/app/Util/ActivityPub/Inbox.php +++ b/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() diff --git a/app/Util/Site/Config.php b/app/Util/Site/Config.php index eb3dd725a..e7132bc00 100644 --- a/app/Util/Site/Config.php +++ b/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' => [ diff --git a/config/exp.php b/config/exp.php index 74e9a5e49..76b4861f4 100644 --- a/config/exp.php +++ b/config/exp.php @@ -6,4 +6,5 @@ return [ 'rec' => false, 'loops' => false, 'top' => env('EXP_TOP', false), + 'polls' => env('EXP_POLLS', false) ]; diff --git a/database/migrations/2021_07_29_014835_create_polls_table.php b/database/migrations/2021_07_29_014835_create_polls_table.php new file mode 100644 index 000000000..d7cd636fc --- /dev/null +++ b/database/migrations/2021_07_29_014835_create_polls_table.php @@ -0,0 +1,41 @@ +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'); + } +} diff --git a/database/migrations/2021_07_29_014849_create_poll_votes_table.php b/database/migrations/2021_07_29_014849_create_poll_votes_table.php new file mode 100644 index 000000000..4db7e23b1 --- /dev/null +++ b/database/migrations/2021_07_29_014849_create_poll_votes_table.php @@ -0,0 +1,36 @@ +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'); + } +} diff --git a/resources/assets/js/components/ComposeModal.vue b/resources/assets/js/components/ComposeModal.vue index 0171d236c..076a0c746 100644 --- a/resources/assets/js/components/ComposeModal.vue +++ b/resources/assets/js/components/ComposeModal.vue @@ -44,6 +44,97 @@ +
+
+
+ + + + + New Poll + + +
+ Loading... +
+
+ + + Create Poll + +
+
+
+
+ +
+
+ + + + +

{{composeTextLength}}/{{config.uploader.max_caption_length}}

+
+
+
+
+ +
+

+ Poll Options +

+ +
+ +
+ +
+ {{ index + 1 }}. + + + +
+ +
+ +
+
+

+ Poll Expiry +

+ +
+ +
+
+ +
+

+ Poll Visibility +

+ +
+ +
+
+
+
+
+
+
+
@@ -147,7 +238,7 @@
-
+
@@ -163,7 +254,7 @@
-
+
@@ -182,7 +273,7 @@
-
+
@@ -200,8 +291,27 @@
+ +
+
+
+ +
+
+

+ New Poll + + BETA + +

+

Create a poll

+
+
+
+
+ -
+
@@ -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'); + }) + } } } diff --git a/resources/assets/js/components/PostComponent.vue b/resources/assets/js/components/PostComponent.vue index ea87e8a10..16bfc8e41 100644 --- a/resources/assets/js/components/PostComponent.vue +++ b/resources/assets/js/components/PostComponent.vue @@ -454,235 +454,317 @@
-
- -
-
-
- - - -
-

- - {{user.username}} - -

-

- {{user.acct.split('@')[0]}}@{{user.acct.split('@')[1]}} -

-

- {{user.display_name}} -

+
+
+
+ +
+
+ Loading... +
+
+
+ + + +
- -
-
-
- - -
-
-
- - - -
-
+ +
+
+
+ + +
+
+
+
+ + - - -
- -
-
- -
-
- +

Learn more about Tagging People.

+ + +
+ +
Embed
+
Copy Link
+
{{ showComments ? 'Disable' : 'Enable'}} Comments
+ Edit +
Moderation Tools
+
Block
+
Unblock
+ Report +
Archive
+
Unarchive
+
Delete
+
Cancel
-
-
- - -
-
- - -
-
- - -
-
-
- -

By using this embed, you agree to our Terms of Use

-
- - -
-
-
- - - -
-

- - {{taguser.username}} - - -

-
-
-
-
-

Learn more about Tagging People.

-
- -
- -
Embed
-
Copy Link
-
{{ showComments ? 'Disable' : 'Enable'}} Comments
- Edit -
Moderation Tools
-
Block
-
Unblock
- Report -
Archive
-
Unarchive
-
Delete
-
Cancel
-
-
- -
-
{{ showComments ? 'Disable' : 'Enable'}} Comments
+ + +
+
{{ showComments ? 'Disable' : 'Enable'}} Comments
-
Unlist from Timelines
-
Remove Content Warning
-
Add Content Warning
-
Cancel
-
-
- -
- - - +
Unlist from Timelines
+
Remove Content Warning
+
Add Content Warning
+
Cancel
+
+
+ +
+ + + -
- -
-
-
- - {{replyText.length > config.uploader.max_caption_length ? config.uploader.max_caption_length - replyText.length : replyText.length}}/{{config.uploader.max_caption_length}} - +
+
-
-
- - +
+
+ + {{replyText.length > config.uploader.max_caption_length ? config.uploader.max_caption_length - replyText.length : replyText.length}}/{{config.uploader.max_caption_length}} + +
+
+
+ + +
+ +
- -
-
- + +
@@ -766,7 +848,10 @@ diff --git a/resources/assets/js/components/RemotePost.vue b/resources/assets/js/components/RemotePost.vue index eab25df32..4436447b7 100644 --- a/resources/assets/js/components/RemotePost.vue +++ b/resources/assets/js/components/RemotePost.vue @@ -19,6 +19,14 @@ :recommended="false" v-on:comment-focus="commentFocus" /> + + +
+ +
+ + +
@@ -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() { diff --git a/resources/assets/js/components/partials/CommentFeed.vue b/resources/assets/js/components/partials/CommentFeed.vue new file mode 100644 index 000000000..ee29e7c69 --- /dev/null +++ b/resources/assets/js/components/partials/CommentFeed.vue @@ -0,0 +1,286 @@ + + + + + diff --git a/resources/assets/js/components/partials/PollCard.vue b/resources/assets/js/components/partials/PollCard.vue new file mode 100644 index 000000000..f6366437c --- /dev/null +++ b/resources/assets/js/components/partials/PollCard.vue @@ -0,0 +1,327 @@ + + + diff --git a/resources/assets/js/components/partials/StatusCard.vue b/resources/assets/js/components/partials/StatusCard.vue index 47aff093a..04cc34226 100644 --- a/resources/assets/js/components/partials/StatusCard.vue +++ b/resources/assets/js/components/partials/StatusCard.vue @@ -1,6 +1,7 @@