mirror of
https://github.com/pixelfed/pixelfed.git
synced 2024-11-22 06:21:27 +00:00
Add Polls
This commit is contained in:
parent
5916f8c76a
commit
7709220074
23 changed files with 1819 additions and 321 deletions
73
app/Http/Controllers/PollController.php
Normal file
73
app/Http/Controllers/PollController.php
Normal 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;
|
||||
}
|
||||
}
|
|
@ -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')
|
||||
|
|
|
@ -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;
|
||||
protected $status;
|
||||
|
||||
/**
|
||||
* Delete the job if its models no longer exist.
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
public $deleteWhenMissingModels = true;
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
/**
|
||||
* 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;
|
||||
/**
|
||||
* Execute the job.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
$status = $this->status;
|
||||
$profile = $status->profile;
|
||||
|
||||
if($status->local == false || $status->url || $status->uri) {
|
||||
return;
|
||||
}
|
||||
if($status->local == false || $status->url || $status->uri) {
|
||||
return;
|
||||
}
|
||||
|
||||
$audience = $status->profile->getAudienceInbox();
|
||||
$audience = $status->profile->getAudienceInbox();
|
||||
|
||||
if(empty($audience) || !in_array($status->scope, ['public', 'unlisted', 'private'])) {
|
||||
// Return on profiles with no remote followers
|
||||
return;
|
||||
}
|
||||
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);
|
||||
$payload = json_encode($activity);
|
||||
|
||||
$client = new Client([
|
||||
'timeout' => config('federation.activitypub.delivery.timeout')
|
||||
]);
|
||||
$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
|
||||
]
|
||||
]);
|
||||
};
|
||||
}
|
||||
};
|
||||
$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) {
|
||||
}
|
||||
]);
|
||||
$pool = new Pool($client, $requests($audience), [
|
||||
'concurrency' => config('federation.activitypub.delivery.concurrency'),
|
||||
'fulfilled' => function ($response, $index) {
|
||||
},
|
||||
'rejected' => function ($reason, $index) {
|
||||
}
|
||||
]);
|
||||
|
||||
$promise = $pool->promise();
|
||||
$promise = $pool->promise();
|
||||
|
||||
$promise->wait();
|
||||
}
|
||||
$promise->wait();
|
||||
}
|
||||
}
|
||||
|
|
35
app/Models/Poll.php
Normal file
35
app/Models/Poll.php
Normal 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
11
app/Models/PollVote.php
Normal 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;
|
||||
}
|
72
app/Services/PollService.php
Normal file
72
app/Services/PollService.php
Normal 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') ?? [];
|
||||
}
|
||||
}
|
46
app/Transformer/ActivityPub/Verb/CreateQuestion.php
Normal file
46
app/Transformer/ActivityPub/Verb/CreateQuestion.php
Normal 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());
|
||||
}
|
||||
}
|
89
app/Transformer/ActivityPub/Verb/Question.php
Normal file
89
app/Transformer/ActivityPub/Verb/Question.php
Normal 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]
|
||||
]
|
||||
];
|
||||
})
|
||||
];
|
||||
}
|
||||
}
|
|
@ -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
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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' => [
|
||||
|
|
|
@ -6,4 +6,5 @@ return [
|
|||
'rec' => false,
|
||||
'loops' => false,
|
||||
'top' => env('EXP_TOP', false),
|
||||
'polls' => env('EXP_POLLS', false)
|
||||
];
|
||||
|
|
41
database/migrations/2021_07_29_014835_create_polls_table.php
Normal file
41
database/migrations/2021_07_29_014835_create_polls_table.php
Normal 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');
|
||||
}
|
||||
}
|
|
@ -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');
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -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">@{{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">@{{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>
|
||||
</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">
|
||||
<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>
|
||||
<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>
|
||||
</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>
|
||||
</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>
|
||||
<p class="float-right"><!-- <a class="btn btn-primary font-weight-bold py-1" href="#">Follow</a> --></p>
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
<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>
|
||||
<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>
|
||||
</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>
|
||||
<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>
|
||||
</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="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>
|
||||
</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>
|
||||
<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>
|
||||
|
|
|
@ -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
resources/assets/js/components/partials/CommentFeed.vue
Normal file
286
resources/assets/js/components/partials/CommentFeed.vue
Normal 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>
|
327
resources/assets/js/components/partials/PollCard.vue
Normal file
327
resources/assets/js/components/partials/PollCard.vue
Normal 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>
|
|
@ -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>
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
Loading…
Reference in a new issue