浏览代码

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

Follow Hashtags
daniel 6 年之前
父节点
当前提交
da01872796
共有 59 个文件被更改,包括 886 次插入969 次删除
  1. 109 0
      app/Console/Commands/FixHashtags.php
  2. 19 0
      app/HashtagFollow.php
  3. 1 4
      app/Http/Controllers/Api/BaseApiController.php
  4. 28 51
      app/Http/Controllers/DiscoverController.php
  5. 61 0
      app/Http/Controllers/HashtagFollowController.php
  6. 6 0
      app/Http/Controllers/PublicApiController.php
  7. 1 0
      app/Http/Controllers/SearchController.php
  8. 3 1
      app/Jobs/DeletePipeline/DeleteAccountPipeline.php
  9. 5 1
      app/Jobs/StatusPipeline/StatusEntityLexer.php
  10. 64 0
      app/Observers/StatusHashtagObserver.php
  11. 3 0
      app/Providers/AppServiceProvider.php
  12. 80 0
      app/Services/StatusHashtagService.php
  13. 14 1
      app/StatusHashtag.php
  14. 38 0
      app/Transformer/Api/StatusHashtagTransformer.php
  15. 3 1
      app/Util/Lexer/Extractor.php
  16. 15 0
      app/Util/RateLimit/User.php
  17. 23 5
      config/instance.php
  18. 35 0
      database/migrations/2019_07_05_034644_create_hashtag_follows_table.php
  19. 32 0
      database/migrations/2019_07_08_045824_add_status_visibility_to_status_hashtags_table.php
  20. 二进制
      public/js/direct.js
  21. 二进制
      public/js/hashtag.js
  22. 二进制
      public/js/loops.js
  23. 二进制
      public/js/mode-dot.js
  24. 二进制
      public/js/profile.js
  25. 二进制
      public/js/quill.js
  26. 二进制
      public/js/search.js
  27. 二进制
      public/js/status.js
  28. 二进制
      public/js/theme-monokai.js
  29. 二进制
      public/js/timeline.js
  30. 二进制
      public/js/vendor.js
  31. 二进制
      public/mix-manifest.json
  32. 187 0
      resources/assets/js/components/Hashtag.vue
  33. 9 6
      resources/assets/js/components/PostComponent.vue
  34. 10 4
      resources/assets/js/components/PostMenu.vue
  35. 6 8
      resources/assets/js/components/SearchResults.vue
  36. 56 1
      resources/assets/js/components/Timeline.vue
  37. 4 0
      resources/assets/js/hashtag.js
  38. 3 45
      resources/views/discover/tags/show.blade.php
  39. 10 0
      resources/views/errors/400.blade.php
  40. 10 0
      resources/views/errors/403.blade.php
  41. 3 7
      resources/views/errors/404.blade.php
  42. 3 8
      resources/views/errors/500.blade.php
  43. 3 8
      resources/views/errors/503.blade.php
  44. 37 0
      resources/views/site/help/hashtags.blade.php
  45. 0 104
      resources/views/status/show/album.blade.php
  46. 0 85
      resources/views/status/show/photo.blade.php
  47. 0 117
      resources/views/status/show/sidebar.blade.php
  48. 0 50
      resources/views/status/show/video.blade.php
  49. 1 1
      resources/views/status/template.blade.php
  50. 0 29
      resources/views/status/timeline/album.blade.php
  51. 0 15
      resources/views/status/timeline/photo.blade.php
  52. 0 57
      resources/views/status/timeline/video-album.blade.php
  53. 0 19
      resources/views/status/timeline/video.blade.php
  54. 0 82
      resources/views/timeline/partial/new-form.blade.php
  55. 0 68
      resources/views/timeline/personal.blade.php
  56. 0 59
      resources/views/timeline/public.blade.php
  57. 0 132
      resources/views/timeline/template.blade.php
  58. 3 0
      routes/web.php
  59. 1 0
      webpack.mix.js

+ 109 - 0
app/Console/Commands/FixHashtags.php

@@ -0,0 +1,109 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use DB;
+use App\{
+    Hashtag,
+    Status,
+    StatusHashtag
+};
+
+class FixHashtags extends Command
+{
+    /**
+     * The name and signature of the console command.
+     *
+     * @var string
+     */
+    protected $signature = 'fix:hashtags';
+
+    /**
+     * The console command description.
+     *
+     * @var string
+     */
+    protected $description = 'Fix Hashtags';
+
+    /**
+     * Create a new command instance.
+     *
+     * @return void
+     */
+    public function __construct()
+    {
+        parent::__construct();
+    }
+
+    /**
+     * Execute the console command.
+     *
+     * @return mixed
+     */
+    public function handle()
+    {
+
+        $this->info('       ____  _           ______         __  ');
+        $this->info('      / __ \(_)  _____  / / __/__  ____/ /  ');
+        $this->info('     / /_/ / / |/_/ _ \/ / /_/ _ \/ __  /   ');
+        $this->info('    / ____/ />  </  __/ / __/  __/ /_/ /    ');
+        $this->info('   /_/   /_/_/|_|\___/_/_/  \___/\__,_/     ');
+        $this->info(' ');
+        $this->info(' ');
+        $this->info('Pixelfed version: ' . config('pixelfed.version'));
+        $this->info(' ');
+        $this->info('Running Fix Hashtags command');
+        $this->info(' ');
+
+        $missingCount = StatusHashtag::doesntHave('profile')->doesntHave('status')->count();
+        if($missingCount > 0) {
+            $this->info("Found {$missingCount} orphaned StatusHashtag records to delete ...");
+            $this->info(' ');
+            $bar = $this->output->createProgressBar($missingCount);
+            $bar->start();
+            foreach(StatusHashtag::doesntHave('profile')->doesntHave('status')->get() as $tag) {
+                $tag->delete();
+                $bar->advance();
+            }
+            $bar->finish();
+            $this->info(' ');
+        } else {
+            $this->info(' ');
+            $this->info('Found no orphaned hashtags to delete!');
+        }
+        
+
+        $this->info(' ');
+
+        $count = StatusHashtag::whereNull('status_visibility')->count();
+        if($count > 0) {
+            $this->info("Found {$count} hashtags to fix ...");
+            $this->info(' ');
+        } else {
+            $this->info('Found no hashtags to fix!');
+            $this->info(' ');
+            return;
+        }
+
+        $bar = $this->output->createProgressBar($count);
+        $bar->start();
+
+        StatusHashtag::with('status')
+        ->whereNull('status_visibility')
+        ->chunk(50, function($tags) use($bar) {
+            foreach($tags as $tag) {
+                if(!$tag->status || !$tag->status->scope) {
+                    continue;
+                }
+                $tag->status_visibility = $tag->status->scope;
+                $tag->save();
+                $bar->advance();
+            }
+        });
+
+        $bar->finish();
+        $this->info(' ');
+        $this->info(' ');
+    }
+}

+ 19 - 0
app/HashtagFollow.php

@@ -0,0 +1,19 @@
+<?php
+
+namespace App;
+
+use Illuminate\Database\Eloquent\Model;
+
+class HashtagFollow extends Model
+{
+    protected $fillable = [
+    	'user_id',
+    	'profile_id',
+    	'hashtag_id'
+    ];
+
+    public function hashtag()
+    {
+    	return $this->belongsTo(Hashtag::class);
+    }
+}

+ 1 - 4
app/Http/Controllers/Api/BaseApiController.php

@@ -59,14 +59,11 @@ class BaseApiController extends Controller
             $res = $this->fractal->createData($resource)->toArray();
         } else {
             $this->validate($request, [
-                'page' => 'nullable|integer|min:1',
+                'page' => 'nullable|integer|min:1|max:10',
                 'limit' => 'nullable|integer|min:1|max:10'
             ]);
             $limit = $request->input('limit') ?? 10;
             $page = $request->input('page') ?? 1;
-            if($page > 3) {
-                return response()->json([]);
-            }
             $end = (int) $page * $limit;
             $start = (int) $end - $limit;
             $res = NotificationService::get($pid, $start, $end);

+ 28 - 51
app/Http/Controllers/DiscoverController.php

@@ -6,6 +6,7 @@ use App\{
   DiscoverCategory,
   Follower,
   Hashtag,
+  HashtagFollow,
   Profile,
   Status, 
   StatusHashtag, 
@@ -17,6 +18,7 @@ use App\Transformer\Api\StatusStatelessTransformer;
 use League\Fractal;
 use League\Fractal\Serializer\ArraySerializer;
 use League\Fractal\Pagination\IlluminatePaginatorAdapter;
+use App\Services\StatusHashtagService;
 
 class DiscoverController extends Controller
 {
@@ -36,57 +38,11 @@ class DiscoverController extends Controller
 
     public function showTags(Request $request, $hashtag)
     {
-        abort_if(!Auth::check(), 403);
-
-        $tag = Hashtag::whereSlug($hashtag)
-          ->firstOrFail();
-
-        $page = 1;
-        $key = 'discover:tag-'.$tag->id.':page-'.$page;
-        $keyMinutes = 15;
-
-        $posts = Cache::remember($key, now()->addMinutes($keyMinutes), function() use ($tag, $request) {
-          $tags = StatusHashtag::select('status_id')
-            ->whereHashtagId($tag->id)
-            ->orderByDesc('id')
-            ->take(48)
-            ->pluck('status_id');
-
-          return Status::select(
-            'id', 
-            'uri',
-            'caption',
-            'rendered',
-            'profile_id', 
-            'type',
-            'in_reply_to_id',
-            'reblog_of_id',
-            'is_nsfw',
-            'scope',
-            'local',
-            'created_at',
-            'updated_at'
-          )->whereIn('type', ['photo', 'photo:album', 'video', 'video:album'])
-          ->with('media')
-          ->whereLocal(true)
-          ->whereNull('uri')
-          ->whereIn('id', $tags)
-          ->whereNull('in_reply_to_id')
-          ->whereNull('reblog_of_id')
-          ->whereNull('url')
-          ->whereNull('uri')
-          ->withCount(['likes', 'comments'])
-          ->whereIsNsfw(false)
-          ->whereVisibility('public')
-          ->orderBy('id', 'desc')
-          ->get();
-        });
+        abort_if(!config('instance.discover.tags.is_public') && !Auth::check(), 403);
 
-        if($posts->count() == 0) {
-          abort(404);
-        }
-        
-        return view('discover.tags.show', compact('tag', 'posts'));
+        $tag = Hashtag::whereSlug($hashtag)->firstOrFail();
+        $tagCount = StatusHashtagService::count($tag->id);
+        return view('discover.tags.show', compact('tag', 'tagCount'));
     }
 
     public function showCategory(Request $request, $slug)
@@ -156,7 +112,6 @@ class DiscoverController extends Controller
         return $res;
     }
 
-
     public function loopWatch(Request $request)
     {
         abort_if(!Auth::check(), 403);
@@ -171,4 +126,26 @@ class DiscoverController extends Controller
 
         return response()->json(200);
     }
+
+    public function getHashtags(Request $request)
+    {
+      $auth = Auth::check();
+      abort_if(!config('instance.discover.tags.is_public') && !$auth, 403);
+
+      $this->validate($request, [
+        'hashtag' => 'required|alphanum|min:2|max:124',
+        'page' => 'nullable|integer|min:1|max:' . ($auth ? 19 : 3)
+      ]);
+
+      $page = $request->input('page') ?? '1';
+      $end = $page > 1 ? $page * 9 : 1;
+      $tag = $request->input('hashtag');
+
+      $hashtag = Hashtag::whereName($tag)->firstOrFail();
+      $res['tags'] = StatusHashtagService::get($hashtag->id, $page, $end);
+      if($page == 1) {
+        $res['follows'] = HashtagFollow::whereUserId(Auth::id())->whereHashtagId($hashtag->id)->exists();
+      }
+      return $res;
+    }
 }

+ 61 - 0
app/Http/Controllers/HashtagFollowController.php

@@ -0,0 +1,61 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use Illuminate\Http\Request;
+use Auth;
+use App\{
+	Hashtag,
+	HashtagFollow,
+	Status
+};
+
+class HashtagFollowController extends Controller
+{
+    public function __construct()
+    {
+    	$this->middleware('auth');
+    }
+
+    public function store(Request $request)
+    {
+    	$this->validate($request, [
+    		'name' => 'required|alpha_num|min:1|max:124|exists:hashtags,name'
+    	]);
+
+    	$user = Auth::user();
+    	$profile = $user->profile;
+    	$tag = $request->input('name');
+
+    	$hashtag = Hashtag::whereName($tag)->firstOrFail();
+
+        $hashtagFollow = HashtagFollow::firstOrCreate([
+            'user_id' => $user->id,
+            'profile_id' => $user->profile_id ?? $user->profile->id,
+            'hashtag_id' => $hashtag->id
+        ]);
+
+        if($hashtagFollow->wasRecentlyCreated) {
+            $state = 'created';
+            // todo: send to HashtagFollowService
+        } else {
+            $state = 'deleted';
+            $hashtagFollow->delete();
+        }
+
+        return [
+            'state' => $state
+        ];
+    }
+
+    public function getTags(Request $request)
+    {
+        return HashtagFollow::with('hashtag')->whereUserId(Auth::id())
+            ->inRandomOrder()
+            ->take(3)
+            ->get()
+            ->map(function($follow, $k) {
+                  return $follow->hashtag->name;
+            });
+    }
+}

+ 6 - 0
app/Http/Controllers/PublicApiController.php

@@ -211,6 +211,10 @@ class PublicApiController extends Controller
           'limit'       => 'nullable|integer|max:20'
         ]);
 
+        if(config('instance.timeline.local.is_public') == false && !Auth::check()) {
+            abort(403, 'Authentication required.');
+        }
+
         $page = $request->input('page');
         $min = $request->input('min_id');
         $max = $request->input('max_id');
@@ -331,6 +335,8 @@ class PublicApiController extends Controller
                 ->orWhere('status', '!=', null)
                 ->pluck('id');
         });
+        
+        $private = $private->diff($following)->flatten();
 
         $filters = UserFilter::whereUserId($pid)
                   ->whereFilterableType('App\Profile')

+ 1 - 0
app/Http/Controllers/SearchController.php

@@ -143,6 +143,7 @@ class SearchController extends Controller
                     'tokens' => [$item->caption],
                     'name'   => $item->caption,
                     'thumb'  => $item->thumb(),
+                    'filter' => $item->firstMedia()->filter_class
                 ];
             });
             $tokens['posts'] = $posts;

+ 3 - 1
app/Jobs/DeletePipeline/DeleteAccountPipeline.php

@@ -80,8 +80,10 @@ class DeleteAccountPipeline implements ShouldQueue
             Bookmark::whereProfileId($user->profile->id)->forceDelete();
 
             EmailVerification::whereUserId($user->id)->forceDelete();
-
             $id = $user->profile->id;
+
+            StatusHashtag::whereProfileId($id)->delete();
+            
             FollowRequest::whereFollowingId($id)->orWhere('follower_id', $id)->forceDelete();
 
             Follower::whereProfileId($id)->orWhere('following_id', $id)->forceDelete();

+ 5 - 1
app/Jobs/StatusPipeline/StatusEntityLexer.php

@@ -89,6 +89,9 @@ class StatusEntityLexer implements ShouldQueue
         $status = $this->status;
 
         foreach ($tags as $tag) {
+            if(mb_strlen($tag) > 124) {
+                continue;
+            }
             DB::transaction(function () use ($status, $tag) {
                 $slug = str_slug($tag, '-', false);
                 $hashtag = Hashtag::firstOrCreate(
@@ -98,7 +101,8 @@ class StatusEntityLexer implements ShouldQueue
                     [
                         'status_id' => $status->id, 
                         'hashtag_id' => $hashtag->id,
-                        'profile_id' => $status->profile_id
+                        'profile_id' => $status->profile_id,
+                        'status_visibility' => $status->visibility,
                     ]
                 );
             });

+ 64 - 0
app/Observers/StatusHashtagObserver.php

@@ -0,0 +1,64 @@
+<?php
+
+namespace App\Observers;
+
+use App\StatusHashtag;
+use App\Services\StatusHashtagService;
+
+class StatusHashtagObserver
+{
+    /**
+     * Handle the notification "created" event.
+     *
+     * @param  \App\Notification  $notification
+     * @return void
+     */
+    public function created(StatusHashtag $hashtag)
+    {
+        StatusHashtagService::set($hashtag->hashtag_id, $hashtag->status_id);
+    }
+
+    /**
+     * Handle the notification "updated" event.
+     *
+     * @param  \App\Notification  $notification
+     * @return void
+     */
+    public function updated(StatusHashtag $hashtag)
+    {
+        StatusHashtagService::set($hashtag->hashtag_id, $hashtag->status_id);
+    }
+
+    /**
+     * Handle the notification "deleted" event.
+     *
+     * @param  \App\Notification  $notification
+     * @return void
+     */
+    public function deleted(StatusHashtag $hashtag)
+    {
+        StatusHashtagService::del($hashtag->hashtag_id, $hashtag->status_id);
+    }
+
+    /**
+     * Handle the notification "restored" event.
+     *
+     * @param  \App\Notification  $notification
+     * @return void
+     */
+    public function restored(StatusHashtag $hashtag)
+    {
+        StatusHashtagService::set($hashtag->hashtag_id, $hashtag->status_id);
+    }
+
+    /**
+     * Handle the notification "force deleted" event.
+     *
+     * @param  \App\Notification  $notification
+     * @return void
+     */
+    public function forceDeleted(StatusHashtag $hashtag)
+    {
+        StatusHashtagService::del($hashtag->hashtag_id, $hashtag->status_id);
+    }
+}

+ 3 - 0
app/Providers/AppServiceProvider.php

@@ -5,11 +5,13 @@ namespace App\Providers;
 use App\Observers\{
     AvatarObserver,
     NotificationObserver,
+    StatusHashtagObserver,
     UserObserver
 };
 use App\{
     Avatar,
     Notification,
+    StatusHashtag,
     User
 };
 use Auth, Horizon, URL;
@@ -31,6 +33,7 @@ class AppServiceProvider extends ServiceProvider
 
         Avatar::observe(AvatarObserver::class);
         Notification::observe(NotificationObserver::class);
+        StatusHashtag::observe(StatusHashtagObserver::class);
         User::observe(UserObserver::class);
 
         Horizon::auth(function ($request) {

+ 80 - 0
app/Services/StatusHashtagService.php

@@ -0,0 +1,80 @@
+<?php
+
+namespace App\Services;
+
+use Cache, Redis;
+use App\{Status, StatusHashtag};
+use App\Transformer\Api\StatusHashtagTransformer;
+use League\Fractal;
+use League\Fractal\Serializer\ArraySerializer;
+use League\Fractal\Pagination\IlluminatePaginatorAdapter;
+
+class StatusHashtagService {
+
+	const CACHE_KEY = 'pf:services:status-hashtag:collection:';
+
+	public static function get($id, $page = 1, $stop = 9)
+	{
+		return StatusHashtag::whereHashtagId($id)
+			->whereStatusVisibility('public')
+			->whereHas('media')
+			->skip($stop)
+			->latest()
+			->take(9)
+			->pluck('status_id')
+			->map(function ($i, $k) use ($id) {
+				return self::getStatus($i, $id);
+			})
+			->all();
+	}
+
+	public static function coldGet($id, $start = 0, $stop = 2000)
+	{
+		$stop = $stop > 2000 ? 2000 : $stop;
+		$ids = StatusHashtag::whereHashtagId($id)
+			->whereStatusVisibility('public')
+			->whereHas('media')
+			->latest()
+			->skip($start)
+			->take($stop)
+			->pluck('status_id');
+		foreach($ids as $key) {
+			self::set($id, $key);
+		}
+		return $ids;
+	}
+
+	public static function set($key, $val)
+	{
+		return Redis::zadd(self::CACHE_KEY . $key, $val, $val);
+	}
+
+	public static function del($key)
+	{
+		return Redis::zrem(self::CACHE_KEY . $key, $key);
+	}
+
+	public static function count($id)
+	{
+		$count = Redis::zcount(self::CACHE_KEY . $id, '-inf', '+inf');
+		if(empty($count)) {
+			$count = StatusHashtag::whereHashtagId($id)->count();
+		}
+		return $count;
+	}
+
+	public static function getStatus($statusId, $hashtagId)
+	{
+		return Cache::remember('pf:services:status-hashtag:post:'.$statusId.':hashtag:'.$hashtagId, now()->addMonths(3), function() use($statusId, $hashtagId) {
+			$statusHashtag = StatusHashtag::with('profile', 'status', 'hashtag')
+				->whereStatusVisibility('public')
+				->whereStatusId($statusId)
+				->whereHashtagId($hashtagId)
+				->first();
+			$fractal = new Fractal\Manager();
+			$fractal->setSerializer(new ArraySerializer());
+			$resource = new Fractal\Resource\Item($statusHashtag, new StatusHashtagTransformer());
+			return $fractal->createData($resource)->toArray();
+		});
+	}
+}

+ 14 - 1
app/StatusHashtag.php

@@ -9,7 +9,8 @@ class StatusHashtag extends Model
     public $fillable = [
     	'status_id', 
     	'hashtag_id', 
-    	'profile_id'
+    	'profile_id',
+    	'status_visibility'
     ];
 
 	public function status()
@@ -26,4 +27,16 @@ class StatusHashtag extends Model
 	{
 		return $this->belongsTo(Profile::class);
 	}
+
+	public function media()
+	{
+        return $this->hasManyThrough(
+            Media::class,
+            Status::class,
+            'id',
+            'status_id',
+            'status_id',
+            'id'
+        );
+	}
 }

+ 38 - 0
app/Transformer/Api/StatusHashtagTransformer.php

@@ -0,0 +1,38 @@
+<?php
+
+namespace App\Transformer\Api;
+
+use App\{Hashtag, Status, StatusHashtag};
+use League\Fractal;
+
+class StatusHashtagTransformer extends Fractal\TransformerAbstract
+{
+	public function transform(StatusHashtag $statusHashtag)
+	{
+		$hashtag = $statusHashtag->hashtag;
+		$status = $statusHashtag->status;
+		$profile = $statusHashtag->profile;
+		
+		return [
+			'status' => [
+				'id'			=> (int) $status->id,
+				'type' 			=> $status->type,
+				'url' 			=> $status->url(),
+				'thumb' 		=> $status->thumb(),
+				'filter' 		=> $status->firstMedia()->filter_class,
+				'sensitive' 	=> (bool) $status->is_nsfw,
+				'like_count' 	=> $status->likes_count,
+				'share_count' 	=> $status->reblogs_count,
+				'user' => [
+					'username' 	=> $profile->username,
+					'url'		=> $profile->url(),
+				],
+				'visibility' 	=> $status->visibility ?? $status->scope
+			],
+			'hashtag' => [
+				'name' 			=> $hashtag->name,
+				'url'  			=> $hashtag->url(),
+			]
+		];
+	}
+}

+ 3 - 1
app/Util/Lexer/Extractor.php

@@ -264,7 +264,9 @@ class Extractor extends Regex
             if (preg_match(self::$patterns['end_hashtag_match'], $outer[0])) {
                 continue;
             }
-
+            if(mb_strlen($hashtag[0]) > 124) {
+                continue;
+            }
             $tags[] = [
                 'hashtag' => $hashtag[0],
                 'indices' => [$start_position, $end_position],

+ 15 - 0
app/Util/RateLimit/User.php

@@ -49,8 +49,23 @@ trait User {
 		return 500;
 	}
 
+	public function getMaxUserBansPerDayAttribute()
+	{
+		return 100;
+	}
+
 	public function getMaxInstanceBansPerDayAttribute()
 	{
 		return 100;
 	}
+
+	public function getMaxHashtagFollowsPerHourAttribute()
+	{
+		return 20;
+	}
+
+	public function getMaxHashtagFollowsPerDayAttribute()
+	{
+		return 100;
+	}
 }

+ 23 - 5
config/instance.php

@@ -1,15 +1,33 @@
 <?php
 
 return [
-	'email' => env('INSTANCE_CONTACT_EMAIL'),
+
+	'announcement' => [
+		'enabled' => env('INSTANCE_ANNOUNCEMENT_ENABLED', true),
+		'message' => env('INSTANCE_ANNOUNCEMENT_MESSAGE', 'Example announcement message.<br><span class="font-weight-normal">Something else here</span>')
+	],
 
 	'contact' => [
 		'enabled' => env('INSTANCE_CONTACT_FORM', false),
 		'max_per_day' => env('INSTANCE_CONTACT_MAX_PER_DAY', 1),
 	],
 
-	'announcement' => [
-		'enabled' => env('INSTANCE_ANNOUNCEMENT_ENABLED', true),
-		'message' => env('INSTANCE_ANNOUNCEMENT_MESSAGE', 'Example announcement message.<br><span class="font-weight-normal">Something else here</span>')
-	]
+	'discover' => [
+		'loops' => [
+			'enabled' => false
+		],
+		'tags' => [
+			'is_public' => env('INSTANCE_PUBLIC_HASHTAGS', false)
+		],
+	],
+	
+	'email' => env('INSTANCE_CONTACT_EMAIL'),
+
+	'timeline' => [
+		'local' => [
+			'is_public' => env('INSTANCE_PUBLIC_LOCAL_TIMELINE', false)
+		]
+	],
+
+
 ];

+ 35 - 0
database/migrations/2019_07_05_034644_create_hashtag_follows_table.php

@@ -0,0 +1,35 @@
+<?php
+
+use Illuminate\Support\Facades\Schema;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Database\Migrations\Migration;
+
+class CreateHashtagFollowsTable extends Migration
+{
+    /**
+     * Run the migrations.
+     *
+     * @return void
+     */
+    public function up()
+    {
+        Schema::create('hashtag_follows', function (Blueprint $table) {
+            $table->bigIncrements('id');
+            $table->bigInteger('user_id')->unsigned()->index();
+            $table->bigInteger('profile_id')->unsigned()->index();
+            $table->bigInteger('hashtag_id')->unsigned()->index();
+            $table->unique(['user_id', 'profile_id', 'hashtag_id']);
+            $table->timestamps();
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     *
+     * @return void
+     */
+    public function down()
+    {
+        Schema::dropIfExists('hashtag_follows');
+    }
+}

+ 32 - 0
database/migrations/2019_07_08_045824_add_status_visibility_to_status_hashtags_table.php

@@ -0,0 +1,32 @@
+<?php
+
+use Illuminate\Support\Facades\Schema;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Database\Migrations\Migration;
+
+class AddStatusVisibilityToStatusHashtagsTable extends Migration
+{
+    /**
+     * Run the migrations.
+     *
+     * @return void
+     */
+    public function up()
+    {
+        Schema::table('status_hashtags', function (Blueprint $table) {
+            $table->string('status_visibility')->nullable()->index()->after('profile_id');
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     *
+     * @return void
+     */
+    public function down()
+    {
+        Schema::table('status_hashtags', function (Blueprint $table) {
+            $table->dropColumn('status_visibility');
+        });
+    }
+}

二进制
public/js/direct.js


二进制
public/js/hashtag.js


二进制
public/js/loops.js


二进制
public/js/mode-dot.js


二进制
public/js/profile.js


二进制
public/js/quill.js


二进制
public/js/search.js


二进制
public/js/status.js


二进制
public/js/theme-monokai.js


二进制
public/js/timeline.js


二进制
public/js/vendor.js


二进制
public/mix-manifest.json


+ 187 - 0
resources/assets/js/components/Hashtag.vue

@@ -0,0 +1,187 @@
+<template>
+<div>
+	<div v-if="loaded" class="container">
+		<div class="profile-header row my-5">
+			<div class="col-12 col-md-3">
+				<div class="profile-avatar">
+					<div class="bg-pixelfed mb-3 d-flex align-items-center justify-content-center display-4 font-weight-bold text-white" style="width: 172px; height: 172px; border-radius: 100%">#</div>
+				</div>
+			</div>
+			<div class="col-12 col-md-9 d-flex align-items-center">
+				<div class="profile-details">
+					<div class="username-bar pb-2">
+						<p class="tag-header mb-0">#{{hashtag}}</p>
+						<p class="lead"><span class="font-weight-bold">{{tags.length ? hashtagCount : '0'}}</span> posts</p>
+						<p v-if="authenticated && tags.length" class="pt-3">
+							<button v-if="!following" type="button" class="btn btn-primary font-weight-bold py-1 px-5" @click="followHashtag">
+								Follow
+							</button>
+							<button v-else type="button" class="btn btn-outline-secondary font-weight-bold py-1 px-5" @click="unfollowHashtag">
+								Unfollow
+							</button>
+						</p>
+					</div>
+				</div>
+			</div>
+		</div>
+		<div v-if="tags.length" class="tag-timeline">
+			<p v-if="top.length" class="font-weight-bold text-muted mb-0">Top Posts</p>
+			<div class="row pb-5">
+				<div v-for="(tag, index) in top" class="col-4 p-0 p-sm-2 p-md-3 hashtag-post-square">
+					<a class="card info-overlay card-md-border-0" :href="tag.status.url">
+						<div :class="[tag.status.filter ? 'square ' + tag.status.filter : 'square']">
+							<div class="square-content" :style="'background-image: url('+tag.status.thumb+')'"></div>
+							<div class="info-overlay-text">
+								<h5 class="text-white m-auto font-weight-bold">
+									<span class="pr-4">
+										<span class="far fa-heart fa-lg pr-1"></span> {{tag.status.like_count}}
+									</span>
+									<span>
+										<span class="fas fa-retweet fa-lg pr-1"></span> {{tag.status.share_count}}
+									</span>
+								</h5>
+							</div>
+						</div>
+					</a>
+				</div>
+			</div>
+			<p class="font-weight-bold text-muted mb-0">Most Recent</p>
+			<div class="row">
+				<div v-for="(tag, index) in tags" class="col-4 p-0 p-sm-2 p-md-3 hashtag-post-square">
+					<a class="card info-overlay card-md-border-0" :href="tag.status.url">
+						<div :class="[tag.status.filter ? 'square ' + tag.status.filter : 'square']">
+							<div class="square-content" :style="'background-image: url('+tag.status.thumb+')'"></div>
+							<div class="info-overlay-text">
+								<h5 class="text-white m-auto font-weight-bold">
+									<span class="pr-4">
+										<span class="far fa-heart fa-lg pr-1"></span> {{tag.status.like_count}}
+									</span>
+									<span>
+										<span class="fas fa-retweet fa-lg pr-1"></span> {{tag.status.share_count}}
+									</span>
+								</h5>
+							</div>
+						</div>
+					</a>
+				</div>
+				<div v-if="tags.length && loaded" class="card card-body text-center shadow-none bg-transparent border-0">
+					<infinite-loading @infinite="infiniteLoader">
+						<div slot="no-results" class="font-weight-bold"></div>
+						<div slot="no-more" class="font-weight-bold"></div>
+					</infinite-loading>
+				</div>
+			</div>
+		</div>
+		<div v-else>
+			<p class="text-center lead font-weight-bold">No public posts found.</p>
+		</div>
+	</div>
+	<div v-else class="container text-center">
+		<div class="mt-5 spinner-border" role="status">
+			<span class="sr-only">Loading...</span>
+		</div>
+	</div>
+</div>
+</template>
+
+<style type="text/css" scoped>
+.tag-header {
+	font-size: 28px;
+	font-weight: 300;
+}
+</style>
+
+<script type="text/javascript">
+	export default {
+		props: [
+		'hashtag',
+		'hashtagCount'
+		],
+		data() {
+			return {
+				loaded: false,
+				page: 1,
+				authenticated: false,
+				following: false,
+				tags: [],
+				top: [],
+			}
+		},
+		beforeMount() {
+			this.authenticated = $('body').hasClass('loggedIn');
+			this.getResults();
+		},
+		methods: {
+			getResults() {
+				axios.get('/api/v2/discover/tag', {
+					params: {
+						hashtag: this.hashtag,
+						page: this.page
+					}
+				}).then(res => {
+					let data = res.data;
+					let tags = data.tags.filter(n => {
+						if(!n || n.length == 0) {
+							return false;
+						}
+						return true;
+					});
+					this.tags = tags;
+					//this.top = tags.slice(6, 9);
+					this.loaded = true;
+					this.following = data.follows;
+					this.page++;
+				});
+			},
+
+			infiniteLoader($state) {
+				if(this.page > (this.authenticated ? 19 : 3)) {
+					$state.complete();
+					return;
+				}
+				axios.get('/api/v2/discover/tag', {
+					params: {
+						hashtag: this.hashtag,
+						page: this.page,
+					}
+				}).then(res => {
+					let data = res.data;
+					if(data.tags.length) {
+						let tags = data.tags.filter(n => {
+							if(!n || n.length == 0) {
+								return false;
+							}
+							return true;
+						});
+						this.tags.push(...tags);
+						if(tags.length > 9) {
+							$state.complete();
+							return;
+						}
+						this.page++;
+						$state.loaded();
+					} else {
+						$state.complete();
+					}
+				});
+			},
+
+			followHashtag() {
+				axios.post('/api/local/discover/tag/subscribe', {
+					name: this.hashtag
+				}).then(res => {
+					this.following = true;
+				});
+			},
+
+			unfollowHashtag() {
+				axios.post('/api/local/discover/tag/subscribe', {
+					name: this.hashtag
+				}).then(res => {
+					this.following = false;
+				});
+			},	
+
+		}
+	}
+</script>

+ 9 - 6
resources/assets/js/components/PostComponent.vue

@@ -107,8 +107,8 @@
             <div class="d-flex flex-md-column flex-column-reverse h-100" style="overflow-y: auto;">
               <div class="card-body status-comments pb-5">
                 <div class="status-comment">
-                  <p :class="[status.content.length > 420 ? 'mb-1 read-more' : 'mb-1']" style="overflow: hidden;">
-                    <span class="font-weight-bold pr-1">{{statusUsername}}</span>
+                  <p :class="[status.content.length > 620 ? 'mb-1 read-more' : 'mb-1']" style="overflow: hidden;">
+                    <a class="font-weight-bold pr-1 text-dark text-decoration-none" :href="statusProfileUrl">{{statusUsername}}</a>
                     <span class="comment-text" :id="status.id + '-status-readmore'" v-html="status.content"></span>
                   </p>
 
@@ -124,10 +124,13 @@
                       <div class="comments">
                         <div v-for="(reply, index) in results" class="pb-3" :key="'tl' + reply.id + '_' + index">
                           <div v-if="reply.sensitive == true">
-                            <div class="card card-body shadow-none border border-left-blue py-3 px-1 text-center small">
-                              <p class="mb-0">This comment may contain sensitive material</p>
-                              <p class="font-weight-bold text-primary cursor-pointer mb-0" @click="reply.sensitive = false;">Show</p>
-                            </div>
+                            <span class="py-3">
+                              <a class="text-dark font-weight-bold mr-1" :href="reply.account.url" v-bind:title="reply.account.username">{{truncate(reply.account.username,15)}}</a>
+                              <span class="text-break">
+                                <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" style="overflow-y: hidden;">

+ 10 - 4
resources/assets/js/components/PostMenu.vue

@@ -6,8 +6,8 @@
 			</button>
 			<div class="dropdown-menu dropdown-menu-right">
 				<a class="dropdown-item font-weight-bold text-decoration-none" :href="status.url">Go to post</a>
-				<a class="dropdown-item font-weight-bold text-decoration-none" href="#">Share</a>
-				<a class="dropdown-item font-weight-bold text-decoration-none" href="#">Embed</a>
+				<!-- <a class="dropdown-item font-weight-bold text-decoration-none" href="#">Share</a>
+				<a class="dropdown-item font-weight-bold text-decoration-none" href="#">Embed</a> -->
 				<span v-if="statusOwner(status) == false">
 					<a class="dropdown-item font-weight-bold" :href="reportUrl(status)">Report</a>
 				</span>
@@ -54,8 +54,9 @@
 						<div class="modal-body">
 							<div class="list-group">
 								<a class="list-group-item font-weight-bold text-decoration-none" :href="status.url">Go to post</a>
-								<a class="list-group-item font-weight-bold text-decoration-none" :href="status.url">Share</a>
-								<a class="list-group-item font-weight-bold text-decoration-none" :href="status.url">Embed</a>
+								<!-- <a class="list-group-item font-weight-bold text-decoration-none" :href="status.url">Share</a>
+								<a class="list-group-item font-weight-bold text-decoration-none" :href="status.url">Embed</a> -->
+								<a class="list-group-item font-weight-bold text-decoration-none" href="#" @click="hidePost(status)">Hide</a>
 								<span v-if="statusOwner(status) == false">
 									<a class="list-group-item font-weight-bold text-decoration-none" :href="reportUrl(status)">Report</a>
 									<a class="list-group-item font-weight-bold text-decoration-none" v-on:click="muteProfile(status)" href="#">Mute Profile</a>
@@ -157,6 +158,11 @@
 				$('#mt_pid_'+this.status.id).modal('hide');
 			},
 
+			hidePost(status) {
+				status.sensitive = true;
+				$('#mt_pid_'+status.id).modal('hide');
+			},
+
 			moderatePost(status, action, $event) {
 				let username = status.account.username;
 				switch(action) {

+ 6 - 8
resources/assets/js/components/SearchResults.vue

@@ -67,15 +67,13 @@
 
 			<div v-if="filters.statuses && results.statuses" class="row mb-4">
 				<p class="col-12 font-weight-bold text-muted">Statuses</p>
-				<a v-for="(status, index) in results.statuses" class="col-12 col-md-4 mb-3" style="text-decoration: none;" :href="status.url">
-					<div class="card">
-						<img class="card-img-top img-fluid" :src="status.thumb">
-						<div class="card-body text-center ">
-							<p class="mb-0 small text-truncate font-weight-bold text-muted" v-html="status.value">
-							</p>
+				<div v-for="(status, index) in results.statuses" class="col-4 p-0 p-sm-2 p-md-3 hashtag-post-square">
+					<a class="card info-overlay card-md-border-0" :href="status.url">
+						<div :class="[status.filter ? 'square ' + status.filter : 'square']">
+							<div class="square-content" :style="'background-image: url('+status.thumb+')'"></div>
 						</div>
-					</div>
-				</a>
+					</a>
+				</div>
 			</div>
 
 			<div v-if="!results.hashtags && !results.profiles && !results.statuses">

+ 56 - 1
resources/assets/js/components/Timeline.vue

@@ -39,6 +39,33 @@
 							</div>
 						</div>
 					</div>
+
+					<div v-if="index == 4 && showHashtagPosts && hashtagPosts.length" class="card mb-sm-4 status-card card-md-rounded-0">
+						<div class="card-header d-flex align-items-center justify-content-between bg-white border-0 pb-0">
+							<span></span>
+							<h6 class="text-muted font-weight-bold mb-0"><a :href="'/discover/tags/'+hashtagPostsName+'?src=tr'">#{{hashtagPostsName}}</a></h6>
+							<span class="cursor-pointer text-muted" v-on:click="showHashtagPosts = false"><i class="fas fa-times"></i></span>
+						</div>
+						<div class="card-body row mx-0">
+							<div v-for="(tag, index) in hashtagPosts" class="col-4 p-0 p-sm-2 p-md-3 hashtag-post-square">
+								<a class="card info-overlay card-md-border-0" :href="tag.status.url">
+									<div :class="[tag.status.filter ? 'square ' + tag.status.filter : 'square']">
+										<div class="square-content" :style="'background-image: url('+tag.status.thumb+')'"></div>
+										<div class="info-overlay-text">
+											<h5 class="text-white m-auto font-weight-bold">
+												<span class="pr-4">
+													<span class="far fa-heart fa-lg pr-1"></span> {{tag.status.like_count}}
+												</span>
+												<span>
+													<span class="fas fa-retweet fa-lg pr-1"></span> {{tag.status.share_count}}
+												</span>
+											</h5>
+										</div>
+									</div>
+								</a>
+							</div>
+						</div>
+					</div>
 					<div class="card mb-sm-4 status-card card-md-rounded-0">
 						<div v-if="!modes.distractionFree" class="card-header d-inline-flex align-items-center bg-white">
 							<img v-bind:src="status.account.avatar" width="32px" height="32px" style="border-radius: 32px;">
@@ -439,7 +466,10 @@
 				showReadMore: true,
 				replyStatus: {},
 				replyText: '',
-				emoji: ['😀','🤣','😃','😄','😆','😉','😊','😋','😘','😗','😙','😚','🤗','🤩','🤔','🤨','😐','😑','😶','🙄','😏','😣','😥','😮','🤐','😪','😫','😴','😌','😛','😜','😝','🤤','😒','😓','😔','😕','🙃','🤑','😲','🙁','😖','😞','😟','😤','😭','😦','😧','😨','😩','🤯','😬','😰','😱','😳','🤪','😵','😡','😠','🤬','😷','🤒','🤕','🤢','🤮','🤧','😇','🤠','🤡','🤥','🤫','🤭','🧐','🤓','😈','👿','👹','👺','💀','👻','👽','🤖','💩','😺','😸','😹','😻','😼','😽','🙀','😿','😾','🤲','👐','🤝','👍','👎','👊','✊','🤛','🤜','🤞','✌️','🤟','🤘','👈','👉','👆','👇','☝️','✋','🤚','🖐','🖖','👋','🤙','💪','🖕','✍️','🙏','💍','💄','💋','👄','👅','👂','👃','👣','👁','👀','🧠','🗣','👤','👥']
+				emoji: ['😀','🤣','😃','😄','😆','😉','😊','😋','😘','😗','😙','😚','🤗','🤩','🤔','🤨','😐','😑','😶','🙄','😏','😣','😥','😮','🤐','😪','😫','😴','😌','😛','😜','😝','🤤','😒','😓','😔','😕','🙃','🤑','😲','🙁','😖','😞','😟','😤','😭','😦','😧','😨','😩','🤯','😬','😰','😱','😳','🤪','😵','😡','😠','🤬','😷','🤒','🤕','🤢','🤮','🤧','😇','🤠','🤡','🤥','🤫','🤭','🧐','🤓','😈','👿','👹','👺','💀','👻','👽','🤖','💩','😺','😸','😹','😻','😼','😽','🙀','😿','😾','🤲','👐','🤝','👍','👎','👊','✊','🤛','🤜','🤞','✌️','🤟','🤘','👈','👉','👆','👇','☝️','✋','🤚','🖐','🖖','👋','🤙','💪','🖕','✍️','🙏','💍','💄','💋','👄','👅','👂','👃','👣','👁','👀','🧠','🗣','👤','👥'],
+				showHashtagPosts: false,
+				hashtagPosts: [],
+				hashtagPostsName: '',
 			}
 		},
 
@@ -542,6 +572,7 @@
 					this.max_id = Math.min(...ids);
 					$('.timeline .pagination').removeClass('d-none');
 					this.loading = false;
+					this.fetchHashtagPosts();
 				}).catch(err => {
 				});
 			},
@@ -1104,6 +1135,30 @@
 						}
 					}, 10000);
 				});
+			},
+
+			fetchHashtagPosts() {
+
+				axios.get('/api/local/discover/tag/list')
+				.then(res => {
+					let tags = res.data;
+					if(tags.length == 0) {
+						return;
+					}
+					let hashtag = tags[0];
+					this.hashtagPostsName = hashtag;
+					axios.get('/api/v2/discover/tag', {
+						params: {
+							hashtag: hashtag
+						}
+					}).then(res => {
+						if(res.data.tags.length) {
+							this.showHashtagPosts = true;
+							this.hashtagPosts = res.data.tags.splice(0,3);
+						}
+					})
+				})
+
 			}
 		}
 	}

+ 4 - 0
resources/assets/js/hashtag.js

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

+ 3 - 45
resources/views/discover/tags/show.blade.php

@@ -1,53 +1,11 @@
 @extends('layouts.app')
 
 @section('content')
-
-<div class="container">
-  
-  <div class="profile-header row my-5">
-    <div class="col-12 col-md-3">
-      <div class="profile-avatar">
-        <img class="rounded-circle card" src="{{$posts->last()->thumb()}}" width="172px" height="172px">
-      </div>
-    </div>
-    <div class="col-12 col-md-9 d-flex align-items-center">
-      <div class="profile-details">
-        <div class="username-bar pb-2  d-flex align-items-center">
-          <span class="h1">{{$tag->name}}</span>
-        </div>
-      </div>
-    </div>
-  </div>
-  <div class="tag-timeline">
-    <div class="row">
-      @foreach($posts as $status)
-      <div class="col-4 p-0 p-sm-2 p-md-3">
-        <a class="card info-overlay card-md-border-0" href="{{$status->url()}}">
-          <div class="square {{$status->firstMedia()->filter_class}}">
-            <div class="square-content" style="background-image: url('{{$status->thumb()}}')"></div>
-            <div class="info-overlay-text">
-              <h5 class="text-white m-auto font-weight-bold">
-                <span class="pr-4">
-                <span class="far fa-heart fa-lg pr-1"></span> {{$status->likes_count}}
-                </span>
-                <span>
-                <span class="far fa-comment fa-lg pr-1"></span> {{$status->comments_count}}
-                </span>
-              </h5>
-            </div>
-          </div>
-        </a>
-      </div>
-      @endforeach
-    </div>
-  </div>
-</div>
-
+<hashtag-component hashtag="{{$tag->name}}" hashtag-count="{{$tagCount}}"></hashtag-component>
 @endsection
 
 @push('scripts')
+<script type="text/javascript" src="{{ mix('js/hashtag.js') }}"></script>
 <script type="text/javascript" src="{{ mix('js/compose.js') }}"></script>
-<script type="text/javascript">
-$(document).ready(function(){new Vue({el: '#content'});});
-</script>
+<script type="text/javascript">$(document).ready(function(){new Vue({el: '#content'});});</script>
 @endpush

+ 10 - 0
resources/views/errors/400.blade.php

@@ -0,0 +1,10 @@
+@extends('layouts.app')
+
+@section('content')
+<div class="container">
+  <div class="error-page py-5 my-5 text-center">
+    <h3 class="font-weight-bold">Sorry, this page isn't available.</h3>
+    <p class="lead">The link you followed may be broken, or the page may have been removed. <a href="/">Go back to Pixelfed.</a></p>
+  </div>
+</div>
+@endsection

+ 10 - 0
resources/views/errors/403.blade.php

@@ -0,0 +1,10 @@
+@extends('layouts.app')
+
+@section('content')
+<div class="container">
+  <div class="error-page py-5 my-5 text-center">
+    <h3 class="font-weight-bold">Sorry, this page isn't available.</h3>
+    <p class="lead">The link you followed may be broken, or the page may have been removed. <a href="/">Go back to Pixelfed.</a></p>
+  </div>
+</div>
+@endsection

+ 3 - 7
resources/views/errors/404.blade.php

@@ -2,13 +2,9 @@
 
 @section('content')
 <div class="container">
-  <div class="error-page py-5 my-5">
-    <div class="card mx-5">
-      <div class="card-body p-5 text-center">
-        <h1>Page Not Found</h1>
-        <img src="/img/fred1.gif" class="img-fluid">
-      </div>
-    </div>
+  <div class="error-page py-5 my-5 text-center">
+    <h3 class="font-weight-bold">Sorry, this page isn't available.</h3>
+    <p class="lead">The link you followed may be broken, or the page may have been removed. <a href="/">Go back to Pixelfed.</a></p>
   </div>
 </div>
 @endsection

+ 3 - 8
resources/views/errors/500.blade.php

@@ -2,14 +2,9 @@
 
 @section('content')
 <div class="container">
-  <div class="error-page py-5 my-5">
-    <div class="card mx-5">
-      <div class="card-body p-5 text-center">
-        <h1>Whoops! Something went wrong.</h1>
-        <p class="mb-0 text-muted lead">If you keep seeing this message, please contact an admin.</p>
-        <img src="/img/fred1.gif" class="img-fluid">
-      </div>
-    </div>
+  <div class="error-page py-5 my-5 text-center">
+    <h3 class="font-weight-bold">Something went wrong</h3>
+    <p class="lead">We cannot process your request at this time, please try again later. <a href="/">Go back to Pixelfed.</a></p>
   </div>
 </div>
 @endsection

+ 3 - 8
resources/views/errors/503.blade.php

@@ -2,14 +2,9 @@
 
 @section('content')
 <div class="container">
-  <div class="error-page py-5 my-5">
-    <div class="card mx-5">
-      <div class="card-body p-5 text-center">
-        <h1>Service Unavailable</h1>
-        <p class="mb-0 text-muted lead">Our services are in maintenance mode, please try again later.</p>
-        <img src="/img/fred1.gif" class="img-fluid">
-      </div>
-    </div>
+  <div class="error-page py-5 my-5 text-center">
+    <h3 class="font-weight-bold">Service Unavailable</h3>
+    <p class="lead">Our service is in maintenance mode, please try again later. <a href="/">Go back to Pixelfed.</a></p>
   </div>
 </div>
 @endsection

+ 37 - 0
resources/views/site/help/hashtags.blade.php

@@ -16,6 +16,43 @@
       <li class="">You can add up to 30 hashtags to your post or comment.</li>
     </ul>
   </div>
+  <div class="py-4">
+  <p>
+    <a class="text-dark font-weight-bold" data-toggle="collapse" href="#collapse0" role="button" aria-expanded="false" aria-controls="collapse0">
+      <i class="fas fa-chevron-down mr-2"></i>
+      How do I use a hashtag on Pixelfed?
+    </a>
+    <div class="collapse" id="collapse0">
+      <div>
+        <ul>
+          <li>You can add hashtags to post captions, if the post is public the hashtag will be discoverable.</li>
+          <li>You can follow hashtags on Pixelfed to stay connected with interests you care about.</li>
+        </ul>
+      </div>
+    </div>
+  </p>
+  <p>
+    <a class="text-dark font-weight-bold" data-toggle="collapse" href="#collapse1" role="button" aria-expanded="false" aria-controls="collapse1">
+      <i class="fas fa-chevron-down mr-2"></i>
+      How do I follow a hashtag?
+    </a>
+    <div class="collapse" id="collapse1">
+      <div>
+        <p>You can follow hashtags on Pixelfed to stay connected with interests you care about.</p>
+        <p class="mb-0">To follow a hashtag:</p>
+        <ol>
+          <li>Tap any hashtag (example: #art) you see on Pixelfed.</li>
+          <li>Tap <span class="font-weight-bold">Follow</span>. Once you follow a hashtag, you'll see its photos and videos appear in feed.</li>
+        </ol>
+        <p>To unfollow a hashtag, tap the hashtag and then tap Unfollow to confirm.</p>
+        <p class="mb-0">
+          You can follow up to 20 hashtags per hour or 100 per day.
+        </p>
+      </div>
+    </div>
+  </p>
+  </div>
+  <hr>
   <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">Hashtag Tips</div>
     <div class="card-body bg-white p-3">

+ 0 - 104
resources/views/status/show/album.blade.php

@@ -1,104 +0,0 @@
-@extends('layouts.app',['title' => $user->username . " posted a photo: " . $status->likes_count . " likes, " . $status->comments_count . " comments" ])
-
-@section('content')
-
-<div class="container px-0 mt-md-4">
-  <div class="card card-md-rounded-0 status-container orientation-{{$status->firstMedia()->orientation ?? 'unknown'}}">
-    <div class="row mx-0">
-    <div class="d-flex d-md-none align-items-center justify-content-between card-header bg-white w-100">
-      <a href="{{$user->url()}}" class="d-flex align-items-center status-username text-truncate" data-toggle="tooltip" data-placement="bottom" title="{{$user->username}}">
-        <div class="status-avatar mr-2">
-          <img src="{{$user->avatarUrl()}}" width="24px" height="24px" style="border-radius:12px;">
-        </div>
-        <div class="username">
-          <span class="username-link font-weight-bold text-dark">{{$user->username}}</span>
-        </div>
-      </a>
-      <div class="float-right">
-        <div class="dropdown">
-          <button class="btn btn-link text-dark no-caret dropdown-toggle" type="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false" title="Post options">
-          <span class="fas fa-ellipsis-v text-muted"></span>
-          </button>
-          <div class="dropdown-menu dropdown-menu-right" aria-labelledby="dropdownMenuButton">
-            <a class="dropdown-item font-weight-bold" href="{{$status->reportUrl()}}">Report</a>
-            {{-- <a class="dropdown-item" href="#">Embed</a> --}}
-          @if(Auth::check())
-          @if(Auth::user()->profile->id !== $status->profile->id)
-          <div class="dropdown-divider"></div>
-          <form method="post" action="/i/mute">
-            @csrf
-            <input type="hidden" name="type" value="user">
-            <input type="hidden" name="item" value="{{$status->profile_id}}">
-            <button type="submit" class="dropdown-item btn btn-link font-weight-bold">Mute this user</button>
-          </form>
-          <form method="post" action="/i/block">
-            @csrf
-            <input type="hidden" name="type" value="user">
-            <input type="hidden" name="item" value="{{$status->profile_id}}">
-            <button type="submit" class="dropdown-item btn btn-link font-weight-bold">Block this user</button>
-          </form>
-          @endif
-            @if(Auth::user()->profile->id === $status->profile->id || Auth::user()->is_admin == true)
-            <div class="dropdown-divider"></div>
-            {{-- <a class="dropdown-item" href="{{$status->editUrl()}}">Edit</a> --}}
-            <form method="post" action="/i/delete">
-              @csrf
-              <input type="hidden" name="type" value="post">
-              <input type="hidden" name="item" value="{{$status->id}}">
-              <button type="submit" class="dropdown-item btn btn-link font-weight-bold">Delete</button>
-            </form>
-            @endif
-          @endif
-
-          </div>
-        </div>
-      </div>
-     </div>
-      <div class="col-12 col-md-8 status-photo px-0">
-        @if($status->is_nsfw)
-        <details class="details-animated">
-          <summary>
-            <p class="mb-0 lead font-weight-bold">CW / NSFW / Hidden Media</p>
-            <p class="font-weight-light">(click to show)</p>
-          </summary>
-          @endif
-            <div id="photoCarousel" class="carousel slide carousel-fade" data-ride="carousel">
-              <ol class="carousel-indicators">
-                @for($i = 0; $i < $status->media_count; $i++)
-                <li data-target="#photoCarousel" data-slide-to="{{$i}}" class="{{$i == 0 ? 'active' : ''}}"></li>
-                @endfor
-              </ol>
-              <div class="carousel-inner">
-                @foreach($status->media()->orderBy('order')->get() as $media)
-                <div class="carousel-item {{$loop->iteration == 1 ? 'active' : ''}}">
-                  <figure class="{{$media->filter_class}}">
-                    <img class="d-block w-100" src="{{$media->url()}}" title="{{$media->caption}}" data-toggle="tooltip" data-placement="bottom">
-                  </figure>
-                </div>
-                @endforeach
-              </div>
-              <a class="carousel-control-prev" href="#photoCarousel" role="button" data-slide="prev">
-                <span class="carousel-control-prev-icon" aria-hidden="true"></span>
-                <span class="sr-only">Previous</span>
-              </a>
-              <a class="carousel-control-next" href="#photoCarousel" role="button" data-slide="next">
-                <span class="carousel-control-next-icon" aria-hidden="true"></span>
-                <span class="sr-only">Next</span>
-              </a>
-            </div>
-        @if($status->is_nsfw)
-          </details>
-        @endif
-      </div>
-      @include('status.show.sidebar')
-    </div>
-  </div>
-</div>
-
-@endsection
-
-@push('meta')
-  <meta property="og:description" content="{{ $status->caption }}">
-  <meta property="og:image" content="{{$status->mediaUrl()}}">
-  <link href='{{$status->url()}}' rel='alternate' type='application/activity+json'>
-@endpush

+ 0 - 85
resources/views/status/show/photo.blade.php

@@ -1,85 +0,0 @@
-@extends('layouts.app',['title' => $user->username . " posted a photo: " . $status->likes_count . " likes, " . $status->comments_count . " comments" ])
-
-@section('content')
-
-<div class="container px-0 mt-md-4">
-  <div class="card card-md-rounded-0 status-container orientation-{{$status->firstMedia()->orientation ?? 'unknown'}}">
-    <div class="row mx-0">
-    <div class="d-flex d-md-none align-items-center justify-content-between card-header bg-white w-100">
-      <a href="{{$user->url()}}" class="d-flex align-items-center status-username text-truncate" data-toggle="tooltip" data-placement="bottom" title="{{$user->username}}">
-        <div class="status-avatar mr-2">
-          <img src="{{$user->avatarUrl()}}" width="24px" height="24px" style="border-radius:12px;">
-        </div>
-        <div class="username">
-          <span class="username-link font-weight-bold text-dark">{{$user->username}}</span>
-        </div>
-      </a>
-      <div class="float-right">
-        <div class="dropdown">
-          <button class="btn btn-link text-dark no-caret dropdown-toggle" type="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false" title="Post options">
-          <span class="fas fa-ellipsis-v text-muted"></span>
-          </button>
-          <div class="dropdown-menu dropdown-menu-right" aria-labelledby="dropdownMenuButton">
-            <a class="dropdown-item font-weight-bold" href="{{$status->reportUrl()}}">Report</a>
-            {{-- <a class="dropdown-item" href="#">Embed</a> --}}
-          @if(Auth::check())
-          @if(Auth::user()->profile->id !== $status->profile->id)
-          <div class="dropdown-divider"></div>
-          <form method="post" action="/i/mute">
-            @csrf
-            <input type="hidden" name="type" value="user">
-            <input type="hidden" name="item" value="{{$status->profile_id}}">
-            <button type="submit" class="dropdown-item btn btn-link font-weight-bold">Mute this user</button>
-          </form>
-          <form method="post" action="/i/block">
-            @csrf
-            <input type="hidden" name="type" value="user">
-            <input type="hidden" name="item" value="{{$status->profile_id}}">
-            <button type="submit" class="dropdown-item btn btn-link font-weight-bold">Block this user</button>
-          </form>
-          @endif
-            @if(Auth::user()->profile->id === $status->profile->id || Auth::user()->is_admin == true)
-            <div class="dropdown-divider"></div>
-            {{-- <a class="dropdown-item" href="{{$status->editUrl()}}">Edit</a> --}}
-            <form method="post" action="/i/delete">
-              @csrf
-              <input type="hidden" name="type" value="post">
-              <input type="hidden" name="item" value="{{$status->id}}">
-              <button type="submit" class="dropdown-item btn btn-link font-weight-bold">Delete</button>
-            </form>
-            @endif
-          @endif
-
-          </div>
-        </div>
-      </div>
-     </div>
-      <div class="col-12 col-md-8 status-photo px-0">
-        @if($status->is_nsfw && $status->media_count == 1)
-        <details class="details-animated">
-          <summary>
-            <p class="mb-0 lead font-weight-bold">CW / NSFW / Hidden Media</p>
-            <p class="font-weight-light">(click to show)</p>
-          </summary>
-          <a class="max-hide-overflow {{$status->firstMedia()->filter_class}}" href="{{$status->url()}}">
-            <img class="card-img-top" src="{{$status->mediaUrl()}}" title="{{$status->firstMedia()->caption}}" data-toggle="tooltip" data-tooltip-placement="bottom">
-          </a>
-        </details>
-        @elseif(!$status->is_nsfw && $status->media_count == 1)
-        <div class="{{$status->firstMedia()->filter_class}}">
-          <img src="{{$status->mediaUrl()}}" width="100%" title="{{$status->firstMedia()->caption}}" data-toggle="tooltip" data-placement="bottom">
-        </div>
-        @endif
-      </div>
-      @include('status.show.sidebar')
-    </div>
-  </div>
-</div>
-
-@endsection
-
-@push('meta')
-  <meta property="og:description" content="{{ $status->caption }}">
-  <meta property="og:image" content="{{$status->mediaUrl()}}">
-  <link href='{{$status->url()}}' rel='alternate' type='application/activity+json'>
-@endpush

+ 0 - 117
resources/views/status/show/sidebar.blade.php

@@ -1,117 +0,0 @@
-<div class="col-12 col-md-4 px-0 d-flex flex-column border-left border-md-left-0">
-  <div class="d-md-flex d-none align-items-center justify-content-between card-header py-3 bg-white">
-    <a href="{{$user->url()}}" class="d-flex align-items-center status-username text-truncate" data-toggle="tooltip" data-placement="bottom" title="{{$user->username}}">
-      <div class="status-avatar mr-2">
-        <img src="{{$user->avatarUrl()}}" width="24px" height="24px" style="border-radius:12px;">
-      </div>
-      <div class="username">
-        <span class="username-link font-weight-bold text-dark">{{$user->username}}</span>
-      </div>
-    </a>
-      <div class="float-right">
-        <div class="dropdown">
-          <button class="btn btn-link text-dark no-caret dropdown-toggle" type="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false" title="Post options">
-          <span class="fas fa-ellipsis-v text-muted"></span>
-          </button>
-          <div class="dropdown-menu dropdown-menu-right" aria-labelledby="dropdownMenuButton">
-            <a class="dropdown-item font-weight-bold" href="{{$status->reportUrl()}}">Report</a>
-            {{-- <a class="dropdown-item" href="#">Embed</a> --}}
-          @if(Auth::check())
-          @if(Auth::user()->profile->id !== $status->profile->id)
-          <div class="dropdown-divider"></div>
-          <form method="post" action="/i/mute">
-            @csrf
-            <input type="hidden" name="type" value="user">
-            <input type="hidden" name="item" value="{{$status->profile_id}}">
-            <button type="submit" class="dropdown-item btn btn-link font-weight-bold">Mute this user</button>
-          </form>
-          <form method="post" action="/i/block">
-            @csrf
-            <input type="hidden" name="type" value="user">
-            <input type="hidden" name="item" value="{{$status->profile_id}}">
-            <button type="submit" class="dropdown-item btn btn-link font-weight-bold">Block this user</button>
-          </form>
-          @endif
-            @if(Auth::user()->profile->id === $status->profile->id || Auth::user()->is_admin == true)
-            <div class="dropdown-divider"></div>
-            {{-- <a class="dropdown-item" href="{{$status->editUrl()}}">Edit</a> --}}
-            <form method="post" action="/i/delete">
-              @csrf
-              <input type="hidden" name="type" value="post">
-              <input type="hidden" name="item" value="{{$status->id}}">
-              <button type="submit" class="dropdown-item btn btn-link font-weight-bold">Delete</button>
-            </form>
-            @endif
-          @endif
-
-          </div>
-        </div>
-      </div>
-  </div>
-  <div class="d-flex flex-md-column flex-column-reverse h-100">
-    <div class="card-body status-comments">
-      <div class="status-comment">
-        <p class="mb-1">
-          <span class="font-weight-bold pr-1">{{$status->profile->username}}</span>
-          <span class="comment-text" v-pre>{!! $status->rendered ?? e($status->caption) !!}</span>
-        </p>
-        <p class="mb-1"><a href="{{$status->url()}}/c" class="text-muted">View all comments</a></p>
-        <div class="comments">
-          @foreach($replies as $item)
-          <p class="mb-1">
-            <span class="font-weight-bold pr-1"><bdi><a class="text-dark" href="{{$item->profile->url()}}">{{ str_limit($item->profile->username, 15)}}</a></bdi></span>
-            <span class="comment-text" v-pre>{!! $item->rendered ?? e($item->caption) !!} <a href="{{$item->url()}}" class="text-dark small font-weight-bold float-right pl-2">{{$item->created_at->diffForHumans(null, true, true ,true)}}</a></span>
-          </p>
-          @endforeach
-        </div>
-      </div>
-    </div>
-    <div class="card-body flex-grow-0 py-1">
-      <div class="reactions my-1">
-        @if(Auth::check())
-        <form class="d-inline-flex pr-3" method="post" action="/i/like" style="display: inline;" data-id="{{$status->id}}" data-action="like">
-          @csrf
-          <input type="hidden" name="item" value="{{$status->id}}">
-          <button class="btn btn-link text-dark p-0 border-0" type="submit" title="Like!">
-            <h3 class="m-0 {{$status->liked() ? 'fas fa-heart text-danger':'far fa-heart text-dark'}}"></h3>
-          </button>
-        </form>
-        <h3 class="far fa-comment pr-3 m-0" title="Comment"></h3>
-        <form class="d-inline-flex share-form pr-3" method="post" action="/i/share" style="display: inline;" data-id="{{$status->id}}" data-action="share" data-count="{{$status->shares_count}}">
-          @csrf
-          <input type="hidden" name="item" value="{{$status->id}}">
-          <button class="btn btn-link text-dark p-0" type="submit" title="Share">
-            <h3 class="m-0 {{$status->shared() ? 'fas fa-share-square text-primary':'far fa-share-square '}}"></h3>
-          </button>
-        </form>
-
-        @endif
-        <span class="float-right">
-          <form class="d-inline-flex " method="post" action="/i/bookmark" style="display: inline;" data-id="{{$status->id}}" data-action="bookmark">
-            @csrf
-            <input type="hidden" name="item" value="{{$status->id}}">
-            <button class="btn btn-link text-dark p-0 border-0" type="submit" title="Save">
-              <h3 class="m-0 {{$status->bookmarked() ? 'fas fa-bookmark text-warning':'far fa-bookmark'}}"></h3>
-            </button>
-          </form>
-        </span>
-      </div>
-      <div class="likes font-weight-bold mb-0">
-        <span class="like-count" data-count="{{$status->likes_count}}">{{$status->likes_count}}</span> likes
-      </div>
-      <div class="timestamp">
-        <a href="{{$status->url()}}" class="small text-muted">
-          {{$status->created_at->format('F j, Y')}}
-        </a>
-      </div>
-    </div>
-  </div>
-  <div class="card-footer bg-white sticky-md-bottom">
-    <form class="comment-form" method="post" action="/i/comment" data-id="{{$status->id}}" data-truncate="false">
-      @csrf
-      <input type="hidden" name="item" value="{{$status->id}}">
-
-      <input class="form-control" name="comment" placeholder="Add a comment…" autocomplete="off">
-    </form>
-  </div>
-</div>

+ 0 - 50
resources/views/status/show/video.blade.php

@@ -1,50 +0,0 @@
-@extends('layouts.app',['title' => $user->username . " posted a photo: " . $status->likes_count . " likes, " . $status->comments_count . " comments" ])
-
-@section('content')
-
-<div class="container px-0 mt-md-4">
-  <div class="card card-md-rounded-0 status-container orientation-video">
-    <div class="row mx-0">
-    <div class="d-flex d-md-none align-items-center justify-content-between card-header bg-white w-100">
-      <a href="{{$user->url()}}" class="d-flex align-items-center status-username text-truncate" data-toggle="tooltip" data-placement="bottom" title="{{$user->username}}">
-        <div class="status-avatar mr-2">
-          <img src="{{$user->avatarUrl()}}" width="24px" height="24px" style="border-radius:12px;">
-        </div>
-        <div class="username">
-          <span class="username-link font-weight-bold text-dark">{{$user->username}}</span>
-        </div>
-      </a>
-     </div>
-      <div class="col-12 col-md-8 status-photo px-0">
-        @if($status->is_nsfw && $status->media_count == 1)
-        <details class="details-animated">
-          <summary>
-            <p class="mb-0 lead font-weight-bold">CW / NSFW / Hidden Media</p>
-            <p class="font-weight-light">(click to show)</p>
-          </summary>
-          <div class="embed-responsive embed-responsive-16by9">
-            <video class="embed-responsive-item" controls="">
-              <source src="{{$status->mediaUrl()}}" type="video/mp4">
-            </video>
-          </div>
-        </details>
-        @elseif(!$status->is_nsfw && $status->media_count == 1)
-        <div class="embed-responsive embed-responsive-16by9">
-          <video class="embed-responsive-item" controls="">
-            <source src="{{$status->mediaUrl()}}" type="video/mp4">
-          </video>
-        </div>
-        @endif
-      </div>
-      @include('status.show.sidebar')
-    </div>
-  </div>
-</div>
-
-@endsection
-
-@push('meta')
-  <meta property="og:description" content="{{ $status->caption }}">
-  <meta property="og:image" content="{{$status->mediaUrl()}}">
-  <link href='{{$status->url()}}' rel='alternate' type='application/activity+json'>
-@endpush

+ 1 - 1
resources/views/status/template.blade.php

@@ -91,7 +91,7 @@
       <span class="like-count">{{$item->likes_count}}</span> likes
     </div>
     <div class="caption">
-      <p class="mb-1">
+      <p class="mb-1 read-more" style="overflow: hidden;">
         <span class="username font-weight-bold">
           <bdi><a class="text-dark" href="{{$item->profile->url()}}" v-pre>{{$item->profile->username}}</a></bdi>
         </span>

+ 0 - 29
resources/views/status/timeline/album.blade.php

@@ -1,29 +0,0 @@
-@if($status->is_nsfw)
-
-@else
-  <div id="photo-carousel-wrapper-{{$status->id}}" class="carousel slide carousel-fade" data-ride="carousel">
-    <ol class="carousel-indicators">
-      @for($i = 0; $i < $status->media_count; $i++)
-      <li data-target="#photo-carousel-wrapper-{{$status->id}}" data-slide-to="{{$i}}" class="{{$i == 0 ? 'active' : ''}}"></li>
-      @endfor
-    </ol>
-    <div class="carousel-inner">
-      @foreach($status->media()->orderBy('order')->get() as $media)
-      <div class="carousel-item {{$loop->iteration == 1 ? 'active' : ''}}">
-        <figure class="{{$media->filter_class}}">
-          <span class="float-right mr-3 badge badge-dark" style="position:fixed;top:8px;right:0;margin-bottom:-20px;">{{$loop->iteration}}/{{$loop->count}}</span>
-          <img class="d-block w-100" src="{{$media->url()}}" alt="{{$status->caption}}">
-        </figure>
-      </div>
-      @endforeach
-    </div>
-    <a class="carousel-control-prev" href="#photo-carousel-wrapper-{{$status->id}}" role="button" data-slide="prev">
-      <span class="carousel-control-prev-icon" aria-hidden="true"></span>
-      <span class="sr-only">Previous</span>
-    </a>
-    <a class="carousel-control-next" href="#photo-carousel-wrapper-{{$status->id}}" role="button" data-slide="next">
-      <span class="carousel-control-next-icon" aria-hidden="true"></span>
-      <span class="sr-only">Next</span>
-    </a>
-  </div>
-@endif

+ 0 - 15
resources/views/status/timeline/photo.blade.php

@@ -1,15 +0,0 @@
-@if($status->is_nsfw)
-<details class="details-animated">
-  <summary>
-    <p class="mb-0 lead font-weight-bold">CW / NSFW / Hidden Media</p>
-    <p class="font-weight-light">(click to show)</p>
-  </summary>
-  <a class="max-hide-overflow {{$status->firstMedia()->filter_class}}" href="{{$status->url()}}">
-    <img class="card-img-top" src="{{$status->mediaUrl()}}">
-  </a>
-</details>
-@else
-<div class="{{$status->firstMedia()->filter_class}}">
-  <img src="{{$status->mediaUrl()}}" width="100%">
-</div>
-@endif

+ 0 - 57
resources/views/status/timeline/video-album.blade.php

@@ -1,57 +0,0 @@
-@if($status->is_nsfw)
-  <div id="video-carousel-wrapper-{{$status->id}}" class="carousel slide carousel-fade" data-ride="false" data-interval="false">
-    <ol class="carousel-indicators">
-      @for($i = 0; $i < $status->media_count; $i++)
-      <li data-target="#video-carousel-wrapper-{{$status->id}}" data-slide-to="{{$i}}" class="{{$i == 0 ? 'active' : ''}}"></li>
-      @endfor
-    </ol>
-    <div class="carousel-inner">
-      @foreach($status->media()->orderBy('order')->get() as $media)
-      <div class="carousel-item {{$loop->iteration == 1 ? 'active' : ''}}">
-        <span class="float-right mr-3 badge badge-dark" style="position:fixed;top:8px;right:0;margin-bottom:-20px;z-index: 999;">{{$loop->iteration}}/{{$loop->count}}</span>
-        <div class="embed-responsive embed-responsive-4by3">
-          <video class=" embed-responsive-item" controls loop>
-            <source src="{{$media->url()}}" type="{{$media->mime}}">
-          </video>
-        </div>
-      </div>
-      @endforeach
-    </div>
-    <a class="carousel-control-prev" href="#video-carousel-wrapper-{{$status->id}}" role="button" data-slide="prev">
-      <span class="carousel-control-prev-icon" aria-hidden="true"></span>
-      <span class="sr-only">Previous</span>
-    </a>
-    <a class="carousel-control-next" href="#video-carousel-wrapper-{{$status->id}}" role="button" data-slide="next">
-      <span class="carousel-control-next-icon" aria-hidden="true"></span>
-      <span class="sr-only">Next</span>
-    </a>
-  </div>
-@else
-  <div id="video-carousel-wrapper-{{$status->id}}" class="carousel slide carousel-fade" data-ride="false" data-interval="false">
-    <ol class="carousel-indicators">
-      @for($i = 0; $i < $status->media_count; $i++)
-      <li data-target="#video-carousel-wrapper-{{$status->id}}" data-slide-to="{{$i}}" class="{{$i == 0 ? 'active' : ''}}"></li>
-      @endfor
-    </ol>
-    <div class="carousel-inner">
-      @foreach($status->media()->orderBy('order')->get() as $media)
-      <div class="carousel-item {{$loop->iteration == 1 ? 'active' : ''}}">
-        <span class="float-right mr-3 badge badge-dark" style="position:fixed;top:8px;right:0;margin-bottom:-20px;z-index: 999;">{{$loop->iteration}}/{{$loop->count}}</span>
-        <div class="embed-responsive embed-responsive-4by3">
-          <video class=" embed-responsive-item" controls loop>
-            <source src="{{$media->url()}}" type="{{$media->mime}}">
-          </video>
-        </div>
-      </div>
-      @endforeach
-    </div>
-    <a class="carousel-control-prev" href="#video-carousel-wrapper-{{$status->id}}" role="button" data-slide="prev">
-      <span class="carousel-control-prev-icon" aria-hidden="true"></span>
-      <span class="sr-only">Previous</span>
-    </a>
-    <a class="carousel-control-next" href="#video-carousel-wrapper-{{$status->id}}" role="button" data-slide="next">
-      <span class="carousel-control-next-icon" aria-hidden="true"></span>
-      <span class="sr-only">Next</span>
-    </a>
-  </div>
-@endif

+ 0 - 19
resources/views/status/timeline/video.blade.php

@@ -1,19 +0,0 @@
-@if($status->is_nsfw)
-<details class="details-animated">
-  <summary>
-    <p class="mb-0 lead font-weight-bold">CW / NSFW / Hidden Media</p>
-    <p class="font-weight-light">(click to show)</p>
-  </summary>
-	<div class="embed-responsive embed-responsive-16by9">
-		<video class="video" preload="none" controls loop>
-			<source src="{{$status->firstMedia()->url()}}" type="{{$status->firstMedia()->mime}}">
-		</video>
-	</div>
- </details>
-@else
-<div class="embed-responsive embed-responsive-16by9">
-	<video class="video" preload="none" controls loop>
-		<source src="{{$status->firstMedia()->url()}}" type="{{$status->firstMedia()->mime}}">
-	</video>
-</div>
-@endif

+ 0 - 82
resources/views/timeline/partial/new-form.blade.php

@@ -1,82 +0,0 @@
-    <div class="card card-md-rounded-0 metro-classic-compose">
-      <div class="card-header bg-white font-weight-bold d-inline-flex justify-content-between">
-        <div>{{__('Create New Post')}}</div>
-      </div>
-      <div class="card-body" id="statusForm">
-
-        <form method="post" action="{{route('timeline.personal')}}" enctype="multipart/form-data">
-          @csrf
-          <input type="hidden" name="filter_name" value="">
-          <input type="hidden" name="filter_class" value="">
-          <div class="form-group">
-            <div class="custom-file">
-              <input type="file" class="custom-file-input" id="fileInput" name="photo[]" accept="{{config('pixelfed.media_types')}}" multiple="">
-              <label class="custom-file-label" for="fileInput">Upload Image(s)</label>
-            </div>
-            <small class="form-text text-muted">
-              Max Size: @maxFileSize(). Supported formats: jpeg, png, gif, bmp. Limited to {{config('pixelfed.max_album_length')}} photos per post.
-            </small>
-          </div>
-          <div class="form-group">
-            <textarea class="form-control" name="caption" placeholder="Add optional caption here" autocomplete="off" data-limit="{{config('pixelfed.max_caption_length')}}" rows="1"></textarea>
-            <p class="form-text text-muted small text-right">
-              <span class="caption-counter">0</span>
-              <span>/</span>
-              <span>{{config('pixelfed.max_caption_length')}}</span>
-            </p>
-          </div>
-          <div class="form-group">
-            <button class="btn btn-outline-primary btn-sm px-3 py-1 font-weight-bold" type="button" data-toggle="collapse" data-target="#collapsePreview" aria-expanded="false" aria-controls="collapsePreview">
-              Options &nbsp; <i class="fas fa-chevron-down"></i>
-            </button>
-            <div class="collapse" id="collapsePreview">
-              <div class="form-group pt-3">
-                <label class="font-weight-bold text-muted small">Visibility</label>
-                <div class="switch switch-sm">
-                  <select class="form-control" name="visibility">
-                    @if(Auth::user()->profile->is_private)
-                      <option value="public">Public</option>
-                      <option value="unlisted">Unlisted (hidden from public timelines)</option>
-                      <option value="private" selected="">Followers Only</option>
-                    @else
-                      <option value="public" selected="">Public</option>
-                      <option value="unlisted">Unlisted (hidden from public timelines)</option>
-                      <option value="private">Followers Only</option>
-                    @endif
-                  </select>
-                </div>
-                <small class="form-text text-muted">
-                  Set the visibility of this post.
-                </small>
-              </div>
-              <div class="form-group">
-                <label class="font-weight-bold text-muted small">CW/NSFW</label>
-                <div class="switch switch-sm">
-                  <input type="checkbox" class="switch" id="cw-switch" name="cw">
-                  <label for="cw-switch" class="small font-weight-bold">(Default off)</label>
-                </div>
-                <small class="form-text text-muted">
-                  Please mark all NSFW and controversial content, as per our content policy.
-                </small>
-              </div>
-              <div class="form-group d-none form-preview">
-                <label class="font-weight-bold text-muted small">Photo Preview</label>
-                <figure class="filterContainer">
-                    <img class="filterPreview img-fluid">
-                </figure>
-                <small class="form-text text-muted font-weight-bold">
-                  No filter selected.
-                </small>
-              </div>
-              <div class="form-group d-none form-filters">
-                <label for="filterSelectDropdown" class="font-weight-bold text-muted small">Select Filter</label>
-                <select class="form-control" id="filterSelectDropdown">
-                  <option value="none" selected="">No Filter</option>
-                </select>
-              </div>  
-            </div>
-          </div>  
-          <button type="submit" class="btn btn-outline-primary btn-block font-weight-bold">Create Post</button>
-        </form>
-      </div>  
-    </div>

+ 0 - 68
resources/views/timeline/personal.blade.php

@@ -1,68 +0,0 @@
-@extends('layouts.app')
-
-@push('scripts')
-<script type="text/javascript" src="{{mix('js/timeline.js')}}"></script>
-@endpush 
-
-@section('content')
-
-<div class="container p-0">
-  <div class="col-md-10 col-lg-8 mx-auto pt-4 px-0">
-    @if ($errors->any())
-      <div class="alert alert-danger">
-          <ul>
-              @foreach ($errors->all() as $error)
-                  <li>{{ $error }}</li>
-              @endforeach
-          </ul>
-      </div>
-    @endif
-
-    @include('timeline.partial.new-form')
-    
-    <div class="timeline-feed my-5" data-timeline="personal">
-    @foreach($timeline as $item)
-
-      @include('status.template')
-
-    @endforeach
-    @if($timeline->count() == 0)
-    <div class="card card-md-rounded-0">
-      <div class="card-body py-5">
-        <div class="d-flex justify-content-center align-items-center">
-          <p class="lead font-weight-bold mb-0">{{ __('timeline.emptyPersonalTimeline') }}</p>
-        </div>
-      </div>
-    </div>
-    @endif
-    </div>
-
-    <div class="page-load-status" style="display: none;">
-      <div class="infinite-scroll-request" style="display: none;">
-        <div class="fixed-top loading-page"></div>
-      </div>
-      <div class="infinite-scroll-last" style="display: none;">
-        <h3>No more content</h3>
-        <p class="text-muted">
-          Maybe you could try 
-          <a href="{{route('discover')}}">discovering</a>
-          more people you can follow.
-        </p>
-      </div>
-      <div class="infinite-scroll-error" style="display: none;">
-        <h3>Whoops, an error</h3>
-        <p class="text-muted">
-          Try reloading the page
-        </p>
-      </div>
-    </div>
-
-    <div class="d-flex justify-content-center">
-      {{$timeline->links()}}
-    </div>
-
-  </div>
-</div>
-
-
-@endsection

+ 0 - 59
resources/views/timeline/public.blade.php

@@ -1,59 +0,0 @@
-@extends('layouts.app')
-
-@push('scripts')
-<script type="text/javascript" src="{{mix('js/timeline.js')}}"></script>
-@endpush 
-
-@section('content')
-
-<div class="container px-0">
-  <div class="col-md-10 col-lg-8 mx-auto pt-4 px-0">
-    @if ($errors->any())
-      <div class="alert alert-danger">
-          <ul>
-              @foreach ($errors->all() as $error)
-                  <li>{{ $error }}</li>
-              @endforeach
-          </ul>
-      </div>
-    @endif
-    
-    @include('timeline.partial.new-form')
-
-    <div class="timeline-feed my-5" data-timeline="public">
-    @foreach($timeline as $item)
-
-      @include('status.template')
-
-    @endforeach
-    </div>
-
-    <div class="page-load-status" style="display: none;">
-      <div class="infinite-scroll-request" style="display: none;">
-        <div class="fixed-top loading-page"></div>
-      </div>
-      <div class="infinite-scroll-last" style="display: none;">
-        <h3>No more content</h3>
-        <p class="text-muted">
-          Maybe you could try 
-          <a href="{{route('discover')}}">discovering</a>
-          more people you can follow.
-        </p>
-      </div>
-      <div class="infinite-scroll-error" style="display: none;">
-        <h3>Whoops, an error</h3>
-        <p class="text-muted">
-          Try reloading the page
-        </p>
-      </div>
-    </div>
-
-    <div class="d-flex justify-content-center">
-      {{$timeline->links()}}
-    </div>
-
-  </div>
-</div>
-
-
-@endsection

+ 0 - 132
resources/views/timeline/template.blade.php

@@ -1,132 +0,0 @@
-@extends('layouts.app')
-
-@section('content')
-
-<noscript>
-  <div class="container">
-    <div class="card border-left-blue mt-5">
-      <div class="card-body">
-        <p class="mb-0 font-weight-bold">Javascript is required for an optimized experience, please enable it to use this site.</p>
-        <p class="mb-0 font-weight-bold text-muted">(We are working on a lite version that does not require javascript)</p>
-      </div>
-    </div>
-  </div>
-</noscript>
-
-<div class="container d-none timeline-container">
-  <div class="row">
-    <div class="col-md-8 col-lg-8 pt-4 px-0 my-3">
-        @if (session('status'))
-            <div class="alert alert-success">
-                <span class="font-weight-bold">{!! session('status') !!}</span>
-            </div>
-        @endif
-        @if (session('error'))
-            <div class="alert alert-danger">
-                <span class="font-weight-bold">{!! session('error') !!}</span>
-            </div>
-        @endif
-
-      
-      <div class="timeline-feed" data-timeline="{{$type}}">
-
-      @foreach($timeline as $item)
-        @if(is_null($item->in_reply_to_id))
-        @include('status.template')
-        @endif
-      @endforeach
-      
-      @if($timeline->count() == 0)
-      <div class="card card-md-rounded-0">
-        <div class="card-body py-5">
-          <div class="d-flex justify-content-center align-items-center">
-            <p class="lead font-weight-bold mb-0">{{ __('timeline.emptyPersonalTimeline') }}</p>
-          </div>
-        </div>
-      </div>
-      @endif
-      </div>
-
-      <div class="page-load-status" style="display: none;">
-        <div class="infinite-scroll-request" style="display: none;">
-          <div class="fixed-top loading-page"></div>
-        </div>
-        <div class="infinite-scroll-last" style="display: none;">
-          <h3>No more content</h3>
-          <p class="text-muted">
-            Maybe you could try 
-            <a href="{{route('discover')}}">discovering</a>
-            more people you can follow.
-          </p>
-        </div>
-        <div class="infinite-scroll-error" style="display: none;">
-          <h3>Whoops, an error</h3>
-          <p class="text-muted">
-            Try reloading the page
-          </p>
-        </div>
-      </div>
-
-      <div class="d-flex justify-content-center">
-        {{$timeline->links()}}
-      </div>
-
-    </div>
-    <div class="col-md-4 col-lg-4 pt-4 my-3">
-        <div class="media d-flex align-items-center mb-4">
-          <a href="{{Auth::user()->profile->url()}}">
-            <img class="mr-3 rounded-circle box-shadow" src="{{Auth::user()->profile->avatarUrl()}}" alt="{{Auth::user()->username}}'s avatar" width="64px">
-          </a>
-          <div class="media-body">
-            <p class="mb-0 px-0 font-weight-bold"><a href="{{Auth::user()->profile->url()}}">&commat;{{Auth::user()->username}}</a></p>
-            <p class="mb-0 text-muted text-truncate pb-0">{{Auth::user()->name}}</p>
-          </div>
-        </div>
-
-        <div class="mb-4">
-          <ul class="nav nav-pills flex-column timeline-sidenav" style="max-width: 240px;">
-            <li class="nav-item">
-              <a class="nav-link font-weight-bold" href="/" data-type="personal">
-                <i class="far fa-user pr-1"></i> My Timeline
-              </a>
-            </li>
-            <li class="nav-item">
-              <a class="nav-link font-weight-bold" href="/timeline/public" data-type="local">
-                <i class="fas fa-bars pr-1"></i> Local Timeline
-              </a>
-            </li>
-            <li class="nav-item" data-toggle="tooltip" data-placement="bottom" title="The network timeline is not available yet.">
-              <span class="nav-link font-weight-bold">
-                <i class="fas fa-globe pr-1"></i> Network Timeline
-              </span>
-            </li>
-          </ul>
-        </div>
-
-        {{-- <follow-suggestions></follow-suggestions> --}}
-
-        <footer>
-          <div class="container pb-5">
-              <p class="mb-0 text-uppercase font-weight-bold text-muted small">
-                <a href="{{route('site.about')}}" class="text-dark pr-2">About Us</a>
-                <a href="{{route('site.help')}}" class="text-dark pr-2">Support</a>
-                <a href="{{route('site.opensource')}}" class="text-dark pr-2">Open Source</a>
-                <a href="{{route('site.language')}}" class="text-dark pr-2">Language</a>
-                <a href="{{route('site.terms')}}" class="text-dark pr-2">Terms</a>
-                <a href="{{route('site.privacy')}}" class="text-dark pr-2">Privacy</a>
-                <a href="{{route('site.platform')}}" class="text-dark pr-2">API</a>
-              </p>
-              <p class="mb-0 text-uppercase font-weight-bold text-muted small">
-                <a href="http://pixelfed.org" class="text-muted" rel="noopener" title="version {{config('pixelfed.version')}}" data-toggle="tooltip">Powered by Pixelfed</a>
-              </p>
-          </div>
-        </footer>
-    </div>
-  </div>
-</div>
-
-@endsection
-
-@push('scripts')
-<script type="text/javascript" src="{{mix('js/timeline.js')}}"></script>
-@endpush 

+ 3 - 0
routes/web.php

@@ -106,11 +106,14 @@ Route::domain(config('pixelfed.domain.app'))->middleware(['validemail', 'twofact
             Route::post('status/compose', 'InternalApiController@composePost')->middleware('throttle:maxPostsPerHour,60')->middleware('throttle:maxPostsPerDay,1440');
             Route::get('loops', 'DiscoverController@loopsApi');
             Route::post('loops/watch', 'DiscoverController@loopWatch');
+            Route::get('discover/tag', 'DiscoverController@getHashtags');
         });
         Route::group(['prefix' => 'local'], function () {
             Route::get('i/follow-suggestions', 'ApiController@followSuggestions');
             Route::post('status/compose', 'InternalApiController@compose')->middleware('throttle:maxPostsPerHour,60')->middleware('throttle:maxPostsPerDay,1440');
             Route::get('exp/rec', 'ApiController@userRecommendations');
+            Route::post('discover/tag/subscribe', 'HashtagFollowController@store')->middleware('throttle:maxHashtagFollowsPerHour,60')->middleware('throttle:maxHashtagFollowsPerDay,1440');;
+            Route::get('discover/tag/list', 'HashtagFollowController@getTags');
         });
     });
 

+ 1 - 0
webpack.mix.js

@@ -30,6 +30,7 @@ mix.js('resources/assets/js/app.js', 'public/js')
 .js('resources/assets/js/lib/ace/theme-monokai.js', 'public/js')
 // .js('resources/assets/js/embed.js', 'public')
 // .js('resources/assets/js/direct.js', 'public/js')
+.js('resources/assets/js/hashtag.js', 'public/js')
 .extract([
 	'lodash',
 	'popper.js',