Merge pull request #1317 from pixelfed/frontend-ui-refactor

Hello Loops
This commit is contained in:
daniel 2019-06-03 13:31:49 -06:00 committed by GitHub
commit cbb98b0462
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
60 changed files with 1117 additions and 295 deletions

View file

@ -0,0 +1,52 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use App\Media;
use App\Jobs\VideoPipeline\VideoThumbnail as Pipeline;
class VideoThumbnail extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'video:thumbnail';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Generate missing video thumbnails';
/**
* Create a new command instance.
*
* @return void
*/
public function __construct()
{
parent::__construct();
}
/**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
$limit = 10;
$videos = Media::whereMime('video/mp4')
->whereNull('thumbnail_path')
->take($limit)
->get();
foreach($videos as $video) {
Pipeline::dispatchNow($video);
}
}
}

View file

@ -9,6 +9,9 @@ class Follower extends Model
protected $fillable = ['profile_id', 'following_id', 'local_profile'];
const MAX_FOLLOWING = 7500;
const FOLLOW_PER_HOUR = 20;
public function actor()
{
return $this->belongsTo(Profile::class, 'profile_id', 'id');

View file

@ -159,6 +159,11 @@ class AccountController extends Controller
return view('account.messages');
}
public function direct()
{
return view('account.direct');
}
public function showMessage(Request $request, $id)
{
return view('account.message');

View file

@ -48,7 +48,8 @@ class BaseApiController extends Controller
public function notifications(Request $request)
{
$pid = Auth::user()->profile->id;
if(config('exp.ns') == false) {
$pg = $request->input('pg');
if($pg == true) {
$timeago = Carbon::now()->subMonths(6);
$notifications = Notification::whereProfileId($pid)
->whereDate('created_at', '>', $timeago)
@ -272,6 +273,7 @@ class BaseApiController extends Controller
'temp-media', now()->addHours(1), ['profileId' => $profile->id, 'mediaId' => $media->id]
);
$preview_url = $url;
switch ($media->mime) {
case 'image/jpeg':
case 'image/png':
@ -280,6 +282,8 @@ class BaseApiController extends Controller
case 'video/mp4':
VideoThumbnail::dispatch($media);
$preview_url = '/storage/no-preview.png';
$url = '/storage/no-preview.png';
break;
default:
@ -288,7 +292,7 @@ class BaseApiController extends Controller
$resource = new Fractal\Resource\Item($media, new MediaTransformer());
$res = $this->fractal->createData($resource)->toArray();
$res['preview_url'] = $url;
$res['preview_url'] = $preview_url;
$res['url'] = $url;
return response()->json($res);
}
@ -326,5 +330,4 @@ class BaseApiController extends Controller
return response()->json($res);
}
}

View file

@ -46,6 +46,7 @@ class ApiController extends BaseApiController
'ab' => [
'lc' => config('exp.lc'),
'rec' => config('exp.rec'),
'loops' => config('exp.loops')
],
];
});

View file

@ -13,21 +13,31 @@ use App\{
};
use Auth, DB, Cache;
use Illuminate\Http\Request;
use App\Transformer\Api\StatusStatelessTransformer;
use League\Fractal;
use League\Fractal\Serializer\ArraySerializer;
use League\Fractal\Pagination\IlluminatePaginatorAdapter;
class DiscoverController extends Controller
{
protected $fractal;
public function __construct()
{
$this->middleware('auth');
$this->fractal = new Fractal\Manager();
$this->fractal->setSerializer(new ArraySerializer());
}
public function home(Request $request)
{
abort_if(!Auth::check(), 403);
return view('discover.home');
}
public function showTags(Request $request, $hashtag)
{
abort_if(!Auth::check(), 403);
$tag = Hashtag::whereSlug($hashtag)
->firstOrFail();
@ -81,6 +91,8 @@ class DiscoverController extends Controller
public function showCategory(Request $request, $slug)
{
abort_if(!Auth::check(), 403);
$tag = DiscoverCategory::whereActive(true)
->whereSlug($slug)
->firstOrFail();
@ -99,6 +111,8 @@ class DiscoverController extends Controller
public function showPersonal(Request $request)
{
abort_if(!Auth::check(), 403);
$profile = Auth::user()->profile;
$tags = Cache::remember('profile-'.$profile->id.':hashtags', now()->addMinutes(15), function() use ($profile){
@ -115,4 +129,43 @@ class DiscoverController extends Controller
});
return view('discover.personal', compact('posts', 'tags'));
}
public function showLoops(Request $request)
{
if(config('exp.loops') != true) {
return redirect('/');
}
return view('discover.loops.home');
}
public function loopsApi(Request $request)
{
abort_if(!config('exp.loops'), 403);
// todo proper pagination, maybe LoopService
$loops = Status::whereType('video')
->whereScope('public')
->latest()
->take(18)
->get();
$resource = new Fractal\Resource\Collection($loops, new StatusStatelessTransformer());
return $this->fractal->createData($resource)->toArray();
}
public function loopWatch(Request $request)
{
abort_if(!Auth::check(), 403);
abort_if(!config('exp.loops'), 403);
$this->validate($request, [
'id' => 'integer|min:1'
]);
$id = $request->input('id');
// todo log loops
return response()->json(200);
}
}

View file

@ -37,6 +37,8 @@ class FollowerController extends Controller
protected function handleFollowRequest($item)
{
$user = Auth::user()->profile;
$target = Profile::where('id', '!=', $user->id)->whereNull('status')->findOrFail($item);
$private = (bool) $target->is_private;
$remote = (bool) $target->domain;
@ -47,7 +49,7 @@ class FollowerController extends Controller
->exists();
if($blocked == true) {
return redirect()->back()->with('error', 'You cannot follow this user.');
abort(400, 'You cannot follow this user.');
}
$isFollowing = Follower::whereProfileId($user->id)->whereFollowingId($target->id)->count();
@ -61,6 +63,13 @@ class FollowerController extends Controller
}
} elseif ($isFollowing == 0) {
if($user->following()->count() >= Follower::MAX_FOLLOWING) {
abort(400, 'You cannot follow more than ' . Follower::MAX_FOLLOWING . ' accounts');
}
if($user->following()->where('followers.created_at', '>', now()->subHour())->count() >= Follower::FOLLOW_PER_HOUR) {
abort(400, 'You can only follow ' . Follower::FOLLOW_PER_HOUR . ' users per hour');
}
$follower = new Follower();
$follower->profile_id = $user->id;
$follower->following_id = $target->id;

View file

@ -106,7 +106,12 @@ class InternalApiController extends Controller
});
$following = array_merge($following, $filters);
$posts = Status::select('id', 'caption', 'profile_id')
$posts = Status::select(
'id',
'caption',
'profile_id',
'type'
)
->whereNull('uri')
->whereHas('media')
->whereHas('profile', function($q) {
@ -123,6 +128,7 @@ class InternalApiController extends Controller
$res = [
'posts' => $posts->map(function($post) {
return [
'type' => $post->type,
'url' => $post->url(),
'thumb' => $post->thumb(),
];

View file

@ -74,17 +74,6 @@ class DeleteAccountPipeline implements ShouldQueue
if($user->profile) {
$avatar = $user->profile->avatar;
if(is_file($avatar->media_path)) {
if($avatar->media_path != 'public/avatars/default.png') {
unlink($avatar->media_path);
}
}
if(is_file($avatar->thumb_path)) {
if($avatar->thumb_path != 'public/avatars/default.png') {
unlink($avatar->thumb_path);
}
}
$avatar->forceDelete();
}

View file

@ -34,6 +34,9 @@ class VideoThumbnail implements ShouldQueue
public function handle()
{
$media = $this->media;
if($media->mime != 'video/mp4') {
return;
}
$base = $media->media_path;
$path = explode('/', $base);
$name = last($path);
@ -43,14 +46,11 @@ class VideoThumbnail implements ShouldQueue
$i = count($path) - 1;
$path[$i] = $t;
$save = implode('/', $path);
$video = FFMpeg::open($base);
if($video->getDurationInSeconds() < 1) {
$video->getFrameFromSeconds(0);
} elseif($video->getDurationInSeconds() < 5) {
$video->getFrameFromSeconds(4);
}
$video->export()
->save($save);
$video = FFMpeg::open($base)
->getFrameFromSeconds(0)
->export()
->toDisk('local')
->save($save);
$media->thumbnail_path = $save;
$media->save();

View file

@ -48,7 +48,13 @@ class AvatarObserver
public function deleting(Avatar $avatar)
{
$path = storage_path('app/'.$avatar->media_path);
@unlink($path);
if(is_file($path) && $avatar->media_path != 'public/avatars/default.png') {
@unlink($path);
}
$path = storage_path('app/'.$avatar->thumb_path);
if(is_file($path) && $avatar->thumb_path != 'public/avatars/default.png') {
@unlink($path);
}
}
/**

View file

@ -73,7 +73,7 @@ class NotificationService {
public static function getNotification($id)
{
return Cache::remember('service:notification:'.$id, now()->addDays(7), function() use($id) {
$n = Notification::findOrFail($id);
$n = Notification::with('item')->findOrFail($id);
$fractal = new Fractal\Manager();
$fractal->setSerializer(new ArraySerializer());
$resource = new Fractal\Resource\Item($n, new NotificationTransformer());

View file

@ -40,9 +40,16 @@ class Status extends Model
'story',
'story:reply',
'story:reaction',
'story:live'
'story:live',
'loop'
];
const MAX_MENTIONS = 5;
const MAX_HASHTAGS = 30;
const MAX_LINKS = 2;
public function profile()
{
return $this->belongsTo(Profile::class);
@ -87,7 +94,7 @@ class Status extends Model
return Cache::remember('status:thumb:'.$this->id, now()->addMinutes(15), function() use ($showNsfw) {
$type = $this->type ?? $this->setType();
$is_nsfw = !$showNsfw ? $this->is_nsfw : false;
if ($this->media->count() == 0 || $is_nsfw || !in_array($type,['photo', 'photo:album'])) {
if ($this->media->count() == 0 || $is_nsfw || !in_array($type,['photo', 'photo:album', 'video'])) {
return url(Storage::url('public/no-preview.png'));
}
@ -99,11 +106,12 @@ class Status extends Model
{
if($this->uri) {
return $this->uri;
} else {
$id = $this->id;
$username = $this->profile->username;
$path = url(config('app.url')."/p/{$username}/{$id}");
return $path;
}
$id = $this->id;
$username = $this->profile->username;
$path = url(config('app.url')."/p/{$username}/{$id}");
return $path;
}
public function permalink($suffix = '/activity')
@ -207,6 +215,8 @@ class Status extends Model
$parent = $this->in_reply_to_id ?? $this->reblog_of_id;
if (!empty($parent)) {
return $this->findOrFail($parent);
} else {
return false;
}
}

View file

@ -2,7 +2,10 @@
namespace App\Transformer\Api;
use App\Notification;
use App\{
Notification,
Status
};
use League\Fractal;
class NotificationTransformer extends Fractal\TransformerAbstract
@ -10,6 +13,7 @@ class NotificationTransformer extends Fractal\TransformerAbstract
protected $defaultIncludes = [
'account',
'status',
'relationship'
];
public function transform(Notification $notification)
@ -30,9 +34,14 @@ class NotificationTransformer extends Fractal\TransformerAbstract
public function includeStatus(Notification $notification)
{
$item = $notification->item;
if(is_object($item) && get_class($item) === 'App\Status') {
return $this->item($item, new StatusTransformer());
$item = $notification;
if($item->item_id && $item->item_type == 'App\Status') {
$status = Status::with('media')->find($item->item_id);
if($status) {
return $this->item($status, new StatusTransformer());
} else {
return null;
}
} else {
return null;
}
@ -50,4 +59,9 @@ class NotificationTransformer extends Fractal\TransformerAbstract
];
return $verbs[$verb];
}
public function includeRelationship(Notification $notification)
{
return $this->item($notification->actor, new RelationshipTransformer());
}
}

View file

@ -0,0 +1,84 @@
<?php
namespace App\Transformer\Api;
use App\Status;
use League\Fractal;
use Cache;
class StatusStatelessTransformer extends Fractal\TransformerAbstract
{
protected $defaultIncludes = [
'account',
'mentions',
'media_attachments',
'tags',
];
public function transform(Status $status)
{
return [
'id' => (string) $status->id,
'uri' => $status->url(),
'url' => $status->url(),
'in_reply_to_id' => $status->in_reply_to_id,
'in_reply_to_account_id' => $status->in_reply_to_profile_id,
'reblog' => null,
'content' => $status->rendered ?? $status->caption,
'created_at' => $status->created_at->format('c'),
'emojis' => [],
'reblogs_count' => $status->shares()->count(),
'favourites_count' => $status->likes()->count(),
'reblogged' => null,
'favourited' => null,
'muted' => null,
'sensitive' => (bool) $status->is_nsfw,
'spoiler_text' => $status->cw_summary,
'visibility' => $status->visibility,
'application' => [
'name' => 'web',
'website' => null
],
'language' => null,
'pinned' => null,
'pf_type' => $status->type ?? $status->setType(),
'reply_count' => (int) $status->reply_count,
'comments_disabled' => $status->comments_disabled ? true : false,
'thread' => false,
'replies' => [],
'parent' => $status->parent() ? $this->transform($status->parent()) : [],
];
}
public function includeAccount(Status $status)
{
$account = $status->profile;
return $this->item($account, new AccountTransformer());
}
public function includeMentions(Status $status)
{
$mentions = $status->mentions;
return $this->collection($mentions, new MentionTransformer());
}
public function includeMediaAttachments(Status $status)
{
return Cache::remember('status:transformer:media:attachments:'.$status->id, now()->addMinutes(3), function() use($status) {
if(in_array($status->type, ['photo', 'video'])) {
$media = $status->media()->orderBy('order')->get();
return $this->collection($media, new MediaTransformer());
}
});
}
public function includeTags(Status $status)
{
$tags = $status->hashtags;
return $this->collection($tags, new HashtagTransformer());
}
}

View file

@ -46,7 +46,8 @@ class StatusTransformer extends Fractal\TransformerAbstract
'reply_count' => (int) $status->reply_count,
'comments_disabled' => $status->comments_disabled ? true : false,
'thread' => false,
'replies' => []
'replies' => [],
'parent' => $status->parent() ? $this->transform($status->parent()) : [],
];
}
@ -67,8 +68,10 @@ class StatusTransformer extends Fractal\TransformerAbstract
public function includeMediaAttachments(Status $status)
{
return Cache::remember('status:transformer:media:attachments:'.$status->id, now()->addMinutes(3), function() use($status) {
$media = $status->media()->orderBy('order')->get();
return $this->collection($media, new MediaTransformer());
if(in_array($status->type, ['photo', 'video'])) {
$media = $status->media()->orderBy('order')->get();
return $this->collection($media, new MediaTransformer());
}
});
}

View file

@ -418,7 +418,7 @@ class Autolink extends Regex
if(Str::startsWith($entity['screen_name'], '@')) {
$text .= StringUtils::substr($tweet, $beginIndex, $entity['indices'][0] - $beginIndex);
} else {
$text .= StringUtils::substr($tweet, $beginIndex, $entity['indices'][0] - $beginIndex + 1);
$text .= StringUtils::substr($tweet, $beginIndex, $entity['indices'][0] - $beginIndex);
}
} else {
$text .= StringUtils::substr($tweet, $beginIndex, $entity['indices'][0] - $beginIndex);
@ -708,16 +708,17 @@ class Autolink extends Regex
{
$attributes = [];
$screen_name = $entity['screen_name'];
if (!empty($entity['list_slug'])) {
// Replace the list and username
$linkText = $entity['screen_name'];
$linkText = Str::startsWith($screen_name, '@') ? $screen_name : '@'.$screen_name;
$class = $this->class_list;
$url = $this->url_base_list.$linkText;
$url = $this->url_base_list.$screen_name;
} else {
// Replace the username
$linkText = $entity['screen_name'];
$linkText = Str::startsWith($screen_name, '@') ? $screen_name : '@'.$screen_name;
$class = $this->class_user;
$url = $this->url_base_user.$linkText;
$url = $this->url_base_user.$screen_name;;
}
if (!empty($class)) {
$attributes['class'] = $class;

View file

@ -10,6 +10,7 @@
namespace App\Util\Lexer;
use Illuminate\Support\Str;
use App\Status;
/**
* Twitter Extractor Class.
@ -121,7 +122,7 @@ class Extractor extends Regex
$hashtagsOnly[] = $hashtagWithIndex['hashtag'];
}
return $hashtagsOnly;
return array_slice($hashtagsOnly, 0, Status::MAX_HASHTAGS);
}
/**
@ -134,12 +135,6 @@ class Extractor extends Regex
public function extractCashtags($tweet = null)
{
$cashtagsOnly = [];
$cashtagsWithIndices = $this->extractCashtagsWithIndices($tweet);
foreach ($cashtagsWithIndices as $cashtagWithIndex) {
$cashtagsOnly[] = $cashtagWithIndex['cashtag'];
}
return $cashtagsOnly;
}
@ -159,7 +154,7 @@ class Extractor extends Regex
$urlsOnly[] = $urlWithIndex['url'];
}
return $urlsOnly;
return array_slice($urlsOnly, 0, Status::MAX_LINKS);
}
/**
@ -277,7 +272,7 @@ class Extractor extends Regex
}
if (!$checkUrlOverlap) {
return $tags;
return array_slice($tags, 0, Status::MAX_HASHTAGS);
}
// check url overlap
@ -292,7 +287,7 @@ class Extractor extends Regex
$validTags[] = $entity;
}
return $validTags;
return array_slice($validTags, 0, Status::MAX_HASHTAGS);
}
/**
@ -390,7 +385,7 @@ class Extractor extends Regex
}
}
return $urls;
return array_slice($urls, 0, Status::MAX_LINKS);
}
/**
@ -415,7 +410,7 @@ class Extractor extends Regex
$usernamesOnly[] = $mention;
}
return $usernamesOnly;
return array_slice($usernamesOnly, 0, Status::MAX_MENTIONS);
}
/**
@ -472,7 +467,7 @@ class Extractor extends Regex
$results[] = $entity;
}
return $results;
return array_slice($results, 0, Status::MAX_MENTIONS);
}
/**

View file

@ -38,6 +38,13 @@ return [
'options' => [
'cluster' => env('PUSHER_APP_CLUSTER'),
'encrypted' => true,
'host' => env('APP_DOMAIN'),
'port' => 6001,
'scheme' => 'https',
'curl_options' => [
CURLOPT_SSL_VERIFYHOST => 0,
CURLOPT_SSL_VERIFYPEER => 0,
]
],
],

View file

@ -4,6 +4,7 @@ return [
'lc' => env('EXP_LC', false),
'rec' => env('EXP_REC', false),
'ns' => env('EXP_NS', false)
'ns' => env('EXP_NS', false),
'loops' => env('EXP_LOOPS', false)
];

View file

@ -78,13 +78,10 @@ return [
|--------------------------------------------------------------------------
|
*/
'ap_inbox' => env('ACTIVITYPUB_INBOX', false),
'ap_shared' => env('ACTIVITYPUB_SHAREDINBOX', false),
'activitypub_enabled' => env('ACTIVITY_PUB', false),
'ap_delivery_timeout' => env('ACTIVITYPUB_DELIVERY_TIMEOUT', 2.0),
'ap_delivery_concurrency' => env('ACTIVITYPUB_DELIVERY_CONCURRENCY', 10),
'remote_follow_enabled' => env('REMOTE_FOLLOW', false),
'activitypub_enabled' => env('ACTIVITY_PUB', false),
'remote_follow_enabled' => false,
/*
|--------------------------------------------------------------------------

123
config/websockets.php Normal file
View file

@ -0,0 +1,123 @@
<?php
use BeyondCode\LaravelWebSockets\Dashboard\Http\Middleware\Authorize;
return [
/*
* This package comes with multi tenancy out of the box. Here you can
* configure the different apps that can use the webSockets server.
*
* Optionally you can disable client events so clients cannot send
* messages to each other via the webSockets.
*/
'apps' => [
[
'id' => env('PUSHER_APP_ID'),
'name' => env('APP_NAME'),
'key' => env('PUSHER_APP_KEY'),
'secret' => env('PUSHER_APP_SECRET'),
'enable_client_messages' => env('WSS_CM', false),
'enable_statistics' => env('WSS_STATS', false),
],
],
/*
* This class is responsible for finding the apps. The default provider
* will use the apps defined in this config file.
*
* You can create a custom provider by implementing the
* `AppProvider` interface.
*/
'app_provider' => BeyondCode\LaravelWebSockets\Apps\ConfigAppProvider::class,
/*
* This array contains the hosts of which you want to allow incoming requests.
* Leave this empty if you want to accept requests from all hosts.
*/
'allowed_origins' => [
//
],
/*
* The maximum request size in kilobytes that is allowed for an incoming WebSocket request.
*/
'max_request_size_in_kb' => 250,
/*
* This path will be used to register the necessary routes for the package.
*/
'path' => 'laravel-websockets',
/*
* Dashboard Routes Middleware
*
* These middleware will be assigned to every dashboard route, giving you
* the chance to add your own middleware to this list or change any of
* the existing middleware. Or, you can simply stick with this list.
*/
'middleware' => [
'web',
Authorize::class,
],
'statistics' => [
/*
* This model will be used to store the statistics of the WebSocketsServer.
* The only requirement is that the model should extend
* `WebSocketsStatisticsEntry` provided by this package.
*/
'model' => \BeyondCode\LaravelWebSockets\Statistics\Models\WebSocketsStatisticsEntry::class,
/*
* Here you can specify the interval in seconds at which statistics should be logged.
*/
'interval_in_seconds' => 60,
/*
* When the clean-command is executed, all recorded statistics older than
* the number of days specified here will be deleted.
*/
'delete_statistics_older_than_days' => 60,
/*
* Use an DNS resolver to make the requests to the statistics logger
* default is to resolve everything to 127.0.0.1.
*/
'perform_dns_lookup' => false,
],
/*
* Define the optional SSL context for your WebSocket connections.
* You can see all available options at: http://php.net/manual/en/context.ssl.php
*/
'ssl' => [
/*
* Path to local certificate file on filesystem. It must be a PEM encoded file which
* contains your certificate and private key. It can optionally contain the
* certificate chain of issuers. The private key also may be contained
* in a separate file specified by local_pk.
*/
'local_cert' => null,
/*
* Path to local private key file on filesystem in case of separate files for
* certificate (local_cert) and private key.
*/
'local_pk' => null,
/*
* Passphrase for your local_cert file.
*/
'passphrase' => null,
],
/*
* Channel Manager
* This class handles how channel persistence is handled.
* By default, persistence is stored in an array by the running webserver.
* The only requirement is that the class should implement
* `ChannelManager` interface provided by this package.
*/
'channel_manager' => \BeyondCode\LaravelWebSockets\WebSockets\Channels\ChannelManagers\ArrayChannelManager::class,
];

BIN
public/js/activity.js vendored

Binary file not shown.

Binary file not shown.

BIN
public/js/compose.js vendored

Binary file not shown.

BIN
public/js/direct.js vendored Normal file

Binary file not shown.

BIN
public/js/discover.js vendored

Binary file not shown.

BIN
public/js/loops.js vendored Normal file

Binary file not shown.

BIN
public/js/profile.js vendored

Binary file not shown.

BIN
public/js/search.js vendored

Binary file not shown.

BIN
public/js/status.js vendored

Binary file not shown.

BIN
public/js/timeline.js vendored

Binary file not shown.

Binary file not shown.

View file

@ -1,10 +1,4 @@
$(document).ready(function() {
$('.pagination').hide();
let elem = document.querySelector('.notification-page .list-group');
let infScroll = new InfiniteScroll( elem, {
path: '.pagination__next',
append: '.notification-page .list-group',
status: '.page-load-status',
history: true,
});
});
Vue.component(
'activity-component',
require('./components/Activity.vue').default
);

View file

@ -0,0 +1,238 @@
<template>
<div>
<!-- <div class="bg-white py-4">
<div class="container">
<div class="d-flex justify-content-between align-items-center">
<div></div>
<a href="/account/activity" class="cursor-pointer font-weight-bold text-primary">Notifications</a>
<a href="/account/direct" class="cursor-pointer font-weight-bold text-dark">Direct Messages</a>
<a href="/account/following" class="cursor-pointer font-weight-bold text-dark">Following</a>
<div></div>
</div>
</div>
</div> -->
<div class="container">
<div class="row my-5">
<div class="col-12 col-md-8 offset-md-2">
<div v-if="notifications.length > 0" class="media mb-3 align-items-center px-3 border-bottom pb-3" v-for="(n, index) in notifications">
<img class="mr-2 rounded-circle" style="border:1px solid #ccc" :src="n.account.avatar" alt="" width="32px" height="32px">
<div class="media-body font-weight-light">
<div v-if="n.type == 'favourite'">
<p class="my-0">
<a :href="n.account.url" class="font-weight-bold text-dark word-break" data-placement="bottom" data-toggle="tooltip" :title="n.account.username">{{truncate(n.account.username)}}</a> liked your <a class="font-weight-bold" v-bind:href="n.status.url">post</a>.
</p>
</div>
<div v-else-if="n.type == 'comment'">
<p class="my-0">
<a :href="n.account.url" class="font-weight-bold text-dark word-break" data-placement="bottom" data-toggle="tooltip" :title="n.account.username">{{truncate(n.account.username)}}</a> commented on your <a class="font-weight-bold" v-bind:href="n.status.url">post</a>.
</p>
</div>
<div v-else-if="n.type == 'mention'">
<p class="my-0">
<a :href="n.account.url" class="font-weight-bold text-dark word-break" data-placement="bottom" data-toggle="tooltip" :title="n.account.username">{{truncate(n.account.username)}}</a> <a class="font-weight-bold" v-bind:href="mentionUrl(n.status)">mentioned</a> you.
</p>
</div>
<div v-else-if="n.type == 'follow'">
<p class="my-0">
<a :href="n.account.url" class="font-weight-bold text-dark word-break" data-placement="bottom" data-toggle="tooltip" :title="n.account.username">{{truncate(n.account.username)}}</a> followed you.
</p>
</div>
<div v-else-if="n.type == 'share'">
<p class="my-0">
<a :href="n.account.url" class="font-weight-bold text-dark word-break" data-placement="bottom" data-toggle="tooltip" :title="n.account.username">{{truncate(n.account.username)}}</a> shared your <a class="font-weight-bold" v-bind:href="n.status.reblog.url">post</a>.
</p>
</div>
<div class="align-items-center">
<span class="small text-muted" data-toggle="tooltip" data-placement="bottom" :title="n.created_at">{{timeAgo(n.created_at)}}</span>
</div>
</div>
<div>
<div v-if="n.status && n.status && n.status.media_attachments && n.status.media_attachments.length">
<a :href="n.status.url">
<img :src="n.status.media_attachments[0].preview_url" width="32px" height="32px">
</a>
</div>
<div v-else-if="n.status && n.status.parent && n.status.parent.media_attachments && n.status.parent.media_attachments.length">
<a :href="n.status.parent.url">
<img :src="n.status.parent.media_attachments[0].preview_url" width="32px" height="32px">
</a>
</div>
<!-- <div v-else-if="n.status && n.status.parent && n.status.parent.media_attachments && n.status.parent.media_attachments.length">
<a :href="n.status.parent.url">
<img :src="n.status.parent.media_attachments[0].preview_url" width="32px" height="32px">
</a>
</div> -->
<div v-else-if="n.type == 'follow' && n.relationship.following == false">
<a href="#" class="btn btn-primary py-0 font-weight-bold" @click.prevent="followProfile(n);">
Follow
</a>
</div>
<!-- <div v-else-if="n.status && n.status.parent && !n.status.parent.media_attachments && n.type == 'like' && n.relationship.following == false">
<a href="#" class="btn btn-primary py-0 font-weight-bold">
Follow
</a>
</div> -->
<div v-else>
<a class="btn btn-outline-primary py-0 font-weight-bold" :href="viewContext(n)">View</a>
</div>
</div>
</div>
<div v-if="notifications.length">
<infinite-loading @infinite="infiniteNotifications">
<div slot="no-results" class="font-weight-bold"></div>
<div slot="no-more" class="font-weight-bold"></div>
</infinite-loading>
</div>
<div v-if="notifications.length == 0" class="text-lighter text-center py-3">
<p class="mb-0"><i class="fas fa-inbox fa-3x"></i></p>
<p class="mb-0 small font-weight-bold">0 Notifications!</p>
</div>
</div>
</div>
</div>
</div>
</template>
<script type="text/javascript">
export default {
data() {
return {
notifications: {},
notificationCursor: 2,
notificationMaxId: 0,
};
},
mounted() {
this.fetchNotifications();
},
updated() {
$('[data-toggle="tooltip"]').tooltip()
},
methods: {
fetchNotifications() {
axios.get('/api/v1/notifications', {
params: {
pg: true
}
})
.then(res => {
let data = res.data.filter(n => {
if(n.type == 'share' && !status) {
return false;
}
return true;
});
let ids = res.data.map(n => n.id);
this.notificationMaxId = Math.max(...ids);
this.notifications = data;
$('.notification-card .loader').addClass('d-none');
$('.notification-card .contents').removeClass('d-none');
});
},
infiniteNotifications($state) {
if(this.notificationCursor > 10) {
$state.complete();
return;
}
axios.get('/api/v1/notifications', {
params: {
page: this.notificationCursor,
pg: true
}
}).then(res => {
if(res.data.length > 0) {
let data = res.data.filter(n => {
if(n.type == 'share' && !status) {
return false;
}
return true;
});
this.notifications.push(...data);
this.notificationCursor++;
$state.loaded();
} else {
$state.complete();
}
});
},
truncate(text) {
if(text.length <= 15) {
return text;
}
return text.slice(0,15) + '...'
},
timeAgo(ts) {
let date = Date.parse(ts);
let seconds = Math.floor((new Date() - date) / 1000);
let interval = Math.floor(seconds / 31536000);
if (interval >= 1) {
return interval + "y";
}
interval = Math.floor(seconds / 604800);
if (interval >= 1) {
return interval + "w";
}
interval = Math.floor(seconds / 86400);
if (interval >= 1) {
return interval + "d";
}
interval = Math.floor(seconds / 3600);
if (interval >= 1) {
return interval + "h";
}
interval = Math.floor(seconds / 60);
if (interval >= 1) {
return interval + "m";
}
return Math.floor(seconds) + "s";
},
mentionUrl(status) {
let username = status.account.username;
let id = status.id;
return '/p/' + username + '/' + id;
},
followProfile(n) {
let self = this;
let id = n.account.id;
axios.post('/i/follow', {
item: id
}).then(res => {
self.notifications.map(notification => {
if(notification.account.id === id) {
notification.relationship.following = true;
}
});
}).catch(err => {
if(err.response.data.message) {
swal('Error', err.response.data.message, 'error');
}
});
},
viewContext(n) {
switch(n.type) {
case 'follow':
return n.account.url;
break;
case 'mention':
return n.status.url;
break;
case 'like':
case 'favourite':
return n.status.url;
break;
}
return '/';
},
}
}
</script>

View file

@ -1,5 +1,16 @@
<template>
<div>
<div v-if="!composeType">
<div class="card">
<div class="card-body">
<button type="button" class="btn btn-primary btn-block font-weight-bold" @click="composeType = 'post'">Compose Post</button>
<hr>
<!-- <button type="button" class="btn btn-outline-secondary btn-block font-weight-bold" @click="composeType = 'story'">Add Story</button> -->
<button type="button" class="btn btn-outline-secondary btn-block font-weight-bold" @click="composeType = 'loop'">Create Loop</button>
</div>
</div>
</div>
<div v-if="composeType == 'post'">
<input type="file" name="media" class="d-none file-input" multiple="" v-bind:accept="config.uploader.media_types">
<div class="timeline">
<div class="card status-card card-md-rounded-0">
@ -198,6 +209,30 @@
</div>
</div>
</div>
<div v-if="composeType == 'loop'">
<div class="card">
<div class="card-body">
<button type="button" class="btn btn-primary btn-block font-weight-bold" @click="composeType = 'post'">Upload Loop</button>
<hr>
<button type="button" class="btn btn-outline-secondary btn-block font-weight-bold" @click="composeType = ''">Back</button>
<!-- <button type="button" class="btn btn-outline-secondary btn-block font-weight-bold">Import from Coub</button>
<button type="button" class="btn btn-outline-secondary btn-block font-weight-bold">Import from Vine</button>
<button type="button" class="btn btn-outline-secondary btn-block font-weight-bold">Import from YouTube</button> -->
</div>
</div>
</div>
<div v-if="composeType == 'story'">
<div class="card">
<div class="card-body">
<button type="button" class="btn btn-primary btn-block font-weight-bold" @click="composeType = 'post'">Add to Story</button>
<hr>
<button type="button" class="btn btn-outline-primary btn-block font-weight-bold" @click="composeType = 'post'">New Story</button>
<hr>
<button type="button" class="btn btn-outline-secondary btn-block font-weight-bold" @click="composeType = ''">Back</button>
</div>
</div>
</div>
</div>
</template>
<style type="text/css" scoped>
@ -239,7 +274,8 @@ export default {
mediaDrawer: false,
composeState: 'publish',
uploading: false,
uploadProgress: 0
uploadProgress: 0,
composeType: false
}
},
@ -293,6 +329,7 @@ export default {
['Willow','filter-willow'],
['X-Pro II','filter-xpro-ii']
];
},
methods: {
@ -300,6 +337,9 @@ export default {
fetchConfig() {
axios.get('/api/v2/config').then(res => {
this.config = res.data;
if(this.config.uploader.media_types.includes('video/mp4') == false) {
this.composeType = 'post'
}
});
},
@ -485,6 +525,7 @@ export default {
},
closeModal() {
this.composeType = '';
$('#composeModal').modal('hide');
}
}

View file

@ -1,22 +1,18 @@
<template>
<div class="container">
<section class="d-none d-md-flex mb-md-5 pb-md-3 px-2" style="overflow-x: hidden;" v-if="categories.length > 0">
<a class="bg-dark rounded d-inline-flex align-items-end justify-content-center mr-3 box-shadow card-disc" href="/discover/personal">
<p class="text-white font-weight-bold" style="text-shadow: 3px 3px 16px #272634;border-bottom: 2px solid #fff;">For You</p>
<section class="d-none d-md-flex mb-md-2 pt-2 discover-bar" style="width:auto; overflow: auto hidden;" v-if="categories.length > 0">
<a v-if="config.ab.loops == true" class="text-decoration-none bg-transparent border border-success rounded d-inline-flex align-items-center justify-content-center mr-3 card-disc" href="/discover/loops">
<p class="text-success lead font-weight-bold mb-0">Loops</p>
</a>
<div v-show="categoryCursor > 5" class="text-dark d-inline-flex align-items-center pr-3" v-on:click="categoryPrev()">
<i class="fas fa-chevron-circle-left fa-lg text-muted"></i>
</div>
<!-- <a class="text-decoration-none rounded d-inline-flex align-items-center justify-content-center mr-3 box-shadow card-disc" href="/discover/personal" style="background: rgb(255, 95, 109);">
<p class="text-white lead font-weight-bold mb-0">For You</p>
</a> -->
<a v-for="(category, index) in categories" :key="index+'_cat_'" class="bg-dark rounded d-inline-flex align-items-end justify-content-center mr-3 box-shadow card-disc" :href="category.url" :style="'background: linear-gradient(rgba(0, 0, 0, 0.3),rgba(0, 0, 0, 0.3)),url('+category.thumb+');'">
<p class="text-white font-weight-bold" style="text-shadow: 3px 3px 16px #272634;">{{category.name}}</p>
</a>
<div v-show="allCategories.length != categoryCursor" class="text-dark d-flex align-items-center" v-on:click="categoryNext()">
<i class="fas fa-chevron-circle-right fa-lg text-muted"></i>
</div>
</section>
<section class="mb-5 section-explore">
<div class="profile-timeline">
@ -41,7 +37,11 @@
</template>
<style type="text/css" scoped>
.discover-bar::-webkit-scrollbar {
display: none;
}
.card-disc {
flex: 0 0 160px;
width:160px;
height:100px;
background-size: cover !important;
@ -52,12 +52,11 @@
export default {
data() {
return {
people: {},
config: {},
posts: {},
trending: {},
categories: {},
allCategories: {},
categoryCursor: 5,
}
},
mounted() {
@ -75,26 +74,18 @@ export default {
el.addClass('btn-outline-secondary').removeClass('btn-primary');
el.text('Unfollow');
}).catch(err => {
swal(
'Whoops! Something went wrong…',
'An error occurred, please try again later.',
'error'
);
if(err.response.data.message) {
swal('Error', err.response.data.message, 'error');
}
});
},
fetchData() {
// axios.get('/api/v2/discover/people')
// .then((res) => {
// let data = res.data;
// this.people = data.people;
// if(this.people.length > 1) {
// $('.section-people .loader').hide();
// $('.section-people .row.d-none').removeClass('d-none');
// }
// });
axios.get('/api/v2/config')
.then((res) => {
let data = res.data;
this.config = data;
});
axios.get('/api/v2/discover/posts')
.then((res) => {
let data = res.data;
@ -111,31 +102,9 @@ export default {
axios.get('/api/v2/discover/categories')
.then(res => {
this.allCategories = res.data;
this.categories = _.slice(res.data, 0, 5);
this.categories = res.data;
});
},
categoryNext() {
if(this.categoryCursor > this.allCategories.length - 1) {
return;
}
this.categoryCursor++;
let cursor = this.categoryCursor;
let start = cursor - 5;
let end = cursor;
this.categories = _.slice(this.allCategories, start, end);
},
categoryPrev() {
if(this.categoryCursor == 5) {
return;
}
this.categoryCursor--;
let cursor = this.categoryCursor;
let start = cursor - 5;
let end = cursor;
this.categories = _.slice(this.allCategories, start, end);
},
}
}
</script>

View file

@ -0,0 +1,111 @@
<template>
<div>
<div class="mb-4">
<p class="text-center">
<!-- <a :class="[tab == 'popular'? 'btn font-weight-bold py-0 btn-success' : 'btn font-weight-bold py-0 btn-outline-success']" href="#" @click.prevent="setTab('popular')">Popular</a> -->
<a :class="[tab == 'new'? 'btn font-weight-bold py-0 btn-success' : 'btn font-weight-bold py-0 btn-outline-success']" href="#" @click.prevent="setTab('new')">New</a>
<!-- <a :class="[tab == 'random'? 'btn font-weight-bold py-0 btn-success' : 'btn font-weight-bold py-0 btn-outline-success']" href="#" @click.prevent="setTab('random')">Random</a> -->
<a :class="[tab == 'about'? 'btn font-weight-bold py-0 btn-success' : 'btn font-weight-bold py-0 btn-outline-success']" href="#" @click.prevent="setTab('about')">About</a>
</p>
</div>
<div v-if="tab != 'about'" class="row loops-container">
<div class="col-12 col-md-4 mb-3" v-for="(loop, index) in loops">
<div class="card border border-success">
<div class="embed-responsive embed-responsive-1by1">
<video class="embed-responsive-item" :src="videoSrc(loop)" preload="auto" loop @click="toggleVideo(loop, $event)"></video>
</div>
<div class="card-body">
<p class="username font-weight-bolder"><a :href="loop.account.url">{{loop.account.acct}}</a> , <a :href="loop.url">{{timestamp(loop)}}</a></p>
<p class="small text-muted" v-html="loop.content"></p>
<div class="small text-muted d-flex justify-content-between mb-0">
<span>{{loop.favourites_count}} Likes</span>
<span>{{loop.reblogs_count}} Shares</span>
<span>0 Loops</span>
</div>
</div>
</div>
</div>
</div>
<div v-else class="col-12">
<div class="card">
<div class="card-body">
<p class="lead text-center mb-0">Loops are an exciting new way to explore short videos on Pixelfed.</p>
</div>
</div>
</div>
</div>
</template>
<style type="text/css">
.loops-container .card {
box-shadow: none;
}
.loops-container .card .card-img-top{
border-radius: 0;
}
.loops-container a {
color: #343a40;
}
a.hashtag,
.loops-container .card-body a:hover {
color: #28a745 !important;
}
</style>
<script type="text/javascript">
Object.defineProperty(HTMLMediaElement.prototype, 'playing', {
get: function(){
return !!(this.currentTime > 0 && !this.paused && !this.ended && this.readyState > 2);
}
})
export default {
data() {
return {
'version': 1,
'loops': [],
'tab': 'new'
}
},
mounted() {
axios.get('/api/v2/loops')
.then(res => {
this.loops = res.data;
})
},
methods: {
videoSrc(loop) {
return loop.media_attachments[0].url;
},
setTab(tab) {
this.tab = tab;
},
toggleVideo(loop, $event) {
let el = $event.target;
$('video').each(function() {
if(el.src != $(this)[0].src) {
$(this)[0].pause();
}
});
if(!el.playing) {
el.play();
this.incrementLoop(loop);
} else {
el.pause();
}
},
incrementLoop(loop) {
axios.post('/api/v2/loops/watch', {
id: loop.id
}).then(res => {
console.log(res.data);
});
},
timestamp(loop) {
let ts = new Date(loop.created_at);
return ts.toLocaleDateString();
}
}
}
</script>

View file

@ -19,31 +19,31 @@
<div class="media-body font-weight-light small">
<div v-if="n.type == 'favourite'">
<p class="my-0">
<a :href="n.account.url" class="font-weight-bold text-dark word-break" data-placement="bottom" data-toggle="tooltip" :title="n.account.username">{{truncate(n.account.username)}}</a> liked your <a class="font-weight-bold" v-bind:href="n.status.url">post</a>.
<a :href="n.account.url" class="font-weight-bold text-dark word-break" :title="n.account.username">{{truncate(n.account.username)}}</a> liked your <a class="font-weight-bold" v-bind:href="n.status.url">post</a>.
</p>
</div>
<div v-else-if="n.type == 'comment'">
<p class="my-0">
<a :href="n.account.url" class="font-weight-bold text-dark word-break" data-placement="bottom" data-toggle="tooltip" :title="n.account.username">{{truncate(n.account.username)}}</a> commented on your <a class="font-weight-bold" v-bind:href="n.status.url">post</a>.
<a :href="n.account.url" class="font-weight-bold text-dark word-break" :title="n.account.username">{{truncate(n.account.username)}}</a> commented on your <a class="font-weight-bold" v-bind:href="n.status.url">post</a>.
</p>
</div>
<div v-else-if="n.type == 'mention'">
<p class="my-0">
<a :href="n.account.url" class="font-weight-bold text-dark word-break" data-placement="bottom" data-toggle="tooltip" :title="n.account.username">{{truncate(n.account.username)}}</a> <a class="font-weight-bold" v-bind:href="mentionUrl(n.status)">mentioned</a> you.
<a :href="n.account.url" class="font-weight-bold text-dark word-break" :title="n.account.username">{{truncate(n.account.username)}}</a> <a class="font-weight-bold" v-bind:href="mentionUrl(n.status)">mentioned</a> you.
</p>
</div>
<div v-else-if="n.type == 'follow'">
<p class="my-0">
<a :href="n.account.url" class="font-weight-bold text-dark word-break" data-placement="bottom" data-toggle="tooltip" :title="n.account.username">{{truncate(n.account.username)}}</a> followed you.
<a :href="n.account.url" class="font-weight-bold text-dark word-break" :title="n.account.username">{{truncate(n.account.username)}}</a> followed you.
</p>
</div>
<div v-else-if="n.type == 'share'">
<p class="my-0">
<a :href="n.account.url" class="font-weight-bold text-dark word-break" data-placement="bottom" data-toggle="tooltip" :title="n.account.username">{{truncate(n.account.username)}}</a> shared your <a class="font-weight-bold" v-bind:href="n.status.reblog.url">post</a>.
<a :href="n.account.url" class="font-weight-bold text-dark word-break" :title="n.account.username">{{truncate(n.account.username)}}</a> shared your <a class="font-weight-bold" v-bind:href="n.status.reblog.url">post</a>.
</p>
</div>
</div>
<div class="small text-muted" data-toggle="tooltip" data-placement="bottom" :title="n.created_at">{{timeAgo(n.created_at)}}</div>
<div class="small text-muted" :title="n.created_at">{{timeAgo(n.created_at)}}</div>
</div>
<div v-if="notifications.length">
<infinite-loading @infinite="infiniteNotifications">
@ -73,13 +73,10 @@
},
mounted() {
if(window.outerWidth > 767) {
this.fetchNotifications();
}
this.fetchNotifications();
},
updated() {
$('[data-toggle="tooltip"]').tooltip()
},
methods: {

View file

@ -950,6 +950,10 @@ export default {
this.profile.followers_count++;
}
this.relationship.following = !this.relationship.following;
}).catch(err => {
if(err.response.data.message) {
swal('Error', err.response.data.message, 'error');
}
});
},
@ -1064,7 +1068,11 @@ export default {
this.following.splice(index, 1);
this.profile.following_count--;
}
})
}).catch(err => {
if(err.response.data.message) {
swal('Error', err.response.data.message, 'error');
}
});
},
momentBackground() {

View file

@ -148,6 +148,10 @@ export default {
item: id
}).then(res => {
window.location.href = window.location.href;
}).catch(err => {
if(err.response.data.message) {
swal('Error', err.response.data.message, 'error');
}
});
},
}

View file

@ -1083,7 +1083,11 @@
item: id
}).then(res => {
this.suggestions.splice(index, 1);
})
}).catch(err => {
if(err.response.data.message) {
swal('Error', err.response.data.message, 'error');
}
});
},
followModalAction(id, index, type = 'following') {
@ -1093,7 +1097,11 @@
if(type == 'following') {
this.following.splice(index, 1);
}
})
}).catch(err => {
if(err.response.data.message) {
swal('Error', err.response.data.message, 'error');
}
});
},
owner(status) {

View file

@ -14,7 +14,7 @@
>
<b-carousel-slide v-for="(media, index) in status.media_attachments" :key="media.id + '-media'">
<video v-if="media.type == 'Video'" slot="img" class="embed-responsive-item" preload="none" controls loop :alt="media.description" width="100%" height="100%">
<video v-if="media.type == 'Video'" slot="img" class="embed-responsive-item" preload="none" controls loop :alt="media.description" width="100%" height="100%" :poster="media.preview_url">
<source :src="media.url" :type="media.mime">
</video>
@ -38,7 +38,7 @@
>
<b-carousel-slide v-for="(media, index) in status.media_attachments" :key="media.id + '-media'">
<video v-if="media.type == 'Video'" slot="img" class="embed-responsive-item" preload="none" controls loop :alt="media.description" width="100%" height="100%">
<video v-if="media.type == 'Video'" slot="img" class="embed-responsive-item" preload="none" controls loop :alt="media.description" width="100%" height="100%" :poster="media.preview_url">
<source :src="media.url" :type="media.mime">
</video>

View file

@ -13,7 +13,7 @@
:interval="0"
>
<b-carousel-slide v-for="(vid, index) in status.media_attachments" :key="vid.id + '-media'">
<video slot="img" class="embed-responsive-item" preload="none" controls loop :alt="vid.description" width="100%" height="100%">
<video slot="img" class="embed-responsive-item" preload="none" controls loop :alt="vid.description" width="100%" height="100%" :poster="vid.preview_url">
<source :src="vid.url" :type="vid.mime">
</video>
</b-carousel-slide>
@ -29,7 +29,7 @@
:interval="0"
>
<b-carousel-slide v-for="(vid, index) in status.media_attachments" :key="vid.id + '-media'">
<video slot="img" class="embed-responsive-item" preload="none" controls loop :alt="vid.description" width="100%" height="100%">
<video slot="img" class="embed-responsive-item" preload="none" controls loop :alt="vid.description" width="100%" height="100%" :poster="vid.preview_url">
<source :src="vid.url" :type="vid.mime">
</video>
</b-carousel-slide>

View file

@ -6,14 +6,14 @@
<p class="font-weight-light">(click to show)</p>
</summary>
<div class="embed-responsive embed-responsive-16by9">
<video class="video" preload="none" controls loop>
<video class="video" preload="none" controls loop :poster="status.media_attachments[0].preview_url">
<source :src="status.media_attachments[0].url" :type="status.media_attachments[0].mime">
</video>
</div>
</details>
</div>
<div v-else class="embed-responsive embed-responsive-16by9">
<video class="video" preload="none" controls loop>
<video class="video" preload="auto" controls loop :poster="status.media_attachments[0].preview_url">
<source :src="status.media_attachments[0].url" :type="status.media_attachments[0].mime">
</video>
</div>
@ -21,6 +21,6 @@
<script type="text/javascript">
export default {
props: ['status']
props: ['status'],
}
</script>

4
resources/assets/js/direct.js vendored Normal file
View file

@ -0,0 +1,4 @@
Vue.component(
'direct-component',
require('./components/Direct.vue').default
);

4
resources/assets/js/loops.js vendored Normal file
View file

@ -0,0 +1,4 @@
Vue.component(
'loops-component',
require('./components/LoopComponent.vue').default
);

View file

@ -1,145 +1,14 @@
@extends('layouts.app')
@section('content')
<div class="container notification-page" style="min-height: 60vh;">
<div class="col-12 col-md-8 offset-md-2">
<div class="card mt-3">
<div class="card-body p-0">
<ul class="nav nav-pills d-flex text-center">
<li class="nav-item flex-fill">
<a class="nav-link font-weight-bold text-uppercase active" href="{{route('notifications')}}">Notifications</a>
</li>
<li class="nav-item flex-fill">
<a class="nav-link font-weight-bold text-uppercase" href="{{route('follow-requests')}}">Follow Requests</a>
</li>
</ul>
</div>
</div>
<div class="">
<div class="dropdown text-right mt-2">
<a class="btn btn-link btn-sm dropdown-toggle font-weight-bold text-dark" href="#" role="button" id="dropdownMenuLink" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
Filter
</a>
<div class="dropdown-menu" aria-labelledby="dropdownMenuLink">
<a href="?a=comment" class="dropdown-item font-weight-bold" title="Commented on your post">
Comments only
</a>
<a href="?a=follow" class="dropdown-item font-weight-bold" title="Followed you">
New Followers only
</a>
<a href="?a=mention" class="dropdown-item font-weight-bold" title="Mentioned you">
Mentions only
</a>
<a href="{{route('notifications')}}" class="dropdown-item font-weight-bold text-dark">
View All
</a>
</div>
</div>
</div>
<ul class="list-group">
@if($notifications->count() > 0)
@foreach($notifications as $notification)
<li class="list-group-item notification border-0">
@switch($notification->action)
@case('like')
<span class="notification-icon pr-3">
<img src="{{optional($notification->actor, function($actor) {
return $actor->avatarUrl(); }) }}" width="32px" class="rounded-circle">
</span>
<span class="notification-text">
{!! $notification->rendered !!}
<span class="text-muted notification-timestamp pl-1">{{$notification->created_at->diffForHumans(null, true, true, true)}}</span>
</span>
<span class="float-right notification-action">
@if($notification->item_id && $notification->item_type == 'App\Status')
<a href="{{$notification->status->url()}}"><img src="{{$notification->status->thumb()}}" width="32px" height="32px"></a>
@endif
</span>
@break
@case('follow')
<span class="notification-icon pr-3">
<img src="{{$notification->actor->avatarUrl()}}" width="32px" class="rounded-circle">
</span>
<span class="notification-text">
{!! $notification->rendered !!}
<span class="text-muted notification-timestamp pl-1">{{$notification->created_at->diffForHumans(null, true, true, true)}}</span>
</span>
@if($notification->actor->followedBy(Auth::user()->profile) == false)
<span class="float-right notification-action">
<form class="follow-form" method="post" action="/i/follow" style="display: inline;" data-id="{{$notification->actor->id}}" data-action="follow">
@csrf
<input type="hidden" name="item" value="{{$notification->actor->id}}">
<button class="btn btn-primary font-weight-bold px-4 py-0" type="submit">Follow</button>
</form>
</span>
@endif
@break
@case('comment')
<span class="notification-icon pr-3">
<img src="{{$notification->actor->avatarUrl()}}" width="32px" class="rounded-circle">
</span>
<span class="notification-text">
{!! $notification->rendered !!}
<span class="text-muted notification-timestamp pl-1">{{$notification->created_at->diffForHumans(null, true, true, true)}}</span>
</span>
<span class="float-right notification-action">
@if($notification->item_id && $notification->item_type == 'App\Status')
@if($notification->status->parent())
<a href="{{$notification->status->parent()->url()}}">
<div class="notification-image" style="background-image: url('{{$notification->status->parent()->thumb()}}')"></div>
</a>
@endif
@endif
</span>
@break
@case('mention')
<span class="notification-icon pr-3">
<img src="{{$notification->status->profile->avatarUrl()}}" width="32px" class="rounded-circle">
</span>
<span class="notification-text">
{!! $notification->rendered !!}
<span class="text-muted notification-timestamp pl-1">{{$notification->created_at->diffForHumans(null, true, true, true)}}</span>
</span>
<span class="float-right notification-action">
@if($notification->item_id && $notification->item_type === 'App\Status')
@if(is_null($notification->status->in_reply_to_id))
<a href="{{$notification->status->url()}}">
<div class="notification-image" style="background-image: url('{{$notification->status->thumb()}}')"></div>
</a>
@else
<a href="{{$notification->status->parent()->url()}}">
<div class="notification-image" style="background-image: url('{{$notification->status->parent()->thumb()}}')"></div>
</a>
@endif
@endif
</span>
@break
@endswitch
</li>
@endforeach
</ul>
<div class="d-flex justify-content-center my-4">
{{$notifications->links()}}
</div>
@else
<div class="mt-4">
<div class="alert alert-info font-weight-bold">No unread notifications found.</div>
</div>
@endif
</div>
<div>
<activity-component></activity-component>
</div>
@endsection
@push('scripts')
<script type="text/javascript" src="{{ mix('js/compose.js') }}"></script>
<script type="text/javascript" src="{{ mix('js/activity.js') }}"></script>
<script type="text/javascript">
new Vue({
el: '#content'

View file

@ -0,0 +1,93 @@
@extends('layouts.app')
@section('content')
<div class="container">
<div class="row">
<div class="col-12 py-5 d-flex justify-content-between align-items-center">
<p class="h1 mb-0"><i class="far fa-circle"></i> Create Circle</p>
</div>
<div class="col-12 col-md-10 offset-md-1">
<div class="card">
<div class="card-body px-5">
@if ($errors->any())
<div class="alert alert-danger">
<ul>
@foreach ($errors->all() as $error)
<li>{{ $error }}</li>
@endforeach
</ul>
</div>
@endif
<form method="post">
@csrf
<div class="form-group row">
<label class="col-sm-2 col-form-label font-weight-bold text-muted">Name</label>
<div class="col-sm-10">
<input type="text" class="form-control" placeholder="Circle Name" name="name" autocomplete="off">
</div>
</div>
<div class="form-group row">
<label class="col-sm-2 col-form-label font-weight-bold text-muted">Description</label>
<div class="col-sm-10">
<textarea class="form-control" placeholder="Optional description visible only to you" rows="3" name="description"></textarea>
</div>
</div>
<div class="form-group row">
<label class="col-sm-2 col-form-label font-weight-bold text-muted">Visibility</label>
<div class="col-sm-10">
<select class="form-control" name="scope">
<option value="public">Public</option>
<option value="unlisted">Unlisted</option>
<option value="private">Followers Only</option>
<option value="exclusive">Circle Only</option>
</select>
<p class="help-text font-weight-bold text-muted small">Who can view posts from this circle</p>
</div>
</div>
<div class="form-group row">
<div class="col-sm-2 font-weight-bold text-muted">BCC Mode</div>
<div class="col-sm-10">
<div class="form-check">
<input class="form-check-input" type="checkbox" name="bcc">
<label class="form-check-label"></label>
</div>
<p class="help-text mb-0 small text-muted">Send posts without mentioning other circle recipients.</p>
</div>
</div>
<hr>
<div class="form-group row">
<label class="col-sm-2 col-form-label font-weight-bold text-muted">Members</label>
<div class="col-sm-10">
<input type="text" class="form-control" placeholder="">
</div>
</div>
<hr>
<div class="form-group row">
<div class="col-sm-10 offset-sm-2">
<div class="custom-control custom-switch">
<input type="checkbox" class="custom-control-input" name="active" id="activeSwitch">
<label class="custom-control-label font-weight-bold text-muted" for="activeSwitch">Active</label>
</div>
</div>
</div>
<div class="form-group text-right mb-0">
<button type="submit" class="btn btn-primary btn-sm py-1 font-weight-bold">Create</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
@endsection
@push('scripts')
<script type="text/javascript">
</script>
@endpush

View file

@ -0,0 +1,17 @@
@extends('layouts.app')
@section('content')
<div>
<direct-component></direct-component>
</div>
@endsection
@push('scripts')
<script type="text/javascript" src="{{ mix('js/compose.js') }}"></script>
<script type="text/javascript" src="{{ mix('js/direct.js') }}"></script>
<script type="text/javascript">
new Vue({
el: '#content'
});
</script>
@endpush

View file

@ -0,0 +1,39 @@
@extends('layouts.app')
@section('content')
<div class="bg-success" style="height:1.2px;"></div>
<div class="profile-header">
<div class="container pt-5">
<div class="profile-details text-center">
<div class="username-bar text-dark">
<p class="display-4 font-weight-bold mb-0"><span class="text-success">Loops</span> <sup class="lead">BETA</sup></p>
<p class="lead font-weight-lighter">Short looping videos</p>
</div>
</div>
</div>
</div>
<div class="loop-page container mt-5">
<section>
<loops-component></loops-component>
</section>
</div>
@endsection
@push('styles')
<style type="text/css">
@media (min-width: 1200px) {
.loop-page.container {
max-width: 1035px;
}
}
</style>
@endpush
@push('scripts')
<script type="text/javascript" src="{{ mix('js/loops.js') }}"></script>
<script type="text/javascript" src="{{ mix('js/compose.js') }}"></script>
<script type="text/javascript">
$(document).ready(function(){
new Vue({el: '#content'});
});
</script>
@endpush

View file

@ -56,19 +56,19 @@
<div class="card card-body rounded-0 py-2 d-flex align-items-middle box-shadow" style="border-top:1px solid #F1F5F8">
<ul class="nav nav-pills nav-fill">
<li class="nav-item">
<a class="nav-link {{request()->is('/')?'text-primary':'text-muted'}}" href="/"><i class="fas fa-home fa-lg"></i></a>
<a class="nav-link {{request()->is('/')?'text-dark':'text-muted'}}" href="/"><i class="fas fa-home fa-lg"></i></a>
</li>
<li class="nav-item">
<a class="nav-link {{request()->is('timeline/public')?'text-primary':'text-muted'}}" href="/timeline/public"><i class="far fa-map fa-lg"></i></a>
<a class="nav-link {{request()->is('timeline/public')?'text-dark':'text-muted'}}" href="/timeline/public"><i class="far fa-map fa-lg"></i></a>
</li>
<li class="nav-item">
<div class="nav-link text-black cursor-pointer" data-toggle="modal" data-target="#composeModal"><i class="far fa-plus-square fa-lg"></i></div>
<div class="nav-link text-primary cursor-pointer" data-toggle="modal" data-target="#composeModal"><i class="fas fa-camera-retro fa-lg"></i></div>
</li>
<li class="nav-item">
<a class="nav-link {{request()->is('discover')?'text-primary':'text-muted'}}" href="{{route('discover')}}"><i class="far fa-compass fa-lg"></i></a>
<a class="nav-link {{request()->is('discover')?'text-dark':'text-muted'}}" href="{{route('discover')}}"><i class="far fa-compass fa-lg"></i></a>
</li>
<li class="nav-item">
<a class="nav-link {{request()->is('account/activity')?'text-primary':'text-muted'}} tooltip-notification" href="/account/activity"><i class="far fa-bell fa-lg"></i></a>
<a class="nav-link {{request()->is('account/activity')?'text-dark':'text-muted'}} tooltip-notification" href="/account/activity"><i class="far fa-bell fa-lg"></i></a>
</li>
</ul>
</div>

View file

@ -27,18 +27,18 @@
<ul class="navbar-nav ml-auto">
<div class="d-none d-md-block">
<li class="nav-item px-md-2">
<a class="nav-link font-weight-bold {{request()->is('/') ?'text-primary':''}}" href="/" title="Home Timeline" data-toggle="tooltip" data-placement="bottom">
<a class="nav-link font-weight-bold {{request()->is('/') ?'text-dark':'text-muted'}}" href="/" title="Home Timeline" data-toggle="tooltip" data-placement="bottom">
<i class="fas fa-home fa-lg"></i>
</a>
</li>
</div>
<div class="d-none d-md-block">
{{-- <div class="d-none d-md-block">
<li class="nav-item px-md-2">
<a class="nav-link font-weight-bold {{request()->is('timeline/public') ?'text-primary':''}}" href="/timeline/public" title="Public Timeline" data-toggle="tooltip" data-placement="bottom">
<i class="far fa-map fa-lg"></i>
</a>
</li>
</div>
</div> --}}
<li class="d-block d-md-none">
@ -51,7 +51,7 @@
</li> --}}
<div class="d-none d-md-block">
<li class="nav-item px-md-2">
<a class="nav-link font-weight-bold {{request()->is('*discover*') ?'text-primary':''}}" href="{{route('discover')}}" title="Discover" data-toggle="tooltip" data-placement="bottom">
<a class="nav-link font-weight-bold {{request()->is('*discover*') ?'text-dark':'text-muted'}}" href="{{route('discover')}}" title="Discover" data-toggle="tooltip" data-placement="bottom">
<i class="far fa-compass fa-lg"></i>
</a>
</li>
@ -60,7 +60,7 @@
<li class="nav-item px-md-2">
<div title="Create new post" data-toggle="tooltip" data-placement="bottom">
<a href="{{route('compose')}}" class="nav-link" data-toggle="modal" data-target="#composeModal">
<i class="far fa-plus-square fa-lg text-dark"></i>
<i class="fas fa-camera-retro fa-lg text-primary"></i>
</a>
</div>
</li>

View file

@ -11,7 +11,7 @@
<p class="font-weight-bold h5 pb-3">How to use Discover</p>
<ul>
<li class="mb-3 ">Click the <i class="far fa-compass fa-sm"></i> icon.</li>
<li class="mb-3 ">View the latest posts from accounts you don't already follow.</li>
<li class="mb-3 ">View the latest posts.</li>
</ul>
</div>
<div class="py-4">
@ -36,7 +36,6 @@
<ul class="pt-3">
<li class="lead mb-4">To make your posts more discoverable, add hashtags to your posts.</li>
<li class="lead mb-4">Any public posts that contain a hashtag may be included in discover pages.</li>
<li class="lead ">No algorithms or behavioral tracking are used in the Discover feature. It may be less personalized than other platforms.</li>
</ul>
</div>

View file

@ -12,7 +12,8 @@
<ul>
<li class="mb-3 ">People use the hashtag symbol (#) before a relevant phrase or keyword in their post to categorize those posts and make them more discoverable.</li>
<li class="mb-3 ">Any hashtags will be linked to a hashtag page with other posts containing the same hashtag.</li>
<li class="">Hashtags can be used anywhere in a post.</li>
<li class="mb-3">Hashtags can be used anywhere in a post.</li>
<li class="">You can add up to 30 hashtags to your post or comment.</li>
</ul>
</div>
<div class="card bg-primary border-primary" style="box-shadow: none !important;border: 3px solid #08d!important;">

View file

@ -163,4 +163,16 @@
</div>
</div>
</p>
<p>
<a class="text-dark font-weight-bold" data-toggle="collapse" href="#collapse11" role="button" aria-expanded="false" aria-controls="collapse11">
<i class="fas fa-chevron-down mr-2"></i>
How many people can I tag or mention in my comments or posts?
</a>
<div class="collapse" id="collapse11">
<div>
You can tag or mention up to 5 profiles per comment or post.
</div>
</div>
</p>
@endsection

View file

@ -6,7 +6,7 @@
<h3 class="font-weight-bold">Timelines</h3>
</div>
<hr>
<p class="lead">Timelines are chronological feeds of posts from accounts you follow or from other instances.</p>
<p class="lead">Timelines are chronological feeds of posts.</p>
<p class="font-weight-bold h5 py-3">Pixelfed has 2 different timelines:</p>
<ul>
@ -26,4 +26,15 @@
<span class="font-weight-light text-muted">Timeline with posts from local and remote accounts - coming soon!</span>
</li> --}}
</ul>
<div class="py-3"></div>
<div class="card bg-primary border-primary" style="box-shadow: none !important;border: 3px solid #08d!important;">
<div class="card-header text-light font-weight-bold h4 p-4">Timeline Tips</div>
<div class="card-body bg-white p-3">
<ul class="pt-3">
<li class="lead mb-4">You can mute or block accounts to prevent them from appearing in timelines.</li>
<li class="lead mb-4">You can create <span class="font-weight-bold">Unlisted</span> posts that don't appear in public timelines.</li>
</ul>
</div>
</div>
@endsection

View file

@ -62,6 +62,7 @@ Route::domain(config('pixelfed.domain.app'))->middleware(['validemail', 'twofact
Route::get('discover/c/{slug}', 'DiscoverController@showCategory');
Route::get('discover/personal', 'DiscoverController@showPersonal');
Route::get('discover', 'DiscoverController@home')->name('discover');
Route::get('discover/loops', 'DiscoverController@showLoops');
Route::group(['prefix' => 'api'], function () {
Route::get('search', 'SearchController@searchAPI');
@ -95,6 +96,8 @@ Route::domain(config('pixelfed.domain.app'))->middleware(['validemail', 'twofact
Route::post('moderator/action', 'InternalApiController@modAction');
Route::get('discover/categories', 'InternalApiController@discoverCategories');
Route::post('status/compose', 'InternalApiController@composePost');
Route::get('loops', 'DiscoverController@loopsApi');
Route::post('loops/watch', 'DiscoverController@loopWatch');
});
Route::group(['prefix' => 'local'], function () {
Route::get('i/follow-suggestions', 'ApiController@followSuggestions');

View file

@ -7,6 +7,7 @@ use Illuminate\Foundation\Testing\WithFaker;
use Illuminate\Foundation\Testing\RefreshDatabase;
use App\Util\Lexer\Autolink;
use App\Util\Lexer\Extractor;
use App\Status;
class StatusLexerTest extends TestCase
{
@ -59,7 +60,7 @@ class StatusLexerTest extends TestCase
public function testAutolink()
{
$expected = '@<a class="u-url mention" href="https://pixelfed.dev/pixelfed" rel="external nofollow noopener" target="_blank">pixelfed</a> hi, really like the website! <a href="https://pixelfed.dev/discover/tags/píxelfed?src=hash" title="#píxelfed" class="u-url hashtag" rel="external nofollow noopener">#píxelfed</a>';
$expected = '<a class="u-url mention" href="https://pixelfed.dev/pixelfed" rel="external nofollow noopener" target="_blank">@pixelfed</a> hi, really like the website! <a href="https://pixelfed.dev/discover/tags/píxelfed?src=hash" title="#píxelfed" class="u-url hashtag" rel="external nofollow noopener">#píxelfed</a>';
$this->assertEquals($this->autolink, $expected);
}
@ -106,4 +107,35 @@ class StatusLexerTest extends TestCase
$actual = Extractor::create()->extract('#dansup @dansup@mstdn.io @test');
$this->assertEquals($actual, $expected);
}
/** @test **/
public function mentionLimit()
{
$text = '@test1 @test @test2 @test3 @test4 @test5 test post';
$entities = Extractor::create()->extract($text);
$count = count($entities['mentions']);
$this->assertEquals($count, Status::MAX_MENTIONS);
}
/** @test **/
public function hashtagLimit()
{
$text = '#hashtag0 #hashtag1 #hashtag2 #hashtag3 #hashtag4 #hashtag5 #hashtag6 #hashtag7 #hashtag8 #hashtag9 #hashtag10 #hashtag11 #hashtag12 #hashtag13 #hashtag14 #hashtag15 #hashtag16 #hashtag17 #hashtag18 #hashtag19 #hashtag20 #hashtag21 #hashtag22 #hashtag23 #hashtag24 #hashtag25 #hashtag26 #hashtag27 #hashtag28 #hashtag29 #hashtag30 #hashtag31';
$entities = Extractor::create()->extract($text);
$count = count($entities['hashtags']);
$this->assertEquals($count, Status::MAX_HASHTAGS);
}
/** @test **/
public function linkLimit()
{
$text = 'https://example.org https://example.net https://example.com';
$entities = Extractor::create()->extract($text);
$count = count($entities['urls']);
$this->assertEquals($count, Status::MAX_LINKS);
}
}

6
webpack.mix.js vendored
View file

@ -37,6 +37,12 @@ mix.js('resources/assets/js/app.js', 'public/js')
// Developer Components
.js('resources/assets/js/developers.js', 'public/js')
// // Direct Component
// .js('resources/assets/js/direct.js', 'public/js')
// Loops Component
.js('resources/assets/js/loops.js', 'public/js')
.sass('resources/assets/sass/app.scss', 'public/css', {
implementation: require('node-sass')
})