Add Polls

This commit is contained in:
Daniel Supernault 2021-08-04 20:29:21 -06:00
parent 5916f8c76a
commit 7709220074
No known key found for this signature in database
GPG key ID: 0DEF1C662C9033F7
23 changed files with 1819 additions and 321 deletions

View file

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

View file

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

View file

@ -12,6 +12,7 @@ use Illuminate\Queue\SerializesModels;
use League\Fractal; use League\Fractal;
use League\Fractal\Serializer\ArraySerializer; use League\Fractal\Serializer\ArraySerializer;
use App\Transformer\ActivityPub\Verb\CreateNote; use App\Transformer\ActivityPub\Verb\CreateNote;
use App\Transformer\ActivityPub\Verb\CreateQuestion;
use App\Util\ActivityPub\Helpers; use App\Util\ActivityPub\Helpers;
use GuzzleHttp\Pool; use GuzzleHttp\Pool;
use GuzzleHttp\Client; use GuzzleHttp\Client;
@ -62,10 +63,20 @@ class StatusActivityPubDeliver implements ShouldQueue
return; return;
} }
switch($status->type) {
case 'poll':
$activitypubObject = new CreateQuestion();
break;
default:
$activitypubObject = new CreateNote();
break;
}
$fractal = new Fractal\Manager(); $fractal = new Fractal\Manager();
$fractal->setSerializer(new ArraySerializer()); $fractal->setSerializer(new ArraySerializer());
$resource = new Fractal\Resource\Item($status, new CreateNote()); $resource = new Fractal\Resource\Item($status, $activitypubObject);
$activity = $fractal->createData($resource)->toArray(); $activity = $fractal->createData($resource)->toArray();
$payload = json_encode($activity); $payload = json_encode($activity);
@ -82,7 +93,9 @@ class StatusActivityPubDeliver implements ShouldQueue
'curl' => [ 'curl' => [
CURLOPT_HTTPHEADER => $headers, CURLOPT_HTTPHEADER => $headers,
CURLOPT_POSTFIELDS => $payload, CURLOPT_POSTFIELDS => $payload,
CURLOPT_HEADER => true CURLOPT_HEADER => true,
CURLOPT_SSL_VERIFYPEER => false,
CURLOPT_SSL_VERIFYHOST => false
] ]
]); ]);
}; };

35
app/Models/Poll.php Normal file
View file

@ -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
app/Models/PollVote.php Normal file
View file

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

View file

@ -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') ?? [];
}
}

View file

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

View file

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

View file

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

View file

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

View file

@ -33,6 +33,7 @@ use App\Services\MediaStorageService;
use App\Jobs\MediaPipeline\MediaStoragePipeline; use App\Jobs\MediaPipeline\MediaStoragePipeline;
use App\Jobs\AvatarPipeline\RemoteAvatarFetch; use App\Jobs\AvatarPipeline\RemoteAvatarFetch;
use App\Util\Media\License; use App\Util\Media\License;
use App\Models\Poll;
class Helpers { class Helpers {
@ -270,7 +271,7 @@ class Helpers {
$res = self::fetchFromUrl($url); $res = self::fetchFromUrl($url);
if(!$res || empty($res) || isset($res['error']) ) { if(!$res || empty($res) || isset($res['error']) || !isset($res['@context']) ) {
return; return;
} }
@ -331,7 +332,6 @@ class Helpers {
$idDomain = parse_url($id, PHP_URL_HOST); $idDomain = parse_url($id, PHP_URL_HOST);
$urlDomain = parse_url($url, PHP_URL_HOST); $urlDomain = parse_url($url, PHP_URL_HOST);
if(!self::validateUrl($id)) { if(!self::validateUrl($id)) {
return; return;
} }
@ -368,6 +368,7 @@ class Helpers {
$cw = true; $cw = true;
} }
$statusLockKey = 'helpers:status-lock:' . hash('sha256', $res['id']); $statusLockKey = 'helpers:status-lock:' . hash('sha256', $res['id']);
$status = Cache::lock($statusLockKey) $status = Cache::lock($statusLockKey)
->get(function () use( ->get(function () use(
@ -380,6 +381,19 @@ class Helpers {
$scope, $scope,
$id $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) { return DB::transaction(function() use($profile, $res, $url, $ts, $reply_to, $cw, $scope, $id) {
$status = new Status; $status = new Status;
$status->profile_id = $profile->id; $status->profile_id = $profile->id;
@ -409,6 +423,55 @@ class Helpers {
return $status; 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) public static function statusFetch($url)
{ {
return self::statusFirstOrFetch($url); return self::statusFirstOrFetch($url);

View file

@ -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\Like as LikeValidator;
use App\Util\ActivityPub\Validator\UndoFollow as UndoFollowValidator; use App\Util\ActivityPub\Validator\UndoFollow as UndoFollowValidator;
use App\Services\PollService;
class Inbox class Inbox
{ {
protected $headers; protected $headers;
@ -147,6 +149,12 @@ class Inbox
} }
$to = $activity['to']; $to = $activity['to'];
$cc = isset($activity['cc']) ? $activity['cc'] : []; $cc = isset($activity['cc']) ? $activity['cc'] : [];
if($activity['type'] == 'Question') {
$this->handlePollCreate();
return;
}
if(count($to) == 1 && if(count($to) == 1 &&
count($cc) == 0 && count($cc) == 0 &&
parse_url($to[0], PHP_URL_HOST) == config('pixelfed.domain.app') parse_url($to[0], PHP_URL_HOST) == config('pixelfed.domain.app')
@ -154,6 +162,7 @@ class Inbox
$this->handleDirectMessage(); $this->handleDirectMessage();
return; return;
} }
if($activity['type'] == 'Note' && !empty($activity['inReplyTo'])) { if($activity['type'] == 'Note' && !empty($activity['inReplyTo'])) {
$this->handleNoteReply(); $this->handleNoteReply();
@ -180,6 +189,18 @@ class Inbox
return; 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() public function handleNoteCreate()
{ {
$activity = $this->payload['object']; $activity = $this->payload['object'];
@ -188,6 +209,16 @@ class Inbox
return; 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) { if($actor->followers()->count() == 0) {
return; return;
} }
@ -200,6 +231,51 @@ class Inbox
return; 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() public function handleDirectMessage()
{ {
$activity = $this->payload['object']; $activity = $this->payload['object'];
@ -558,10 +634,8 @@ class Inbox
return; return;
} }
public function handleRejectActivity() public function handleRejectActivity()
{ {
} }
public function handleUndoActivity() public function handleUndoActivity()

View file

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

View file

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

View file

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

View file

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

View file

@ -44,6 +44,97 @@
</div> </div>
</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 v-else>
<div class="card status-card card-md-rounded-0 w-100 h-100" style="display:flex;"> <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"> <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 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 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 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="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"> <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> <i class="fas fa-bolt text-white fa-lg"></i>
@ -163,7 +254,7 @@
</div> </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 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="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"> <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> <i class="far fa-edit text-primary fa-lg"></i>
@ -182,7 +273,7 @@
</div> </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"> <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="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"> <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> <i class="fas fa-history text-primary fa-lg"></i>
@ -200,8 +291,27 @@
</div> </div>
</a> </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"> <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="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"> <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> <i class="fas fa-images text-primary fa-lg"></i>
@ -906,7 +1016,11 @@ export default {
}, },
licenseId: 1, licenseId: 1,
licenseTitle: null, licenseTitle: null,
maxAltTextLength: 140 maxAltTextLength: 140,
pollOptionModel: null,
pollOptions: [],
pollExpiry: 1440,
postingPoll: false
} }
}, },
@ -1590,6 +1704,53 @@ export default {
break; 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> </script>

View file

@ -454,8 +454,89 @@
</div> </div>
</div> </div>
</div> </div>
<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>
<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>
</div>
<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" <b-modal ref="likesModal"
id="l-modal" id="l-modal"
hide-footer hide-footer
@ -684,6 +765,7 @@
</div> </div>
</b-modal> </b-modal>
</div> </div>
</div>
</template> </template>
<style type="text/css" scoped> <style type="text/css" scoped>
@ -766,7 +848,10 @@
</style> </style>
<script> <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 = {}; pixelfed.postComponent = {};
@ -785,7 +870,10 @@ export default {
], ],
components: { components: {
VueTribute VueTribute,
PollCard,
CommentFeed,
StatusCard
}, },
data() { data() {
@ -944,6 +1032,12 @@ export default {
let self = this; let self = this;
axios.get('/api/v2/profile/'+this.statusUsername+'/status/'+this.statusId) axios.get('/api/v2/profile/'+this.statusUsername+'/status/'+this.statusId)
.then(response => { .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.status = response.data.status;
self.media = self.status.media_attachments; self.media = self.status.media_attachments;
self.likesPage = 2; self.likesPage = 2;
@ -1780,8 +1874,17 @@ export default {
.then(res => { .then(res => {
this.$refs.ctxModal.hide(); this.$refs.ctxModal.hide();
}); });
} },
statusLike(s) {
this.reactions.liked = !!this.reactions.liked;
},
trimCaption(caption, len = 60) {
return _.truncate(caption, {
length: len
});
},
}, },
} }
</script> </script>

View file

@ -19,6 +19,14 @@
:recommended="false" :recommended="false"
v-on:comment-focus="commentFocus" /> 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> </div>
@ -545,6 +553,8 @@ pixelfed.postComponent = {};
import StatusCard from './partials/StatusCard.vue'; import StatusCard from './partials/StatusCard.vue';
import CommentCard from './partials/CommentCard.vue'; import CommentCard from './partials/CommentCard.vue';
import PollCard from './partials/PollCard.vue';
import CommentFeed from './partials/CommentFeed.vue';
export default { export default {
props: [ props: [
@ -560,7 +570,9 @@ export default {
components: { components: {
StatusCard, StatusCard,
CommentCard CommentCard,
CommentFeed,
PollCard
}, },
data() { data() {

View file

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

View file

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

View file

@ -1,6 +1,7 @@
<template> <template>
<div> <div class="status-card-component"
<div v-if="status.pf_type === 'text'" class="card shadow-none border border-top-0 rounded-0"> :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="card-body">
<div class="media"> <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"> <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> </a>
<span class="text-right" style="flex-grow:1;"> <span class="text-right" style="flex-grow:1;">
<button class="btn btn-link text-dark py-0" type="button" @click="ctxMenu()"> <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> <span class="sr-only">Post Menu</span>
</button> </button>
</span> </span>
@ -27,10 +28,10 @@
<details v-if="status.sensitive"> <details v-if="status.sensitive">
<summary class="mb-2 font-weight-bold text-muted">Content Warning</summary> <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> </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"> <p class="mb-0">
<i class="fa-heart fa-lg cursor-pointer mr-3" <i class="fa-heart fa-lg cursor-pointer mr-3"
@ -49,6 +50,10 @@
</div> </div>
</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-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 v-if="status" class="card-header d-inline-flex align-items-center bg-white">
<div> <div>
@ -179,6 +184,7 @@
<script type="text/javascript"> <script type="text/javascript">
import ContextMenu from './ContextMenu.vue'; import ContextMenu from './ContextMenu.vue';
import PollCard from './PollCard.vue';
export default { export default {
props: { props: {
@ -194,11 +200,23 @@
reactionBar: { reactionBar: {
type: Boolean, type: Boolean,
default: true default: true
},
hasTopBorder: {
type: Boolean,
default: false
},
size: {
type: String,
validator: (val) => ['regular', 'small'].includes(val),
default: 'regular'
} }
}, },
components: { components: {
"context-menu": ContextMenu "context-menu": ContextMenu,
"poll-card": PollCard
}, },
data() { data() {
@ -302,8 +320,9 @@
item: status.id item: status.id
}).then(res => { }).then(res => {
status.favourites_count = res.data.count; status.favourites_count = res.data.count;
status.favourited = !!status.favourited;
}).catch(err => { }).catch(err => {
status.favourited = !status.favourited; status.favourited = !!status.favourited;
status.favourites_count = count; status.favourites_count = count;
swal('Error', 'Something went wrong, please try again later.', 'error'); swal('Error', 'Something went wrong, please try again later.', 'error');
}); });
@ -367,3 +386,23 @@
} }
} }
</script> </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>

View file

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