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();
|
$profile = Profile::whereUsername($username)->whereNull('status')->firstOrFail();
|
||||||
$status = Status::whereProfileId($profile->id)->findOrFail($postid);
|
$status = Status::whereProfileId($profile->id)->findOrFail($postid);
|
||||||
$this->scopeCheck($profile, $status);
|
$this->scopeCheck($profile, $status);
|
||||||
if(!Auth::check()) {
|
if(!$request->user()) {
|
||||||
$res = Cache::remember('wapi:v1:status:stateless_byid:' . $status->id, now()->addMinutes(30), function() use($status) {
|
$res = ['status' => StatusService::get($status->id)];
|
||||||
$item = new Fractal\Resource\Item($status, new StatusStatelessTransformer());
|
} else {
|
||||||
$res = [
|
$item = new Fractal\Resource\Item($status, new StatusStatelessTransformer());
|
||||||
'status' => $this->fractal->createData($item)->toArray(),
|
$res = [
|
||||||
];
|
'status' => $this->fractal->createData($item)->toArray(),
|
||||||
return $res;
|
];
|
||||||
});
|
|
||||||
return response()->json($res);
|
|
||||||
}
|
}
|
||||||
$item = new Fractal\Resource\Item($status, new StatusStatelessTransformer());
|
|
||||||
$res = [
|
|
||||||
'status' => $this->fractal->createData($item)->toArray(),
|
|
||||||
];
|
|
||||||
return response()->json($res);
|
return response()->json($res);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -403,11 +398,22 @@ class PublicApiController extends Controller
|
||||||
}
|
}
|
||||||
|
|
||||||
$filtered = $user ? UserFilterService::filters($user->profile_id) : [];
|
$filtered = $user ? UserFilterService::filters($user->profile_id) : [];
|
||||||
$textOnlyPosts = (bool) Redis::zscore('pf:tl:top', $pid);
|
$types = ['photo', 'photo:album', 'video', 'video:album', 'photo:video:album'];
|
||||||
$textOnlyReplies = (bool) Redis::zscore('pf:tl:replies', $pid);
|
|
||||||
$types = $textOnlyPosts ?
|
$textOnlyReplies = false;
|
||||||
['text', 'photo', 'photo:album', 'video', 'video:album', 'photo:video:album'] :
|
|
||||||
['photo', 'photo:album', 'video', 'video:album', 'photo:video:album'];
|
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) {
|
if($min || $max) {
|
||||||
$dir = $min ? '>' : '<';
|
$dir = $min ? '>' : '<';
|
||||||
|
@ -433,7 +439,7 @@ class PublicApiController extends Controller
|
||||||
'updated_at'
|
'updated_at'
|
||||||
)
|
)
|
||||||
->whereIn('type', $types)
|
->whereIn('type', $types)
|
||||||
->when(!$textOnlyReplies, function($q, $textOnlyReplies) {
|
->when($textOnlyReplies != true, function($q, $textOnlyReplies) {
|
||||||
return $q->whereNull('in_reply_to_id');
|
return $q->whereNull('in_reply_to_id');
|
||||||
})
|
})
|
||||||
->with('profile', 'hashtags', 'mentions')
|
->with('profile', 'hashtags', 'mentions')
|
||||||
|
|
|
@ -12,6 +12,7 @@ use Illuminate\Queue\SerializesModels;
|
||||||
use League\Fractal;
|
use League\Fractal;
|
||||||
use League\Fractal\Serializer\ArraySerializer;
|
use League\Fractal\Serializer\ArraySerializer;
|
||||||
use App\Transformer\ActivityPub\Verb\CreateNote;
|
use App\Transformer\ActivityPub\Verb\CreateNote;
|
||||||
|
use App\Transformer\ActivityPub\Verb\CreateQuestion;
|
||||||
use App\Util\ActivityPub\Helpers;
|
use App\Util\ActivityPub\Helpers;
|
||||||
use GuzzleHttp\Pool;
|
use GuzzleHttp\Pool;
|
||||||
use GuzzleHttp\Client;
|
use GuzzleHttp\Client;
|
||||||
|
@ -20,85 +21,97 @@ use App\Util\ActivityPub\HttpSignature;
|
||||||
|
|
||||||
class StatusActivityPubDeliver implements ShouldQueue
|
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;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a new job instance.
|
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
*/
|
|
||||||
public function __construct(Status $status)
|
|
||||||
{
|
|
||||||
$this->status = $status;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Execute the job.
|
* Delete the job if its models no longer exist.
|
||||||
*
|
*
|
||||||
* @return void
|
* @var bool
|
||||||
*/
|
*/
|
||||||
public function handle()
|
public $deleteWhenMissingModels = true;
|
||||||
{
|
|
||||||
$status = $this->status;
|
|
||||||
$profile = $status->profile;
|
|
||||||
|
|
||||||
if($status->local == false || $status->url || $status->uri) {
|
/**
|
||||||
return;
|
* Create a new job instance.
|
||||||
}
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function __construct(Status $status)
|
||||||
|
{
|
||||||
|
$this->status = $status;
|
||||||
|
}
|
||||||
|
|
||||||
$audience = $status->profile->getAudienceInbox();
|
/**
|
||||||
|
* Execute the job.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function handle()
|
||||||
|
{
|
||||||
|
$status = $this->status;
|
||||||
|
$profile = $status->profile;
|
||||||
|
|
||||||
if(empty($audience) || !in_array($status->scope, ['public', 'unlisted', 'private'])) {
|
if($status->local == false || $status->url || $status->uri) {
|
||||||
// Return on profiles with no remote followers
|
return;
|
||||||
return;
|
}
|
||||||
}
|
|
||||||
|
$audience = $status->profile->getAudienceInbox();
|
||||||
|
|
||||||
|
if(empty($audience) || !in_array($status->scope, ['public', 'unlisted', 'private'])) {
|
||||||
|
// Return on profiles with no remote followers
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch($status->type) {
|
||||||
|
case 'poll':
|
||||||
|
$activitypubObject = new CreateQuestion();
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
$activitypubObject = new CreateNote();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
$fractal = new Fractal\Manager();
|
$fractal = new Fractal\Manager();
|
||||||
$fractal->setSerializer(new ArraySerializer());
|
$fractal->setSerializer(new ArraySerializer());
|
||||||
$resource = new Fractal\Resource\Item($status, new CreateNote());
|
$resource = new Fractal\Resource\Item($status, $activitypubObject);
|
||||||
$activity = $fractal->createData($resource)->toArray();
|
$activity = $fractal->createData($resource)->toArray();
|
||||||
|
|
||||||
$payload = json_encode($activity);
|
$payload = json_encode($activity);
|
||||||
|
|
||||||
$client = new Client([
|
|
||||||
'timeout' => config('federation.activitypub.delivery.timeout')
|
|
||||||
]);
|
|
||||||
|
|
||||||
$requests = function($audience) use ($client, $activity, $profile, $payload) {
|
$client = new Client([
|
||||||
foreach($audience as $url) {
|
'timeout' => config('federation.activitypub.delivery.timeout')
|
||||||
$headers = HttpSignature::sign($profile, $url, $activity);
|
]);
|
||||||
yield function() use ($client, $url, $headers, $payload) {
|
|
||||||
return $client->postAsync($url, [
|
|
||||||
'curl' => [
|
|
||||||
CURLOPT_HTTPHEADER => $headers,
|
|
||||||
CURLOPT_POSTFIELDS => $payload,
|
|
||||||
CURLOPT_HEADER => true
|
|
||||||
]
|
|
||||||
]);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
$pool = new Pool($client, $requests($audience), [
|
$requests = function($audience) use ($client, $activity, $profile, $payload) {
|
||||||
'concurrency' => config('federation.activitypub.delivery.concurrency'),
|
foreach($audience as $url) {
|
||||||
'fulfilled' => function ($response, $index) {
|
$headers = HttpSignature::sign($profile, $url, $activity);
|
||||||
},
|
yield function() use ($client, $url, $headers, $payload) {
|
||||||
'rejected' => function ($reason, $index) {
|
return $client->postAsync($url, [
|
||||||
}
|
'curl' => [
|
||||||
]);
|
CURLOPT_HTTPHEADER => $headers,
|
||||||
|
CURLOPT_POSTFIELDS => $payload,
|
||||||
$promise = $pool->promise();
|
CURLOPT_HEADER => true,
|
||||||
|
CURLOPT_SSL_VERIFYPEER => false,
|
||||||
|
CURLOPT_SSL_VERIFYHOST => false
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
$promise->wait();
|
$pool = new Pool($client, $requests($audience), [
|
||||||
}
|
'concurrency' => config('federation.activitypub.delivery.concurrency'),
|
||||||
|
'fulfilled' => function ($response, $index) {
|
||||||
|
},
|
||||||
|
'rejected' => function ($reason, $index) {
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
$promise = $pool->promise();
|
||||||
|
|
||||||
|
$promise->wait();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
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\StatusHashtagService;
|
||||||
use App\Services\StatusLabelService;
|
use App\Services\StatusLabelService;
|
||||||
use App\Services\ProfileService;
|
use App\Services\ProfileService;
|
||||||
|
use App\Services\PollService;
|
||||||
|
|
||||||
class StatusStatelessTransformer extends Fractal\TransformerAbstract
|
class StatusStatelessTransformer extends Fractal\TransformerAbstract
|
||||||
{
|
{
|
||||||
public function transform(Status $status)
|
public function transform(Status $status)
|
||||||
{
|
{
|
||||||
$taggedPeople = MediaTagService::get($status->id);
|
$taggedPeople = MediaTagService::get($status->id);
|
||||||
|
$poll = $status->type === 'poll' ? PollService::get($status->id) : null;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'_v' => 1,
|
'_v' => 1,
|
||||||
|
@ -61,7 +63,8 @@ class StatusStatelessTransformer extends Fractal\TransformerAbstract
|
||||||
'liked_by' => LikeService::likedBy($status),
|
'liked_by' => LikeService::likedBy($status),
|
||||||
'media_attachments' => MediaService::get($status->id),
|
'media_attachments' => MediaService::get($status->id),
|
||||||
'account' => ProfileService::get($status->profile_id),
|
'account' => ProfileService::get($status->profile_id),
|
||||||
'tags' => StatusHashtagService::statusTags($status->id)
|
'tags' => StatusHashtagService::statusTags($status->id),
|
||||||
|
'poll' => $poll
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,12 +14,14 @@ use App\Services\StatusHashtagService;
|
||||||
use App\Services\StatusLabelService;
|
use App\Services\StatusLabelService;
|
||||||
use App\Services\ProfileService;
|
use App\Services\ProfileService;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
use App\Services\PollService;
|
||||||
|
|
||||||
class StatusTransformer extends Fractal\TransformerAbstract
|
class StatusTransformer extends Fractal\TransformerAbstract
|
||||||
{
|
{
|
||||||
public function transform(Status $status)
|
public function transform(Status $status)
|
||||||
{
|
{
|
||||||
$taggedPeople = MediaTagService::get($status->id);
|
$taggedPeople = MediaTagService::get($status->id);
|
||||||
|
$poll = $status->type === 'poll' ? PollService::get($status->id, request()->user()->profile_id) : null;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'_v' => 1,
|
'_v' => 1,
|
||||||
|
@ -63,7 +65,8 @@ class StatusTransformer extends Fractal\TransformerAbstract
|
||||||
'liked_by' => LikeService::likedBy($status),
|
'liked_by' => LikeService::likedBy($status),
|
||||||
'media_attachments' => MediaService::get($status->id),
|
'media_attachments' => MediaService::get($status->id),
|
||||||
'account' => ProfileService::get($status->profile_id),
|
'account' => ProfileService::get($status->profile_id),
|
||||||
'tags' => StatusHashtagService::statusTags($status->id)
|
'tags' => StatusHashtagService::statusTags($status->id),
|
||||||
|
'poll' => $poll,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -33,6 +33,7 @@ use App\Services\MediaStorageService;
|
||||||
use App\Jobs\MediaPipeline\MediaStoragePipeline;
|
use App\Jobs\MediaPipeline\MediaStoragePipeline;
|
||||||
use App\Jobs\AvatarPipeline\RemoteAvatarFetch;
|
use App\Jobs\AvatarPipeline\RemoteAvatarFetch;
|
||||||
use App\Util\Media\License;
|
use App\Util\Media\License;
|
||||||
|
use App\Models\Poll;
|
||||||
|
|
||||||
class Helpers {
|
class Helpers {
|
||||||
|
|
||||||
|
@ -270,7 +271,7 @@ class Helpers {
|
||||||
|
|
||||||
$res = self::fetchFromUrl($url);
|
$res = self::fetchFromUrl($url);
|
||||||
|
|
||||||
if(!$res || empty($res) || isset($res['error']) ) {
|
if(!$res || empty($res) || isset($res['error']) || !isset($res['@context']) ) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -331,7 +332,6 @@ class Helpers {
|
||||||
$idDomain = parse_url($id, PHP_URL_HOST);
|
$idDomain = parse_url($id, PHP_URL_HOST);
|
||||||
$urlDomain = parse_url($url, PHP_URL_HOST);
|
$urlDomain = parse_url($url, PHP_URL_HOST);
|
||||||
|
|
||||||
|
|
||||||
if(!self::validateUrl($id)) {
|
if(!self::validateUrl($id)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -368,6 +368,7 @@ class Helpers {
|
||||||
$cw = true;
|
$cw = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
$statusLockKey = 'helpers:status-lock:' . hash('sha256', $res['id']);
|
$statusLockKey = 'helpers:status-lock:' . hash('sha256', $res['id']);
|
||||||
$status = Cache::lock($statusLockKey)
|
$status = Cache::lock($statusLockKey)
|
||||||
->get(function () use(
|
->get(function () use(
|
||||||
|
@ -380,6 +381,19 @@ class Helpers {
|
||||||
$scope,
|
$scope,
|
||||||
$id
|
$id
|
||||||
) {
|
) {
|
||||||
|
if($res['type'] === 'Question') {
|
||||||
|
$status = self::storePoll(
|
||||||
|
$profile,
|
||||||
|
$res,
|
||||||
|
$url,
|
||||||
|
$ts,
|
||||||
|
$reply_to,
|
||||||
|
$cw,
|
||||||
|
$scope,
|
||||||
|
$id
|
||||||
|
);
|
||||||
|
return $status;
|
||||||
|
}
|
||||||
return DB::transaction(function() use($profile, $res, $url, $ts, $reply_to, $cw, $scope, $id) {
|
return DB::transaction(function() use($profile, $res, $url, $ts, $reply_to, $cw, $scope, $id) {
|
||||||
$status = new Status;
|
$status = new Status;
|
||||||
$status->profile_id = $profile->id;
|
$status->profile_id = $profile->id;
|
||||||
|
@ -409,6 +423,55 @@ class Helpers {
|
||||||
return $status;
|
return $status;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static function storePoll($profile, $res, $url, $ts, $reply_to, $cw, $scope, $id)
|
||||||
|
{
|
||||||
|
if(!isset($res['endTime']) || !isset($res['oneOf']) || !is_array($res['oneOf']) || count($res['oneOf']) > 4) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$options = collect($res['oneOf'])->map(function($option) {
|
||||||
|
return $option['name'];
|
||||||
|
})->toArray();
|
||||||
|
|
||||||
|
$cachedTallies = collect($res['oneOf'])->map(function($option) {
|
||||||
|
return $option['replies']['totalItems'] ?? 0;
|
||||||
|
})->toArray();
|
||||||
|
|
||||||
|
$status = new Status;
|
||||||
|
$status->profile_id = $profile->id;
|
||||||
|
$status->url = isset($res['url']) ? $res['url'] : $url;
|
||||||
|
$status->uri = isset($res['url']) ? $res['url'] : $url;
|
||||||
|
$status->object_url = $id;
|
||||||
|
$status->caption = strip_tags($res['content']);
|
||||||
|
$status->rendered = Purify::clean($res['content']);
|
||||||
|
$status->created_at = Carbon::parse($ts);
|
||||||
|
$status->in_reply_to_id = null;
|
||||||
|
$status->local = false;
|
||||||
|
$status->is_nsfw = $cw;
|
||||||
|
$status->scope = 'draft';
|
||||||
|
$status->visibility = 'draft';
|
||||||
|
$status->cw_summary = $cw == true && isset($res['summary']) ?
|
||||||
|
Purify::clean(strip_tags($res['summary'])) : null;
|
||||||
|
$status->save();
|
||||||
|
|
||||||
|
$poll = new Poll;
|
||||||
|
$poll->status_id = $status->id;
|
||||||
|
$poll->profile_id = $status->profile_id;
|
||||||
|
$poll->poll_options = $options;
|
||||||
|
$poll->cached_tallies = $cachedTallies;
|
||||||
|
$poll->votes_count = array_sum($cachedTallies);
|
||||||
|
$poll->expires_at = now()->parse($res['endTime']);
|
||||||
|
$poll->last_fetched_at = now();
|
||||||
|
$poll->save();
|
||||||
|
|
||||||
|
$status->type = 'poll';
|
||||||
|
$status->scope = $scope;
|
||||||
|
$status->visibility = $scope;
|
||||||
|
$status->save();
|
||||||
|
|
||||||
|
return $status;
|
||||||
|
}
|
||||||
|
|
||||||
public static function statusFetch($url)
|
public static function statusFetch($url)
|
||||||
{
|
{
|
||||||
return self::statusFirstOrFetch($url);
|
return self::statusFirstOrFetch($url);
|
||||||
|
|
|
@ -30,6 +30,8 @@ use App\Util\ActivityPub\Validator\Follow as FollowValidator;
|
||||||
use App\Util\ActivityPub\Validator\Like as LikeValidator;
|
use App\Util\ActivityPub\Validator\Like as LikeValidator;
|
||||||
use App\Util\ActivityPub\Validator\UndoFollow as UndoFollowValidator;
|
use App\Util\ActivityPub\Validator\UndoFollow as UndoFollowValidator;
|
||||||
|
|
||||||
|
use App\Services\PollService;
|
||||||
|
|
||||||
class Inbox
|
class Inbox
|
||||||
{
|
{
|
||||||
protected $headers;
|
protected $headers;
|
||||||
|
@ -147,6 +149,12 @@ class Inbox
|
||||||
}
|
}
|
||||||
$to = $activity['to'];
|
$to = $activity['to'];
|
||||||
$cc = isset($activity['cc']) ? $activity['cc'] : [];
|
$cc = isset($activity['cc']) ? $activity['cc'] : [];
|
||||||
|
|
||||||
|
if($activity['type'] == 'Question') {
|
||||||
|
$this->handlePollCreate();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if(count($to) == 1 &&
|
if(count($to) == 1 &&
|
||||||
count($cc) == 0 &&
|
count($cc) == 0 &&
|
||||||
parse_url($to[0], PHP_URL_HOST) == config('pixelfed.domain.app')
|
parse_url($to[0], PHP_URL_HOST) == config('pixelfed.domain.app')
|
||||||
|
@ -154,10 +162,11 @@ class Inbox
|
||||||
$this->handleDirectMessage();
|
$this->handleDirectMessage();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if($activity['type'] == 'Note' && !empty($activity['inReplyTo'])) {
|
if($activity['type'] == 'Note' && !empty($activity['inReplyTo'])) {
|
||||||
$this->handleNoteReply();
|
$this->handleNoteReply();
|
||||||
|
|
||||||
} elseif($activity['type'] == 'Note' && !empty($activity['attachment'])) {
|
} elseif ($activity['type'] == 'Note' && !empty($activity['attachment'])) {
|
||||||
if(!$this->verifyNoteAttachment()) {
|
if(!$this->verifyNoteAttachment()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -180,6 +189,18 @@ class Inbox
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function handlePollCreate()
|
||||||
|
{
|
||||||
|
$activity = $this->payload['object'];
|
||||||
|
$actor = $this->actorFirstOrCreate($this->payload['actor']);
|
||||||
|
if(!$actor || $actor->domain == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$url = isset($activity['url']) ? $activity['url'] : $activity['id'];
|
||||||
|
Helpers::statusFirstOrFetch($url);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
public function handleNoteCreate()
|
public function handleNoteCreate()
|
||||||
{
|
{
|
||||||
$activity = $this->payload['object'];
|
$activity = $this->payload['object'];
|
||||||
|
@ -188,6 +209,16 @@ class Inbox
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if( isset($activity['inReplyTo']) &&
|
||||||
|
isset($activity['name']) &&
|
||||||
|
!isset($activity['content']) &&
|
||||||
|
!isset($activity['attachment'] &&
|
||||||
|
Helpers::validateLocalUrl($activity['inReplyTo']))
|
||||||
|
) {
|
||||||
|
$this->handlePollVote();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if($actor->followers()->count() == 0) {
|
if($actor->followers()->count() == 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -200,6 +231,51 @@ class Inbox
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function handlePollVote()
|
||||||
|
{
|
||||||
|
$activity = $this->payload['object'];
|
||||||
|
$actor = $this->actorFirstOrCreate($this->payload['actor']);
|
||||||
|
$status = Helpers::statusFetch($activity['inReplyTo']);
|
||||||
|
$poll = $status->poll;
|
||||||
|
|
||||||
|
if(!$status || !$poll) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if(now()->gt($poll->expires_at)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$choices = $poll->poll_options;
|
||||||
|
$choice = array_search($activity['name'], $choices);
|
||||||
|
|
||||||
|
if($choice === false) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if(PollVote::whereStatusId($status->id)->whereProfileId($actor->id)->exists()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$vote = new PollVote;
|
||||||
|
$vote->status_id = $status->id;
|
||||||
|
$vote->profile_id = $actor->id;
|
||||||
|
$vote->poll_id = $poll->id;
|
||||||
|
$vote->choice = $choice;
|
||||||
|
$vote->uri = isset($activity['id']) ? $activity['id'] : null;
|
||||||
|
$vote->save();
|
||||||
|
|
||||||
|
$tallies = $poll->cached_tallies;
|
||||||
|
$tallies[$choice] = $tallies[$choice] + 1;
|
||||||
|
$poll->cached_tallies = $tallies;
|
||||||
|
$poll->votes_count = array_sum($tallies);
|
||||||
|
$poll->save();
|
||||||
|
|
||||||
|
PollService::del($status->id);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
public function handleDirectMessage()
|
public function handleDirectMessage()
|
||||||
{
|
{
|
||||||
$activity = $this->payload['object'];
|
$activity = $this->payload['object'];
|
||||||
|
@ -558,10 +634,8 @@ class Inbox
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
public function handleRejectActivity()
|
public function handleRejectActivity()
|
||||||
{
|
{
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function handleUndoActivity()
|
public function handleUndoActivity()
|
||||||
|
|
|
@ -7,7 +7,7 @@ use Illuminate\Support\Str;
|
||||||
|
|
||||||
class Config {
|
class Config {
|
||||||
|
|
||||||
const CACHE_KEY = 'api:site:configuration:_v0.3';
|
const CACHE_KEY = 'api:site:configuration:_v0.4';
|
||||||
|
|
||||||
public static function get() {
|
public static function get() {
|
||||||
return Cache::remember(self::CACHE_KEY, now()->addMinutes(5), function() {
|
return Cache::remember(self::CACHE_KEY, now()->addMinutes(5), function() {
|
||||||
|
@ -37,7 +37,8 @@ class Config {
|
||||||
'lc' => config('exp.lc'),
|
'lc' => config('exp.lc'),
|
||||||
'rec' => config('exp.rec'),
|
'rec' => config('exp.rec'),
|
||||||
'loops' => config('exp.loops'),
|
'loops' => config('exp.loops'),
|
||||||
'top' => config('exp.top')
|
'top' => config('exp.top'),
|
||||||
|
'polls' => config('exp.polls')
|
||||||
],
|
],
|
||||||
|
|
||||||
'site' => [
|
'site' => [
|
||||||
|
|
|
@ -6,4 +6,5 @@ return [
|
||||||
'rec' => false,
|
'rec' => false,
|
||||||
'loops' => false,
|
'loops' => false,
|
||||||
'top' => env('EXP_TOP', false),
|
'top' => env('EXP_TOP', false),
|
||||||
|
'polls' => env('EXP_POLLS', false)
|
||||||
];
|
];
|
||||||
|
|
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>
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="page == 'poll'">
|
||||||
|
<div class="card status-card card-md-rounded-0" style="display:flex;">
|
||||||
|
<div class="card-header d-inline-flex align-items-center justify-content-between bg-white">
|
||||||
|
<span class="pr-3">
|
||||||
|
<i class="fas fa-info-circle fa-lg text-primary"></i>
|
||||||
|
</span>
|
||||||
|
<span class="font-weight-bold">
|
||||||
|
New Poll
|
||||||
|
</span>
|
||||||
|
<span v-if="postingPoll">
|
||||||
|
<div class="spinner-border spinner-border-sm" role="status">
|
||||||
|
<span class="sr-only">Loading...</span>
|
||||||
|
</div>
|
||||||
|
</span>
|
||||||
|
<button v-else-if="!postingPoll && pollOptions.length > 1 && composeText.length" class="btn btn-primary btn-sm font-weight-bold" @click="postNewPoll">
|
||||||
|
<span>Create Poll</span>
|
||||||
|
</button>
|
||||||
|
<span v-else class="font-weight-bold text-lighter">
|
||||||
|
Create Poll
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="h-100 card-body p-0 border-top" style="width:100%; min-height: 400px;">
|
||||||
|
<div class="border-bottom mt-2">
|
||||||
|
<div class="media px-3">
|
||||||
|
<img src="/storage/avatars/default.png" width="42px" height="42px" class="rounded-circle">
|
||||||
|
<div class="media-body">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="font-weight-bold text-muted small d-none">Caption</label>
|
||||||
|
<vue-tribute :options="tributeSettings">
|
||||||
|
<textarea class="form-control border-0 rounded-0 no-focus" rows="3" placeholder="Write a poll question..." style="" v-model="composeText" v-on:keyup="composeTextLength = composeText.length"></textarea>
|
||||||
|
</vue-tribute>
|
||||||
|
<p class="help-text small text-right text-muted mb-0">{{composeTextLength}}/{{config.uploader.max_caption_length}}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-3">
|
||||||
|
<p class="font-weight-bold text-muted small">
|
||||||
|
Poll Options
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div v-if="pollOptions.length < 4" class="form-group mb-4">
|
||||||
|
<input type="text" class="form-control rounded-pill" placeholder="Add a poll option, press enter to save" v-model="pollOptionModel" @keyup.enter="savePollOption">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-for="(option, index) in pollOptions" class="form-group mb-4 d-flex align-items-center" style="max-width:400px;position: relative;">
|
||||||
|
<span class="font-weight-bold mr-2" style="position: absolute;left: 10px;">{{ index + 1 }}.</span>
|
||||||
|
<input v-if="pollOptions[index].length < 50" type="text" class="form-control rounded-pill" placeholder="Add a poll option, press enter to save" v-model="pollOptions[index]" style="padding-left: 30px;padding-right: 90px;">
|
||||||
|
<textarea v-else class="form-control" v-model="pollOptions[index]" placeholder="Add a poll option, press enter to save" rows="3" style="padding-left: 30px;padding-right:90px;"></textarea>
|
||||||
|
<button class="btn btn-danger btn-sm rounded-pill font-weight-bold" style="position: absolute;right: 5px;" @click="deletePollOption(index)">
|
||||||
|
<i class="fas fa-trash"></i> Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-between">
|
||||||
|
<div>
|
||||||
|
<p class="font-weight-bold text-muted small">
|
||||||
|
Poll Expiry
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<select class="form-control rounded-pill" style="width: 200px;" v-model="pollExpiry">
|
||||||
|
<option value="60">1 hour</option>
|
||||||
|
<option value="360">6 hours</option>
|
||||||
|
<option value="1440" selected>24 hours</option>
|
||||||
|
<option value="10080">7 days</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p class="font-weight-bold text-muted small">
|
||||||
|
Poll Visibility
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<select class="form-control rounded-pill" style="max-width: 200px;" v-model="visibility">
|
||||||
|
<option value="public">Public</option>
|
||||||
|
<option value="private">Followers Only</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-else>
|
<div v-else>
|
||||||
<div class="card status-card card-md-rounded-0 w-100 h-100" style="display:flex;">
|
<div class="card status-card card-md-rounded-0 w-100 h-100" style="display:flex;">
|
||||||
<div class="card-header d-inline-flex align-items-center justify-content-between bg-white">
|
<div class="card-header d-inline-flex align-items-center justify-content-between bg-white">
|
||||||
|
@ -147,7 +238,7 @@
|
||||||
<div v-if="page == 1" class="w-100 h-100 d-flex justify-content-center align-items-center" style="min-height: 400px;">
|
<div v-if="page == 1" class="w-100 h-100 d-flex justify-content-center align-items-center" style="min-height: 400px;">
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<div v-if="media.length == 0" class="card mx-md-5 my-md-3 shadow-none border compose-action text-decoration-none text-dark">
|
<div v-if="media.length == 0" class="card mx-md-5 my-md-3 shadow-none border compose-action text-decoration-none text-dark">
|
||||||
<div @click.prevent="addMedia" class="card-body">
|
<div @click.prevent="addMedia" class="card-body py-2">
|
||||||
<div class="media">
|
<div class="media">
|
||||||
<div class="mr-3 align-items-center justify-content-center" style="display:inline-flex;width:40px;height:40px;border-radius: 100%;background-color: #008DF5">
|
<div class="mr-3 align-items-center justify-content-center" style="display:inline-flex;width:40px;height:40px;border-radius: 100%;background-color: #008DF5">
|
||||||
<i class="fas fa-bolt text-white fa-lg"></i>
|
<i class="fas fa-bolt text-white fa-lg"></i>
|
||||||
|
@ -163,7 +254,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="config.ab.top == true && media.length == 0" class="card mx-md-5 my-md-3 shadow-none border compose-action text-decoration-none text-dark">
|
<div v-if="config.ab.top == true && media.length == 0" class="card mx-md-5 my-md-3 shadow-none border compose-action text-decoration-none text-dark">
|
||||||
<div @click.prevent="addText" class="card-body">
|
<div @click.prevent="addText" class="card-body py-2">
|
||||||
<div class="media">
|
<div class="media">
|
||||||
<div class="mr-3 align-items-center justify-content-center" style="display:inline-flex;width:40px;height:40px;border-radius: 100%;border: 2px solid #008DF5">
|
<div class="mr-3 align-items-center justify-content-center" style="display:inline-flex;width:40px;height:40px;border-radius: 100%;border: 2px solid #008DF5">
|
||||||
<i class="far fa-edit text-primary fa-lg"></i>
|
<i class="far fa-edit text-primary fa-lg"></i>
|
||||||
|
@ -182,7 +273,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<a v-if="config.features.stories == true" class="card mx-md-5 my-md-3 shadow-none border compose-action text-decoration-none text-dark" href="/i/stories/new">
|
<a v-if="config.features.stories == true" class="card mx-md-5 my-md-3 shadow-none border compose-action text-decoration-none text-dark" href="/i/stories/new">
|
||||||
<div class="card-body">
|
<div class="card-body py-2">
|
||||||
<div class="media">
|
<div class="media">
|
||||||
<div class="mr-3 align-items-center justify-content-center" style="display:inline-flex;width:40px;height:40px;border-radius: 100%;border: 2px solid #008DF5">
|
<div class="mr-3 align-items-center justify-content-center" style="display:inline-flex;width:40px;height:40px;border-radius: 100%;border: 2px solid #008DF5">
|
||||||
<i class="fas fa-history text-primary fa-lg"></i>
|
<i class="fas fa-history text-primary fa-lg"></i>
|
||||||
|
@ -200,8 +291,27 @@
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
<a v-if="config.ab.polls == true" class="card mx-md-5 my-md-3 shadow-none border compose-action text-decoration-none text-dark" href="#" @click.prevent="newPoll">
|
||||||
|
<div class="card-body py-2">
|
||||||
|
<div class="media">
|
||||||
|
<div class="mr-3 align-items-center justify-content-center" style="display:inline-flex;width:40px;height:40px;border-radius: 100%;border: 2px solid #008DF5">
|
||||||
|
<i class="fas fa-poll-h text-primary fa-lg"></i>
|
||||||
|
</div>
|
||||||
|
<div class="media-body text-left">
|
||||||
|
<p class="mb-0">
|
||||||
|
<span class="h5 mt-0 font-weight-bold text-primary">New Poll</span>
|
||||||
|
<sup class="float-right mt-2">
|
||||||
|
<span class="btn btn-outline-lighter p-1 btn-sm font-weight-bold py-0" style="font-size:10px;line-height: 0.6">BETA</span>
|
||||||
|
</sup>
|
||||||
|
</p>
|
||||||
|
<p class="mb-0 text-muted">Create a poll</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
|
||||||
<a class="card mx-md-5 my-md-3 shadow-none border compose-action text-decoration-none text-dark" href="/i/collections/create">
|
<a class="card mx-md-5 my-md-3 shadow-none border compose-action text-decoration-none text-dark" href="/i/collections/create">
|
||||||
<div class="card-body">
|
<div class="card-body py-2">
|
||||||
<div class="media">
|
<div class="media">
|
||||||
<div class="mr-3 align-items-center justify-content-center" style="display:inline-flex;width:40px;height:40px;border-radius: 100%;border: 2px solid #008DF5">
|
<div class="mr-3 align-items-center justify-content-center" style="display:inline-flex;width:40px;height:40px;border-radius: 100%;border: 2px solid #008DF5">
|
||||||
<i class="fas fa-images text-primary fa-lg"></i>
|
<i class="fas fa-images text-primary fa-lg"></i>
|
||||||
|
@ -906,7 +1016,11 @@ export default {
|
||||||
},
|
},
|
||||||
licenseId: 1,
|
licenseId: 1,
|
||||||
licenseTitle: null,
|
licenseTitle: null,
|
||||||
maxAltTextLength: 140
|
maxAltTextLength: 140,
|
||||||
|
pollOptionModel: null,
|
||||||
|
pollOptions: [],
|
||||||
|
pollExpiry: 1440,
|
||||||
|
postingPoll: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@ -1590,6 +1704,53 @@ export default {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
newPoll() {
|
||||||
|
this.page = 'poll';
|
||||||
|
},
|
||||||
|
|
||||||
|
savePollOption() {
|
||||||
|
if(this.pollOptions.indexOf(this.pollOptionModel) != -1) {
|
||||||
|
this.pollOptionModel = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.pollOptions.push(this.pollOptionModel);
|
||||||
|
this.pollOptionModel = null;
|
||||||
|
},
|
||||||
|
|
||||||
|
deletePollOption(index) {
|
||||||
|
this.pollOptions.splice(index, 1);
|
||||||
|
},
|
||||||
|
|
||||||
|
postNewPoll() {
|
||||||
|
this.postingPoll = true;
|
||||||
|
axios.post('/api/compose/v0/poll', {
|
||||||
|
caption: this.composeText,
|
||||||
|
cw: false,
|
||||||
|
visibility: this.visibility,
|
||||||
|
comments_disabled: false,
|
||||||
|
expiry: this.pollExpiry,
|
||||||
|
pollOptions: this.pollOptions
|
||||||
|
}).then(res => {
|
||||||
|
if(!res.data.hasOwnProperty('url')) {
|
||||||
|
swal('Oops!', 'An error occured while attempting to create this poll. Please refresh the page and try again.', 'error');
|
||||||
|
this.postingPoll = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.location.href = res.data.url;
|
||||||
|
}).catch(err => {
|
||||||
|
console.log(err.response.data.error);
|
||||||
|
if(err.response.data.hasOwnProperty('error')) {
|
||||||
|
if(err.response.data.error == 'Duplicate detected.') {
|
||||||
|
this.postingPoll = false;
|
||||||
|
swal('Oops!', 'The poll you are trying to create is similar to an existing poll you created. Please make the poll question (caption) unique.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.postingPoll = false;
|
||||||
|
swal('Oops!', 'An error occured while attempting to create this poll. Please refresh the page and try again.', 'error');
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -454,235 +454,317 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<b-modal ref="likesModal"
|
<div v-if="layout == 'poll'" class="container px-0">
|
||||||
id="l-modal"
|
<div class="row justify-content-center">
|
||||||
hide-footer
|
<div class="col-12 col-md-6">
|
||||||
centered
|
|
||||||
title="Likes"
|
<div v-if="loading || !user || !reactions" class="text-center">
|
||||||
body-class="list-group-flush py-3 px-0">
|
<div class="spinner-border" role="status">
|
||||||
<div class="list-group">
|
<span class="sr-only">Loading...</span>
|
||||||
<div class="list-group-item border-0 py-1" v-for="(user, index) in likes" :key="'modal_likes_'+index">
|
</div>
|
||||||
<div class="media">
|
</div>
|
||||||
<a :href="user.url">
|
<div v-else>
|
||||||
<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';">
|
<poll-card
|
||||||
</a>
|
:status="status"
|
||||||
<div class="media-body">
|
:profile="user"
|
||||||
<p class="mb-0" style="font-size: 14px">
|
:showBorderTop="true"
|
||||||
<a :href="user.url" class="font-weight-bold text-dark">
|
:fetch-state="true"
|
||||||
{{user.username}}
|
:reactions="reactions"
|
||||||
</a>
|
v-on:likeStatus="likeStatus" />
|
||||||
</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">
|
<comment-feed :status="status" class="mt-3" />
|
||||||
<span class="font-weight-bold">{{user.acct.split('@')[0]}}</span><span class="text-lighter">@{{user.acct.split('@')[1]}}</span>
|
<!-- <div v-if="user.hasOwnProperty('id')" class="card card-body shadow-none border border-top-0 bg-light">
|
||||||
</p>
|
<div class="media">
|
||||||
<p v-else class="text-muted mb-0 text-truncate" style="font-size: 14px">
|
<img src="/storage/avatars/default.png" class="rounded-circle mr-2" width="40" height="40">
|
||||||
{{user.display_name}}
|
<div class="media-body">
|
||||||
</p>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<infinite-loading @infinite="infiniteLikesHandler" spinner="spiral">
|
|
||||||
<div slot="no-more"></div>
|
|
||||||
<div slot="no-results"></div>
|
|
||||||
</infinite-loading>
|
|
||||||
</div>
|
</div>
|
||||||
</b-modal>
|
|
||||||
<b-modal ref="sharesModal"
|
<div v-if="layout == 'text'" class="container px-0">
|
||||||
id="s-modal"
|
<div class="row justify-content-center">
|
||||||
hide-footer
|
<div class="col-12 col-md-6">
|
||||||
centered
|
<status-card :status="status" :hasTopBorder="true" />
|
||||||
title="Shares"
|
<comment-feed :status="status" class="mt-3" />
|
||||||
body-class="list-group-flush py-3 px-0">
|
</div>
|
||||||
<div class="list-group">
|
</div>
|
||||||
<div class="list-group-item border-0 py-1" v-for="(user, index) in shares" :key="'modal_shares_'+index">
|
</div>
|
||||||
<div class="media">
|
</div>
|
||||||
<a :href="user.url">
|
|
||||||
<img class="mr-3 rounded-circle box-shadow" :src="user.avatar" :alt="user.username + '’s avatar'" width="30px">
|
<div class="modal-stack">
|
||||||
</a>
|
<b-modal ref="likesModal"
|
||||||
<div class="media-body">
|
id="l-modal"
|
||||||
<div class="d-inline-block">
|
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">
|
<p class="mb-0" style="font-size: 14px">
|
||||||
<a :href="user.url" class="font-weight-bold text-dark">
|
<a :href="user.url" class="font-weight-bold text-dark">
|
||||||
{{user.username}}
|
{{user.username}}
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
<p class="text-muted mb-0" style="font-size: 14px">
|
<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">
|
||||||
{{user.display_name}}
|
<span class="font-weight-bold">{{user.acct.split('@')[0]}}</span><span class="text-lighter">@{{user.acct.split('@')[1]}}</span>
|
||||||
</a>
|
</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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<p class="float-right"><!-- <a class="btn btn-primary font-weight-bold py-1" href="#">Follow</a> --></p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<infinite-loading @infinite="infiniteSharesHandler" spinner="spiral">
|
<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>
|
||||||
<div slot="no-more"></div>
|
</b-modal>
|
||||||
<div slot="no-results"></div>
|
<b-modal ref="ctxModal"
|
||||||
</infinite-loading>
|
id="ctx-modal"
|
||||||
</div>
|
hide-header
|
||||||
</b-modal>
|
hide-footer
|
||||||
<b-modal ref="lightboxModal"
|
centered
|
||||||
id="lightbox"
|
rounded
|
||||||
:hide-header="true"
|
size="sm"
|
||||||
:hide-footer="true"
|
body-class="list-group-flush p-0 rounded">
|
||||||
centered
|
<div class="list-group text-center">
|
||||||
size="lg"
|
<!-- <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>
|
||||||
body-class="p-0"
|
<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 v-if="lightboxMedia" >
|
<div class="list-group-item rounded cursor-pointer" @click="ctxMenuCopyLink()">Copy Link</div>
|
||||||
<img :src="lightboxMedia.url" :class="lightboxMedia.filter_class + ' img-fluid'" style="min-height: 100%; min-width: 100%">
|
<div v-if="status && user.id == status.account.id" class="list-group-item rounded cursor-pointer" @click="toggleCommentVisibility">{{ showComments ? 'Disable' : 'Enable'}} Comments</div>
|
||||||
</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>
|
||||||
</b-modal>
|
<div v-if="user && user.is_admin == true" class="list-group-item rounded cursor-pointer" @click="ctxModMenu()">Moderation Tools</div>
|
||||||
<b-modal ref="embedModal"
|
<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>
|
||||||
id="ctx-embed-modal"
|
<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>
|
||||||
hide-header
|
<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>
|
||||||
hide-footer
|
<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>
|
||||||
centered
|
<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>
|
||||||
rounded
|
<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>
|
||||||
size="md"
|
<div class="list-group-item rounded cursor-pointer text-lighter" @click="closeCtxMenu()">Cancel</div>
|
||||||
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>
|
||||||
<div class="form-group pl-2 d-flex justify-content-center">
|
</b-modal>
|
||||||
<div class="form-check mr-3">
|
<b-modal ref="ctxModModal"
|
||||||
<input class="form-check-input" type="checkbox" v-model="ctxEmbedShowCaption" :disabled="ctxEmbedCompactMode == true">
|
id="ctx-mod-modal"
|
||||||
<label class="form-check-label font-weight-light">
|
hide-header
|
||||||
Show Caption
|
hide-footer
|
||||||
</label>
|
centered
|
||||||
</div>
|
rounded
|
||||||
<div class="form-check mr-3">
|
size="sm"
|
||||||
<input class="form-check-input" type="checkbox" v-model="ctxEmbedShowLikes" :disabled="ctxEmbedCompactMode == true">
|
body-class="list-group-flush p-0 rounded">
|
||||||
<label class="form-check-label font-weight-light">
|
<div class="list-group text-center">
|
||||||
Show Likes
|
<div class="list-group-item rounded cursor-pointer" @click="toggleCommentVisibility">{{ showComments ? 'Disable' : 'Enable'}} Comments</div>
|
||||||
</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>
|
|
||||||
|
|
||||||
<div class="list-group-item rounded cursor-pointer" @click="moderatePost('unlist')">Unlist from Timelines</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-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 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 class="list-group-item rounded cursor-pointer text-lighter" @click="ctxModMenuClose()">Cancel</div>
|
||||||
</div>
|
</div>
|
||||||
</b-modal>
|
</b-modal>
|
||||||
<b-modal ref="replyModal"
|
<b-modal ref="replyModal"
|
||||||
id="ctx-reply-modal"
|
id="ctx-reply-modal"
|
||||||
hide-footer
|
hide-footer
|
||||||
centered
|
centered
|
||||||
rounded
|
rounded
|
||||||
:title-html="replyingToUsername ? 'Reply to <span class=text-dark>' + replyingToUsername + '</span>' : ''"
|
:title-html="replyingToUsername ? 'Reply to <span class=text-dark>' + replyingToUsername + '</span>' : ''"
|
||||||
title-tag="p"
|
title-tag="p"
|
||||||
title-class="font-weight-bold text-muted"
|
title-class="font-weight-bold text-muted"
|
||||||
size="md"
|
size="md"
|
||||||
body-class="p-2 rounded">
|
body-class="p-2 rounded">
|
||||||
<div>
|
<div>
|
||||||
<vue-tribute :options="tributeSettings">
|
<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 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>
|
</textarea>
|
||||||
</vue-tribute>
|
</vue-tribute>
|
||||||
|
|
||||||
<div class="border-top border-bottom my-2">
|
<div class="border-top border-bottom my-2">
|
||||||
<ul class="nav align-items-center emoji-reactions" style="overflow-x: scroll;flex-wrap: unset;">
|
<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>
|
<li class="nav-item" v-on:click="emojiReaction(status)" v-for="e in emoji">{{e}}</li>
|
||||||
</ul>
|
</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>
|
</div>
|
||||||
<div class="d-flex align-items-center">
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
<div class="custom-control custom-switch mr-3">
|
<div>
|
||||||
<input type="checkbox" class="custom-control-input" id="replyModalCWSwitch" v-model="replySensitive">
|
<span class="pl-2 small text-muted font-weight-bold text-monospace">
|
||||||
<label :class="[replySensitive ? 'custom-control-label font-weight-bold text-dark':'custom-control-label text-lighter']" for="replyModalCWSwitch">Mark as NSFW</label>
|
<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>
|
</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>
|
</div>
|
||||||
</div>
|
</b-modal>
|
||||||
</b-modal>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
@ -766,7 +848,10 @@
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import VueTribute from 'vue-tribute'
|
import VueTribute from 'vue-tribute';
|
||||||
|
import PollCard from './partials/PollCard.vue';
|
||||||
|
import CommentFeed from './partials/CommentFeed.vue';
|
||||||
|
import StatusCard from './partials/StatusCard.vue';
|
||||||
|
|
||||||
pixelfed.postComponent = {};
|
pixelfed.postComponent = {};
|
||||||
|
|
||||||
|
@ -785,7 +870,10 @@ export default {
|
||||||
],
|
],
|
||||||
|
|
||||||
components: {
|
components: {
|
||||||
VueTribute
|
VueTribute,
|
||||||
|
PollCard,
|
||||||
|
CommentFeed,
|
||||||
|
StatusCard
|
||||||
},
|
},
|
||||||
|
|
||||||
data() {
|
data() {
|
||||||
|
@ -944,6 +1032,12 @@ export default {
|
||||||
let self = this;
|
let self = this;
|
||||||
axios.get('/api/v2/profile/'+this.statusUsername+'/status/'+this.statusId)
|
axios.get('/api/v2/profile/'+this.statusUsername+'/status/'+this.statusId)
|
||||||
.then(response => {
|
.then(response => {
|
||||||
|
if(response.data.status.pf_type == 'poll') {
|
||||||
|
self.layout = 'poll';
|
||||||
|
}
|
||||||
|
if(response.data.status.pf_type == 'text') {
|
||||||
|
self.layout = 'text';
|
||||||
|
}
|
||||||
self.status = response.data.status;
|
self.status = response.data.status;
|
||||||
self.media = self.status.media_attachments;
|
self.media = self.status.media_attachments;
|
||||||
self.likesPage = 2;
|
self.likesPage = 2;
|
||||||
|
@ -1780,8 +1874,17 @@ export default {
|
||||||
.then(res => {
|
.then(res => {
|
||||||
this.$refs.ctxModal.hide();
|
this.$refs.ctxModal.hide();
|
||||||
});
|
});
|
||||||
}
|
},
|
||||||
|
|
||||||
|
statusLike(s) {
|
||||||
|
this.reactions.liked = !!this.reactions.liked;
|
||||||
|
},
|
||||||
|
|
||||||
|
trimCaption(caption, len = 60) {
|
||||||
|
return _.truncate(caption, {
|
||||||
|
length: len
|
||||||
|
});
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -19,6 +19,14 @@
|
||||||
:recommended="false"
|
:recommended="false"
|
||||||
v-on:comment-focus="commentFocus" />
|
v-on:comment-focus="commentFocus" />
|
||||||
|
|
||||||
|
<comment-feed :status="status" class="mt-3" />
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="status.pf_type === 'poll'" class="col-12 col-md-6 offset-md-3">
|
||||||
|
<poll-card :status="status" :profile="profile" :fetch-state="true"/>
|
||||||
|
|
||||||
|
<comment-feed :status="status" class="mt-3" />
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -545,6 +553,8 @@ pixelfed.postComponent = {};
|
||||||
|
|
||||||
import StatusCard from './partials/StatusCard.vue';
|
import StatusCard from './partials/StatusCard.vue';
|
||||||
import CommentCard from './partials/CommentCard.vue';
|
import CommentCard from './partials/CommentCard.vue';
|
||||||
|
import PollCard from './partials/PollCard.vue';
|
||||||
|
import CommentFeed from './partials/CommentFeed.vue';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
props: [
|
props: [
|
||||||
|
@ -560,7 +570,9 @@ export default {
|
||||||
|
|
||||||
components: {
|
components: {
|
||||||
StatusCard,
|
StatusCard,
|
||||||
CommentCard
|
CommentCard,
|
||||||
|
CommentFeed,
|
||||||
|
PollCard
|
||||||
},
|
},
|
||||||
|
|
||||||
data() {
|
data() {
|
||||||
|
|
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>
|
<template>
|
||||||
<div>
|
<div class="status-card-component"
|
||||||
<div v-if="status.pf_type === 'text'" class="card shadow-none border border-top-0 rounded-0">
|
:class="{ 'status-card-sm': size === 'small' }">
|
||||||
|
<div v-if="status.pf_type === 'text'" :class="{ 'border-top-0': !hasTopBorder }" class="card shadow-none border rounded-0">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="media">
|
<div class="media">
|
||||||
<img class="rounded-circle box-shadow mr-2" :src="status.account.avatar" width="32px" height="32px" onerror="this.onerror=null;this.src='/storage/avatars/default.png?v=2'" alt="avatar">
|
<img class="rounded-circle box-shadow mr-2" :src="status.account.avatar" width="32px" height="32px" onerror="this.onerror=null;this.src='/storage/avatars/default.png?v=2'" alt="avatar">
|
||||||
|
@ -18,7 +19,7 @@
|
||||||
</a>
|
</a>
|
||||||
<span class="text-right" style="flex-grow:1;">
|
<span class="text-right" style="flex-grow:1;">
|
||||||
<button class="btn btn-link text-dark py-0" type="button" @click="ctxMenu()">
|
<button class="btn btn-link text-dark py-0" type="button" @click="ctxMenu()">
|
||||||
<span class="fas fa-ellipsis-v text-lighter"></span>
|
<span class="fas fa-ellipsis-h text-lighter"></span>
|
||||||
<span class="sr-only">Post Menu</span>
|
<span class="sr-only">Post Menu</span>
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
|
@ -27,10 +28,10 @@
|
||||||
|
|
||||||
<details v-if="status.sensitive">
|
<details v-if="status.sensitive">
|
||||||
<summary class="mb-2 font-weight-bold text-muted">Content Warning</summary>
|
<summary class="mb-2 font-weight-bold text-muted">Content Warning</summary>
|
||||||
<p v-html="status.content" class="pt-2 text-break" style="font-size: 17px;"></p>
|
<p v-html="status.content" class="pt-2 text-break status-content"></p>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<p v-else v-html="status.content" class="pt-2 text-break" style="font-size: 17px;"></p>
|
<p v-else v-html="status.content" class="pt-2 text-break status-content"></p>
|
||||||
|
|
||||||
<p class="mb-0">
|
<p class="mb-0">
|
||||||
<i class="fa-heart fa-lg cursor-pointer mr-3"
|
<i class="fa-heart fa-lg cursor-pointer mr-3"
|
||||||
|
@ -49,6 +50,10 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="status.pf_type === 'poll'">
|
||||||
|
<poll-card :status="status" :profile="profile" v-on:status-delete="statusDeleted" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-else class="card rounded-0 border-top-0 status-card card-md-rounded-0 shadow-none border">
|
<div v-else class="card rounded-0 border-top-0 status-card card-md-rounded-0 shadow-none border">
|
||||||
<div v-if="status" class="card-header d-inline-flex align-items-center bg-white">
|
<div v-if="status" class="card-header d-inline-flex align-items-center bg-white">
|
||||||
<div>
|
<div>
|
||||||
|
@ -179,6 +184,7 @@
|
||||||
|
|
||||||
<script type="text/javascript">
|
<script type="text/javascript">
|
||||||
import ContextMenu from './ContextMenu.vue';
|
import ContextMenu from './ContextMenu.vue';
|
||||||
|
import PollCard from './PollCard.vue';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
props: {
|
props: {
|
||||||
|
@ -194,11 +200,23 @@
|
||||||
reactionBar: {
|
reactionBar: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: true
|
default: true
|
||||||
|
},
|
||||||
|
|
||||||
|
hasTopBorder: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
|
||||||
|
size: {
|
||||||
|
type: String,
|
||||||
|
validator: (val) => ['regular', 'small'].includes(val),
|
||||||
|
default: 'regular'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
components: {
|
components: {
|
||||||
"context-menu": ContextMenu
|
"context-menu": ContextMenu,
|
||||||
|
"poll-card": PollCard
|
||||||
},
|
},
|
||||||
|
|
||||||
data() {
|
data() {
|
||||||
|
@ -302,8 +320,9 @@
|
||||||
item: status.id
|
item: status.id
|
||||||
}).then(res => {
|
}).then(res => {
|
||||||
status.favourites_count = res.data.count;
|
status.favourites_count = res.data.count;
|
||||||
|
status.favourited = !!status.favourited;
|
||||||
}).catch(err => {
|
}).catch(err => {
|
||||||
status.favourited = !status.favourited;
|
status.favourited = !!status.favourited;
|
||||||
status.favourites_count = count;
|
status.favourites_count = count;
|
||||||
swal('Error', 'Something went wrong, please try again later.', 'error');
|
swal('Error', 'Something went wrong, please try again later.', 'error');
|
||||||
});
|
});
|
||||||
|
@ -367,3 +386,23 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.status-card-component {
|
||||||
|
.status-content {
|
||||||
|
font-size: 17px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.status-card-sm {
|
||||||
|
.status-content {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fa-lg {
|
||||||
|
font-size: unset;
|
||||||
|
line-height: unset;
|
||||||
|
vertical-align: unset;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
|
@ -105,6 +105,8 @@ Route::domain(config('pixelfed.domain.app'))->middleware(['validemail', 'twofact
|
||||||
Route::get('search', 'SearchController@searchAPI');
|
Route::get('search', 'SearchController@searchAPI');
|
||||||
Route::get('nodeinfo/2.0.json', 'FederationController@nodeinfo');
|
Route::get('nodeinfo/2.0.json', 'FederationController@nodeinfo');
|
||||||
Route::post('status/view', 'StatusController@storeView');
|
Route::post('status/view', 'StatusController@storeView');
|
||||||
|
Route::get('v1/polls/{id}', 'PollController@getPoll');
|
||||||
|
Route::post('v1/polls/{id}/votes', 'PollController@vote');
|
||||||
|
|
||||||
Route::group(['prefix' => 'compose'], function() {
|
Route::group(['prefix' => 'compose'], function() {
|
||||||
Route::group(['prefix' => 'v0'], function() {
|
Route::group(['prefix' => 'v0'], function() {
|
||||||
|
@ -120,6 +122,7 @@ Route::domain(config('pixelfed.domain.app'))->middleware(['validemail', 'twofact
|
||||||
Route::post('/publish/text', 'ComposeController@storeText');
|
Route::post('/publish/text', 'ComposeController@storeText');
|
||||||
Route::get('/media/processing', 'ComposeController@mediaProcessingCheck');
|
Route::get('/media/processing', 'ComposeController@mediaProcessingCheck');
|
||||||
Route::get('/settings', 'ComposeController@composeSettings');
|
Route::get('/settings', 'ComposeController@composeSettings');
|
||||||
|
Route::post('/poll', 'ComposeController@createPoll');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue