Browse Source

Merge pull request #4164 from pixelfed/staging

Staging
daniel 2 years ago
parent
commit
2c5281522a
50 changed files with 1018 additions and 64 deletions
  1. 10 0
      CHANGELOG.md
  2. 95 64
      app/Http/Controllers/Api/ApiV1Controller.php
  3. 8 0
      app/Profile.php
  4. 11 0
      package-lock.json
  5. 1 0
      package.json
  6. 0 0
      public/js/about.bundle.a0398e8c630f7036.js
  7. BIN
      public/js/admin.js
  8. BIN
      public/js/app.js
  9. 0 0
      public/js/changelog.bundle.9ac9432f209bde4e.js
  10. BIN
      public/js/components.js
  11. 0 0
      public/js/compose.chunk.f335df0cd85ea00b.js
  12. 0 0
      public/js/contact.bundle.3c0833e75a8155f2.js
  13. 0 0
      public/js/daci.chunk.232f6f724c527858.js
  14. BIN
      public/js/discover.chunk.3ef6d6fe45dbe91b.js
  15. BIN
      public/js/discover.chunk.b33cd1cc42853828.js
  16. 0 0
      public/js/discover~findfriends.chunk.e3a7e0813bc9e3ec.js
  17. BIN
      public/js/discover~hashtag.bundle.2f72cbbe9aa1b317.js
  18. BIN
      public/js/discover~hashtag.bundle.7c5f7f5c21a1d88c.js
  19. 0 0
      public/js/discover~memories.chunk.487c14a0180fbf85.js
  20. BIN
      public/js/discover~myhashtags.chunk.075cc9fe49783f65.js
  21. BIN
      public/js/discover~myhashtags.chunk.8cbed746da3c3307.js
  22. 0 0
      public/js/discover~serverfeed.chunk.c37e8a7a49d49297.js
  23. 0 0
      public/js/discover~settings.chunk.ddc15c2d10514bf9.js
  24. BIN
      public/js/dms.chunk.37131c41fc288259.js
  25. BIN
      public/js/dms.chunk.ffe36114c17441be.js
  26. 0 0
      public/js/dms~message.chunk.848e25098152c821.js
  27. 0 0
      public/js/error404.bundle.6f43a867cb75b343.js
  28. 0 0
      public/js/help.bundle.4157e6be875557da.js
  29. BIN
      public/js/home.chunk.294faaa69171455b.js
  30. BIN
      public/js/home.chunk.c5608f771be873ca.js
  31. 0 0
      public/js/i18n.bundle.c5c5f4ddf5b18688.js
  32. BIN
      public/js/installer.js
  33. 0 0
      public/js/kb.bundle.e5709245effd8e20.js
  34. BIN
      public/js/manifest.js
  35. 0 0
      public/js/notifications.chunk.a310984a7cefe091.js
  36. BIN
      public/js/post.chunk.02abe63d47f8d51e.js
  37. BIN
      public/js/post.chunk.dffb139831cf2ae9.js
  38. BIN
      public/js/profile.chunk.99838eb369862e91.js
  39. BIN
      public/js/profile.chunk.cdad3298b78ff083.js
  40. 0 0
      public/js/profile~followers.bundle.f18a24d3924b651a.js
  41. 0 0
      public/js/profile~following.bundle.8a269b2c4fd0722c.js
  42. BIN
      public/js/search.js
  43. BIN
      public/js/spa.js
  44. 0 0
      public/js/static~privacy.bundle.c647cbc1674cfea8.js
  45. 0 0
      public/js/static~tos.bundle.fc0a2c6ff6297f24.js
  46. BIN
      public/js/vendor.js
  47. BIN
      public/mix-manifest.json
  48. 239 0
      resources/assets/components/partials/post/LikeModal.vue
  49. 239 0
      resources/assets/components/partials/post/ShareModal.vue
  50. 415 0
      resources/assets/components/sections/Notifications.vue

+ 10 - 0
CHANGELOG.md

@@ -94,6 +94,16 @@
 - Update reply view, fix visibility filtering ([d419af4b](https://github.com/pixelfed/pixelfed/commit/d419af4b))
 - Update AP helpers, ingest attachments in replies ([c504e643](https://github.com/pixelfed/pixelfed/commit/c504e643))
 - Update Media model, use cloud filesystem url if enabled instead of cdn_url to easily update S3 media urls ([e6bc57d7](https://github.com/pixelfed/pixelfed/commit/e6bc57d7))
+- Update ap helpers, fix unset media name bug ([083f506b](https://github.com/pixelfed/pixelfed/commit/083f506b))
+- Update MediaStorageService, fix improper path ([964c62da](https://github.com/pixelfed/pixelfed/commit/964c62da))
+- Update ApiV1Controller, fix account statuses and bookmark pagination ([9f66d6b6](https://github.com/pixelfed/pixelfed/commit/9f66d6b6))
+- Update SearchApiV2Service, improve account search results ([f6a588f9](https://github.com/pixelfed/pixelfed/commit/f6a588f9))
+- Update profile model, improve avatarUrl fallback ([620ee826](https://github.com/pixelfed/pixelfed/commit/620ee826))
+- Update ApiV1Controller, use cursor pagination for favourited_by and reblogged_by endpoints ([e1c7e701](https://github.com/pixelfed/pixelfed/commit/e1c7e701))
+- Update ApiV1Controller, fix favourited_by and reblogged_by follows attribute ([1a130f3e](https://github.com/pixelfed/pixelfed/commit/1a130f3e))
+- Update notifications component, improve UX with exponential retry and loading state ([937e6d07](https://github.com/pixelfed/pixelfed/commit/937e6d07))
+- Update likeModal and shareModal components, use new pagination logic and re-add Follow/Unfollow buttons ([b565ead6](https://github.com/pixelfed/pixelfed/commit/b565ead6))
+- Update profileFeed component, fix pagination ([7cf41628](https://github.com/pixelfed/pixelfed/commit/7cf41628))
 -  ([](https://github.com/pixelfed/pixelfed/commit/))
 
 ## [v0.11.4 (2022-10-04)](https://github.com/pixelfed/pixelfed/compare/v0.11.3...v0.11.4)

+ 95 - 64
app/Http/Controllers/Api/ApiV1Controller.php

@@ -2471,15 +2471,21 @@ class ApiV1Controller extends Controller
 		abort_if(!$request->user(), 403);
 
 		$this->validate($request, [
-			'page'  => 'nullable|integer|min:1|max:40',
 			'limit' => 'nullable|integer|min:1|max:100'
 		]);
 
-		$limit = $request->input('limit') ?? 40;
+		$limit = $request->input('limit') ?? 10;
 		$user = $request->user();
 		$status = Status::findOrFail($id);
+		$author = intval($status->profile_id) === intval($user->profile_id) || $user->is_admin;
 
-		if(intval($status->profile_id) !== intval($user->profile_id)) {
+		abort_if(
+			!$status->type ||
+			!in_array($status->type, ['photo','photo:album', 'photo:video:album', 'reply', 'text', 'video', 'video:album']),
+			404,
+		);
+
+		if(!$author) {
 			if($status->scope == 'private') {
 				abort_if(!FollowerService::follows($user->profile_id, $status->profile_id), 403);
 			} else {
@@ -2487,35 +2493,46 @@ class ApiV1Controller extends Controller
 			}
 		}
 
-		$page = $request->input('page', 1);
-		$start = $page == 1 ? 0 : (($page * $limit) - $limit);
-		$end = $start + $limit - 1;
+		$res = Status::where('reblog_of_id', $status->id)
+		->orderByDesc('id')
+		->cursorPaginate($limit)
+		->withQueryString();
 
-		$ids = ReblogService::getPostReblogs($id, $start, $end);
-		if(empty($ids)) {
-			return [];
+		if(!$res) {
+			return $this->json([]);
 		}
 
-		$res = collect($ids)
-			->map(function($id) {
-				$status = StatusService::get($id);
-				if($status) {
-					return AccountService::get($status['account']['id']);
+		$headers = [];
+		if($author && $res->hasPages()) {
+			$links = '';
+			if($res->previousPageUrl()) {
+				$links = '<' . $res->previousPageUrl() .'>; rel="prev"';
+			}
+
+			if($res->nextPageUrl()) {
+				if(!empty($links)) {
+					$links .= ', ';
 				}
-				return;
-			})
-			->filter(function($account) {
-				return $account && isset($account['id']);
-			})
-			->values();
+				$links .= '<' . $res->nextPageUrl() .'>; rel="next"';
+			}
 
-		$url = $request->url();
-		$page = $request->input('page', 1);
-		$next = $page < 40 ? $page + 1 : 40;
-		$prev = $page > 1 ? $page - 1 : 1;
-		$links = '<'.$url.'?page='.$next.'&limit='.$limit.'>; rel="next", <'.$url.'?page='.$prev.'&limit='.$limit.'>; rel="prev"';
+			$headers = ['Link' => $links];
+		}
+
+		$res = $res->map(function($status) use($user) {
+			$account = AccountService::getMastodon($status->profile_id, true);
+			if(!$account) {
+				return false;
+			}
+			$account['follows'] = $status->profile_id == $user->profile_id ? null : FollowerService::follows($user->profile_id, $status->profile_id);
+			return $account;
+		})
+		->filter(function($account) {
+			return $account && isset($account['id']);
+		})
+		->values();
 
-		return $this->json($res, 200, ['Link' => $links]);
+		return $this->json($res, 200, $headers);
 	}
 
 	/**
@@ -2530,58 +2547,72 @@ class ApiV1Controller extends Controller
 		abort_if(!$request->user(), 403);
 
 		$this->validate($request, [
-			'page'  => 'nullable|integer|min:1|max:40',
 			'limit' => 'nullable|integer|min:1|max:100'
 		]);
 
-		$page = $request->input('page', 1);
-		$limit = $request->input('limit') ?? 40;
+		$limit = $request->input('limit') ?? 10;
 		$user = $request->user();
 		$status = Status::findOrFail($id);
-		$offset = $page == 1 ? 0 : ($page * $limit - $limit);
-		if($offset > 100) {
-			if($user->profile_id != $status->profile_id) {
-				return [];
-			}
-		}
+		$author = intval($status->profile_id) === intval($user->profile_id) || $user->is_admin;
 
-		if(intval($status->profile_id) !== intval($user->profile_id)) {
+		abort_if(
+			!$status->type ||
+			!in_array($status->type, ['photo','photo:album', 'photo:video:album', 'reply', 'text', 'video', 'video:album']),
+			404,
+		);
+
+		if(!$author) {
 			if($status->scope == 'private') {
-				abort_if(!$status->profile->followedBy($user->profile), 403);
+				abort_if(!FollowerService::follows($user->profile_id, $status->profile_id), 403);
 			} else {
 				abort_if(!in_array($status->scope, ['public','unlisted']), 403);
 			}
+
+			if($request->has('cursor')) {
+				return $this->json([]);
+			}
 		}
 
-		$res = DB::table('likes')
-			->select('likes.id', 'likes.profile_id', 'likes.status_id', 'followers.created_at')
-			->leftJoin('followers', function($join) use($user, $status) {
-				return $join->on('likes.profile_id', '=', 'followers.following_id')
-					->where('followers.profile_id', $user->profile_id)
-					->where('likes.status_id', $status->id);
-			})
-			->whereStatusId($status->id)
-			->orderByDesc('followers.created_at')
-			->offset($offset)
-			->limit($limit)
-			->get()
-			->map(function($like) {
-				$account = AccountService::getMastodon($like->profile_id, true);
-				$account['follows'] = isset($like->created_at);
-				return $account;
-			})
-			->filter(function($account) use($user) {
-				return $account && isset($account['id']);
-			})
-			->values();
+		$res = Like::where('status_id', $status->id)
+		->orderByDesc('id')
+		->cursorPaginate($limit)
+		->withQueryString();
 
-		$url = $request->url();
-		$page = $request->input('page', 1);
-		$next = $page < 40 ? $page + 1 : 40;
-		$prev = $page > 1 ? $page - 1 : 1;
-		$links = '<'.$url.'?page='.$next.'&limit='.$limit.'>; rel="next", <'.$url.'?page='.$prev.'&limit='.$limit.'>; rel="prev"';
+		if(!$res) {
+			return $this->json([]);
+		}
+
+		$headers = [];
+		if($author && $res->hasPages()) {
+			$links = '';
+			if($res->previousPageUrl()) {
+				$links = '<' . $res->previousPageUrl() .'>; rel="prev"';
+			}
+
+			if($res->nextPageUrl()) {
+				if(!empty($links)) {
+					$links .= ', ';
+				}
+				$links .= '<' . $res->nextPageUrl() .'>; rel="next"';
+			}
+
+			$headers = ['Link' => $links];
+		}
+
+		$res = $res->map(function($like) use($user) {
+			$account = AccountService::getMastodon($like->profile_id, true);
+			if(!$account) {
+				return false;
+			}
+			$account['follows'] = $like->profile_id == $user->profile_id ? null : FollowerService::follows($user->profile_id, $like->profile_id);
+			return $account;
+		})
+		->filter(function($account) use($user) {
+			return $account && isset($account['id']);
+		})
+		->values();
 
-		return $this->json($res, 200, ['Link' => $links]);
+		return $this->json($res, 200, $headers);
 	}
 
 	/**

+ 8 - 0
app/Profile.php

@@ -160,6 +160,10 @@ class Profile extends Model
 		$url = Cache::remember('avatar:'.$this->id, 1209600, function () {
 			$avatar = $this->avatar;
 
+			if(!$avatar) {
+				return url('/storage/avatars/default.jpg');
+			}
+
 			if($avatar->cdn_url) {
 				if(substr($avatar->cdn_url, 0, 8) === 'https://') {
 					return $avatar->cdn_url;
@@ -170,6 +174,10 @@ class Profile extends Model
 
 			$path = $avatar->media_path;
 
+			if(!$path) {
+				return url('/storage/avatars/default.jpg');
+			}
+
 			if(substr($path, 0, 6) !== 'public') {
 				return url('/storage/avatars/default.jpg');
 			}

+ 11 - 0
package-lock.json

@@ -8,6 +8,7 @@
 			"dependencies": {
 				"@fancyapps/fancybox": "^3.5.7",
 				"@trevoreyre/autocomplete-vue": "^2.2.0",
+				"@web3-storage/parse-link-header": "^3.1.0",
 				"animate.css": "^4.1.0",
 				"bigpicture": "^2.6.2",
 				"blurhash": "^1.1.3",
@@ -2171,6 +2172,11 @@
 			"integrity": "sha512-ncTzHV7NvsQZkYe1DW7cbDLm0YpzHmZF5r/iyP3ZnQtMiJ+pjzisCiMNI+Sj+xQF5pXhSHxSB3uDbsBTzY/c2A==",
 			"dev": true
 		},
+		"node_modules/@web3-storage/parse-link-header": {
+			"version": "3.1.0",
+			"resolved": "https://registry.npmjs.org/@web3-storage/parse-link-header/-/parse-link-header-3.1.0.tgz",
+			"integrity": "sha512-K1undnK70vLLauqdE8bq/l98isTF2FDhcP0UPpXVSjkSWe3xhAn5eRXk5jfA1E5ycNm84Ws/rQFUD7ue11nciw=="
+		},
 		"node_modules/@webassemblyjs/ast": {
 			"version": "1.11.1",
 			"resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.11.1.tgz",
@@ -10970,6 +10976,11 @@
 				}
 			}
 		},
+		"@web3-storage/parse-link-header": {
+			"version": "3.1.0",
+			"resolved": "https://registry.npmjs.org/@web3-storage/parse-link-header/-/parse-link-header-3.1.0.tgz",
+			"integrity": "sha512-K1undnK70vLLauqdE8bq/l98isTF2FDhcP0UPpXVSjkSWe3xhAn5eRXk5jfA1E5ycNm84Ws/rQFUD7ue11nciw=="
+		},
 		"@webassemblyjs/ast": {
 			"version": "1.11.1",
 			"resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.11.1.tgz",

+ 1 - 0
package.json

@@ -35,6 +35,7 @@
 	"dependencies": {
 		"@fancyapps/fancybox": "^3.5.7",
 		"@trevoreyre/autocomplete-vue": "^2.2.0",
+		"@web3-storage/parse-link-header": "^3.1.0",
 		"animate.css": "^4.1.0",
 		"bigpicture": "^2.6.2",
 		"blurhash": "^1.1.3",

+ 0 - 0
public/js/about.bundle.b4e0fef2bfd282de.js → public/js/about.bundle.a0398e8c630f7036.js


BIN
public/js/admin.js


BIN
public/js/app.js


+ 0 - 0
public/js/changelog.bundle.1346cd4a2aea418e.js → public/js/changelog.bundle.9ac9432f209bde4e.js


BIN
public/js/components.js


+ 0 - 0
public/js/compose.chunk.88ef87270cef7295.js → public/js/compose.chunk.f335df0cd85ea00b.js


+ 0 - 0
public/js/contact.bundle.de2898072b8b7c67.js → public/js/contact.bundle.3c0833e75a8155f2.js


+ 0 - 0
public/js/daci.chunk.fc282ba63a43593c.js → public/js/daci.chunk.232f6f724c527858.js


BIN
public/js/discover.chunk.3ef6d6fe45dbe91b.js


BIN
public/js/discover.chunk.b33cd1cc42853828.js


+ 0 - 0
public/js/discover~findfriends.chunk.53fdd18a929791f0.js → public/js/discover~findfriends.chunk.e3a7e0813bc9e3ec.js


BIN
public/js/discover~hashtag.bundle.2f72cbbe9aa1b317.js


BIN
public/js/discover~hashtag.bundle.7c5f7f5c21a1d88c.js


+ 0 - 0
public/js/discover~memories.chunk.1cb17840dc8aea5f.js → public/js/discover~memories.chunk.487c14a0180fbf85.js


BIN
public/js/discover~myhashtags.chunk.075cc9fe49783f65.js


BIN
public/js/discover~myhashtags.chunk.8cbed746da3c3307.js


+ 0 - 0
public/js/discover~serverfeed.chunk.a24b7b8c20612b1b.js → public/js/discover~serverfeed.chunk.c37e8a7a49d49297.js


+ 0 - 0
public/js/discover~settings.chunk.9e385ba7c7242192.js → public/js/discover~settings.chunk.ddc15c2d10514bf9.js


BIN
public/js/dms.chunk.37131c41fc288259.js


BIN
public/js/dms.chunk.ffe36114c17441be.js


+ 0 - 0
public/js/dms~message.chunk.3396750e3f3e370d.js → public/js/dms~message.chunk.848e25098152c821.js


+ 0 - 0
public/js/error404.bundle.2358054dc4c0a62b.js → public/js/error404.bundle.6f43a867cb75b343.js


+ 0 - 0
public/js/help.bundle.37981267c6a91fdd.js → public/js/help.bundle.4157e6be875557da.js


BIN
public/js/home.chunk.294faaa69171455b.js


BIN
public/js/home.chunk.c5608f771be873ca.js


+ 0 - 0
public/js/i18n.bundle.233c3a2e6c08c397.js → public/js/i18n.bundle.c5c5f4ddf5b18688.js


BIN
public/js/installer.js


+ 0 - 0
public/js/kb.bundle.ffb0bccb31e767a2.js → public/js/kb.bundle.e5709245effd8e20.js


BIN
public/js/manifest.js


+ 0 - 0
public/js/notifications.chunk.e36cd010dbca7065.js → public/js/notifications.chunk.a310984a7cefe091.js


BIN
public/js/post.chunk.02abe63d47f8d51e.js


BIN
public/js/post.chunk.dffb139831cf2ae9.js


BIN
public/js/profile.chunk.99838eb369862e91.js


BIN
public/js/profile.chunk.cdad3298b78ff083.js


+ 0 - 0
public/js/profile~followers.bundle.d4dc19a65836f5ba.js → public/js/profile~followers.bundle.f18a24d3924b651a.js


+ 0 - 0
public/js/profile~following.bundle.3d192304050edfc2.js → public/js/profile~following.bundle.8a269b2c4fd0722c.js


BIN
public/js/search.js


BIN
public/js/spa.js


+ 0 - 0
public/js/static~privacy.bundle.1e765099c99b7409.js → public/js/static~privacy.bundle.c647cbc1674cfea8.js


+ 0 - 0
public/js/static~tos.bundle.dcf1fb170b2dae10.js → public/js/static~tos.bundle.fc0a2c6ff6297f24.js


BIN
public/js/vendor.js


BIN
public/mix-manifest.json


+ 239 - 0
resources/assets/components/partials/post/LikeModal.vue

@@ -0,0 +1,239 @@
+<template>
+	<div>
+		<b-modal
+			ref="likesModal"
+			centered
+			size="md"
+			:scrollable="true"
+			hide-footer
+			header-class="py-2"
+			body-class="p-0"
+			title-class="w-100 text-center pl-4 font-weight-bold"
+			title-tag="p"
+			:title="$t('common.likes')">
+			<div v-if="isLoading" class="likes-loader list-group border-top-0" style="max-height: 500px;">
+				<like-placeholder />
+			</div>
+
+			<div v-else>
+				<div v-if="!likes.length" class="d-flex justify-content-center align-items-center" style="height: 140px;">
+					<p class="font-weight-bold mb-0">{{ $t('post.noLikes') }}</p>
+				</div>
+
+				<div v-else class="list-group" style="max-height: 500px;">
+					<div v-for="(account, index) in likes" class="list-group-item border-left-0 border-right-0 px-3" :class="[ index === 0 ? 'border-top-0' : '']">
+						<div class="media align-items-center">
+							<img :src="account.avatar" width="40" height="40" style="border-radius: 8px;" class="mr-3 shadow-sm" onerror="this.src='/storage/avatars/default.jpg?v=0';this.onerror=null;">
+							<div class="media-body">
+								<p class="mb-0 text-truncate"><a :href="account.url" class="text-dark font-weight-bold text-decoration-none" @click.prevent="goToProfile(account)">{{ getUsername(account) }}</a></p>
+								<p class="mb-0 mt-n1 text-dark font-weight-bold small text-break">&commat;{{ account.acct }}</p>
+							</div>
+
+							<div>
+								<button
+									v-if="account.follows == null || account.id == user.id"
+									class="btn btn-outline-muted rounded-pill btn-sm font-weight-bold"
+									@click="goToProfile(profile)"
+									style="width:110px;">
+									View Profile
+								</button>
+								<button
+									v-else-if="account.follows"
+									class="btn btn-outline-muted rounded-pill btn-sm font-weight-bold"
+									:disabled="isUpdatingFollowState"
+									@click="handleUnfollow(index)"
+									style="width:110px;">
+									<span v-if="isUpdatingFollowState && followStateIndex === index">
+										<b-spinner small />
+									</span>
+									<span v-else>Following</span>
+								</button>
+								<button
+									v-else-if="!account.follows"
+									class="btn btn-primary rounded-pill btn-sm font-weight-bold"
+									:disabled="isUpdatingFollowState"
+									@click="handleFollow(index)"
+									style="width:110px;">
+									<span v-if="isUpdatingFollowState && followStateIndex === index">
+										<b-spinner small />
+									</span>
+									<span v-else>Follow</span>
+								</button>
+							</div>
+						</div>
+					</div>
+
+					<div v-if="canLoadMore">
+						<intersect @enter="enterIntersect">
+							<like-placeholder class="border-top-0" />
+						</intersect>
+						<like-placeholder />
+					</div>
+				</div>
+			</div>
+		</b-modal>
+	</div>
+</template>
+
+<script type="text/javascript">
+	import Intersect from 'vue-intersect'
+	import LikePlaceholder from './LikeListPlaceholder.vue';
+	import { parseLinkHeader } from '@web3-storage/parse-link-header';
+
+	export default {
+		props: {
+			status: {
+				type: Object
+			},
+
+			profile: {
+				type: Object
+			}
+		},
+
+		components: {
+			"intersect": Intersect,
+			"like-placeholder": LikePlaceholder
+		},
+
+		data() {
+			return {
+				isOpen: false,
+				isLoading: true,
+				canLoadMore: false,
+				isFetchingMore: false,
+				likes: [],
+				ids: [],
+				page: undefined,
+				isUpdatingFollowState: false,
+				followStateIndex: undefined,
+				user: window._sharedData.user
+			}
+		},
+
+		methods: {
+			clear() {
+				this.isOpen = false;
+				this.isLoading = true;
+				this.canLoadMore = false;
+				this.isFetchingMore = false;
+				this.likes = [];
+				this.ids = [];
+				this.page = undefined;
+			},
+
+			fetchLikes() {
+				axios.get('/api/v1/statuses/'+this.status.id+'/favourited_by', {
+					params: {
+						limit: 40
+					}
+				})
+				.then(res => {
+					this.ids = res.data.map(a => a.id);
+					this.likes = res.data;
+					if(res.headers && res.headers.link) {
+						const links = parseLinkHeader(res.headers.link);
+						if(links.next) {
+							this.page = links.next.cursor;
+							this.canLoadMore = true;
+						} else {
+							this.canLoadMore = false;
+						}
+					}
+					this.isLoading = false;
+				});
+			},
+
+			open() {
+				if(this.page) {
+					this.clear();
+				}
+				this.isOpen = true;
+				this.fetchLikes();
+				this.$refs.likesModal.show();
+			},
+
+			enterIntersect() {
+				if(this.isFetchingMore) {
+					return;
+				}
+
+				this.isFetchingMore = true;
+
+				axios.get('/api/v1/statuses/'+this.status.id+'/favourited_by', {
+					params: {
+						limit: 10,
+						cursor: this.page
+					}
+				}).then(res => {
+					if(!res.data || !res.data.length) {
+						this.canLoadMore = false;
+						this.isFetchingMore = false;
+						return;
+					}
+					res.data.forEach(user => {
+						if(this.ids.indexOf(user.id) == -1) {
+							this.ids.push(user.id);
+							this.likes.push(user);
+						}
+					})
+					if(res.headers && res.headers.link) {
+						const links = parseLinkHeader(res.headers.link);
+						if(links.next) {
+							this.page = links.next.cursor;
+						} else {
+							this.canLoadMore = false;
+						}
+					}
+					this.isFetchingMore = false;
+				})
+			},
+
+			getUsername(account) {
+				return account.display_name ? account.display_name : account.username;
+			},
+
+			goToProfile(account) {
+				this.$router.push({
+					name: 'profile',
+					path: `/i/web/profile/${account.id}`,
+					params: {
+						id: account.id,
+						cachedProfile: account,
+						cachedUser: this.profile
+					}
+				})
+			},
+
+			handleFollow(index) {
+				event.currentTarget.blur();
+
+				this.followStateIndex = index;
+				this.isUpdatingFollowState = true;
+
+				let account = this.likes[index];
+				axios.post('/api/v1/accounts/' + account.id + '/follow')
+				.then(res => {
+					this.likes[index].follows = true;
+					this.followStateIndex = undefined;
+					this.isUpdatingFollowState = false;
+				});
+			},
+
+			handleUnfollow(index) {
+				event.currentTarget.blur();
+
+				this.followStateIndex = index;
+				this.isUpdatingFollowState = true;
+
+				let account = this.likes[index];
+				axios.post('/api/v1/accounts/' + account.id + '/unfollow')
+				.then(res => {
+					this.likes[index].follows = false;
+					this.followStateIndex = undefined;
+					this.isUpdatingFollowState = false;
+				});
+			}
+		}
+	}
+</script>

+ 239 - 0
resources/assets/components/partials/post/ShareModal.vue

@@ -0,0 +1,239 @@
+<template>
+	<div>
+		<b-modal
+			ref="sharesModal"
+			centered
+			size="md"
+			:scrollable="true"
+			hide-footer
+			header-class="py-2"
+			body-class="p-0"
+			title-class="w-100 text-center pl-4 font-weight-bold"
+			title-tag="p"
+			title="Shared By">
+			<div v-if="isLoading" class="likes-loader list-group border-top-0" style="max-height: 500px;">
+				<like-placeholder />
+			</div>
+
+			<div v-else>
+				<div v-if="!likes.length" class="d-flex justify-content-center align-items-center" style="height: 140px;">
+					<p class="font-weight-bold mb-0">Nobody has shared this yet!</p>
+				</div>
+
+				<div v-else class="list-group" style="max-height: 500px;">
+					<div v-for="(account, index) in likes" class="list-group-item border-left-0 border-right-0 px-3" :class="[ index === 0 ? 'border-top-0' : '']">
+						<div class="media align-items-center">
+							<img :src="account.avatar" width="40" height="40" style="border-radius: 8px;" class="mr-3 shadow-sm" onerror="this.src='/storage/avatars/default.jpg?v=0';this.onerror=null;">
+							<div class="media-body">
+								<p class="mb-0 text-truncate"><a :href="account.url" class="text-dark font-weight-bold text-decoration-none" @click.prevent="goToProfile(account)">{{ getUsername(account) }}</a></p>
+								<p class="mb-0 mt-n1 text-dark font-weight-bold small text-break">&commat;{{ account.acct }}</p>
+							</div>
+
+							<div>
+								<button
+									v-if="account.id == user.id"
+									class="btn btn-outline-muted rounded-pill btn-sm font-weight-bold"
+									@click="goToProfile(profile)"
+									style="width:110px;">
+									View Profile
+								</button>
+								<button
+									v-else-if="account.follows"
+									class="btn btn-outline-muted rounded-pill btn-sm font-weight-bold"
+									:disabled="isUpdatingFollowState"
+									@click="handleUnfollow(index)"
+									style="width:110px;">
+									<span v-if="isUpdatingFollowState && followStateIndex === index">
+										<b-spinner small />
+									</span>
+									<span v-else>Following</span>
+								</button>
+								<button
+									v-else-if="!account.follows"
+									class="btn btn-primary rounded-pill btn-sm font-weight-bold"
+									:disabled="isUpdatingFollowState"
+									@click="handleFollow(index)"
+									style="width:110px;">
+									<span v-if="isUpdatingFollowState && followStateIndex === index">
+										<b-spinner small />
+									</span>
+									<span v-else>Follow</span>
+								</button>
+							</div>
+						</div>
+					</div>
+
+					<div v-if="canLoadMore">
+						<intersect @enter="enterIntersect">
+							<like-placeholder class="border-top-0" />
+						</intersect>
+						<like-placeholder />
+					</div>
+				</div>
+			</div>
+		</b-modal>
+	</div>
+</template>
+
+<script type="text/javascript">
+	import Intersect from 'vue-intersect'
+	import LikePlaceholder from './LikeListPlaceholder.vue';
+	import { parseLinkHeader } from '@web3-storage/parse-link-header';
+
+	export default {
+		props: {
+			status: {
+				type: Object
+			},
+
+			profile: {
+				type: Object
+			}
+		},
+
+		components: {
+			"intersect": Intersect,
+			"like-placeholder": LikePlaceholder
+		},
+
+		data() {
+			return {
+				isOpen: false,
+				isLoading: true,
+				canLoadMore: false,
+				isFetchingMore: false,
+				likes: [],
+				ids: [],
+				page: undefined,
+				isUpdatingFollowState: false,
+				followStateIndex: undefined,
+				user: window._sharedData.user
+			}
+		},
+
+		methods: {
+			clear() {
+				this.isOpen = false;
+				this.isLoading = true;
+				this.canLoadMore = false;
+				this.isFetchingMore = false;
+				this.likes = [];
+				this.ids = [];
+				this.page = undefined;
+			},
+
+			fetchShares() {
+				axios.get('/api/v1/statuses/'+this.status.id+'/reblogged_by', {
+					params: {
+						limit: 40
+					}
+				})
+				.then(res => {
+					this.ids = res.data.map(a => a.id);
+					this.likes = res.data;
+					if(res.headers && res.headers.link) {
+						const links = parseLinkHeader(res.headers.link);
+						if(links.next) {
+							this.page = links.next.cursor;
+							this.canLoadMore = true;
+						} else {
+							this.canLoadMore = false;
+						}
+					}
+					this.isLoading = false;
+				});
+			},
+
+			open() {
+				if(this.page) {
+					this.clear();
+				}
+				this.isOpen = true;
+				this.fetchShares();
+				this.$refs.sharesModal.show();
+			},
+
+			enterIntersect() {
+				if(this.isFetchingMore) {
+					return;
+				}
+
+				this.isFetchingMore = true;
+
+				axios.get('/api/v1/statuses/'+this.status.id+'/reblogged_by', {
+					params: {
+						limit: 10,
+						cursor: this.page
+					}
+				}).then(res => {
+					if(!res.data || !res.data.length) {
+						this.canLoadMore = false;
+						this.isFetchingMore = false;
+						return;
+					}
+					res.data.forEach(user => {
+						if(this.ids.indexOf(user.id) == -1) {
+							this.ids.push(user.id);
+							this.likes.push(user);
+						}
+					})
+					if(res.headers && res.headers.link) {
+						const links = parseLinkHeader(res.headers.link);
+						if(links.next) {
+							this.page = links.next.cursor;
+						} else {
+							this.canLoadMore = false;
+						}
+					}
+					this.isFetchingMore = false;
+				})
+			},
+
+			getUsername(account) {
+				return account.display_name ? account.display_name : account.username;
+			},
+
+			goToProfile(account) {
+				this.$router.push({
+					name: 'profile',
+					path: `/i/web/profile/${account.id}`,
+					params: {
+						id: account.id,
+						cachedProfile: account,
+						cachedUser: this.profile
+					}
+				})
+			},
+
+			handleFollow(index) {
+				event.currentTarget.blur();
+
+				this.followStateIndex = index;
+				this.isUpdatingFollowState = true;
+
+				let account = this.likes[index];
+				axios.post('/api/v1/accounts/' + account.id + '/follow')
+				.then(res => {
+					this.likes[index].follows = true;
+					this.followStateIndex = undefined;
+					this.isUpdatingFollowState = false;
+				});
+			},
+
+			handleUnfollow(index) {
+				event.currentTarget.blur();
+
+				this.followStateIndex = index;
+				this.isUpdatingFollowState = true;
+
+				let account = this.likes[index];
+				axios.post('/api/v1/accounts/' + account.id + '/unfollow')
+				.then(res => {
+					this.likes[index].follows = false;
+					this.followStateIndex = undefined;
+					this.isUpdatingFollowState = false;
+				});
+			}
+		}
+	}
+</script>

+ 415 - 0
resources/assets/components/sections/Notifications.vue

@@ -0,0 +1,415 @@
+<template>
+	<div class="notifications-component">
+		<div class="card shadow-sm mb-3" style="overflow: hidden;border-radius: 15px !important;">
+			<div class="card-body pb-0">
+				<div class="d-flex justify-content-between align-items-center mb-3">
+					<span class="text-muted font-weight-bold">Notifications</span>
+					<div v-if="feed && feed.length">
+						<router-link to="/i/web/notifications" class="btn btn-outline-light btn-sm mr-2" style="color: #B8C2CC !important">
+							<i class="far fa-filter"></i>
+						</router-link>
+						<button
+							v-if="hasLoaded && feed.length"
+							class="btn btn-light btn-sm"
+							:class="{ 'text-lighter': isRefreshing }"
+							:disabled="isRefreshing"
+							@click="refreshNotifications">
+							<i class="fal fa-redo"></i>
+						</button>
+					</div>
+				</div>
+
+				<div v-if="!hasLoaded" class="notifications-component-feed">
+					<div class="d-flex align-items-center justify-content-center flex-column bg-light rounded-lg p-3 mb-3" style="min-height: 100px;">
+						<b-spinner variant="grow" />
+					</div>
+				</div>
+
+				<div v-else class="notifications-component-feed">
+					<template v-if="isEmpty">
+						<div class="d-flex align-items-center justify-content-center flex-column bg-light rounded-lg p-3 mb-3" style="min-height: 100px;">
+							<i class="fal fa-bell fa-2x text-lighter"></i>
+							<p class="mt-2 small font-weight-bold text-center mb-0">{{ $t('notifications.noneFound') }}</p>
+						</div>
+					</template>
+
+					<template v-else>
+						<div v-for="(n, index) in feed" class="mb-2">
+							<div class="media align-items-center">
+								<img
+									class="mr-2 rounded-circle shadow-sm"
+									:src="n.account.avatar"
+									width="32"
+									height="32"
+									onerror="this.onerror=null;this.src='/storage/avatars/default.png';">
+
+								<div class="media-body font-weight-light small">
+									<div v-if="n.type == 'favourite'">
+										<p class="my-0">
+											<a :href="getProfileUrl(n.account)" class="font-weight-bold text-dark word-break" :title="n.account.acct">{{n.account.local == false ? '@':''}}{{truncate(n.account.username)}}</a> liked your
+											<span v-if="n.status && n.status.hasOwnProperty('media_attachments')">
+												<a class="font-weight-bold" v-bind:href="getPostUrl(n.status)" :id="'fvn-' + n.id" @click.prevent="goToPost(n.status)">post</a>.
+												<b-popover :target="'fvn-' + n.id" title="" triggers="hover" placement="top" boundary="window">
+													<img :src="notificationPreview(n)" width="100px" height="100px" style="object-fit: cover;">
+												</b-popover>
+											</span>
+											<span v-else>
+												<a class="font-weight-bold" :href="getPostUrl(n.status)" @click.prevent="goToPost(n.status)">post</a>.
+											</span>
+										</p>
+									</div>
+									<div v-else-if="n.type == 'comment'">
+										<p class="my-0">
+											<a :href="getProfileUrl(n.account)" class="font-weight-bold text-dark word-break" :title="n.account.acct">{{n.account.local == false ? '@':''}}{{truncate(n.account.username)}}</a> commented on your <a class="font-weight-bold" :href="getPostUrl(n.status)" @click.prevent="goToPost(n.status)">post</a>.
+										</p>
+									</div>
+									<div v-else-if="n.type == 'group:comment'">
+										<p class="my-0">
+											<a :href="getProfileUrl(n.account)" class="font-weight-bold text-dark word-break" :title="n.account.acct">{{n.account.local == false ? '@':''}}{{truncate(n.account.username)}}</a> commented on your <a class="font-weight-bold" :href="n.group_post_url">group post</a>.
+										</p>
+									</div>
+									<div v-else-if="n.type == 'story:react'">
+										<p class="my-0">
+											<a :href="getProfileUrl(n.account)" class="font-weight-bold text-dark word-break" :title="n.account.acct">{{n.account.local == false ? '@':''}}{{truncate(n.account.username)}}</a> reacted to your <a class="font-weight-bold" v-bind:href="'/account/direct/t/'+n.account.id">story</a>.
+										</p>
+									</div>
+									<div v-else-if="n.type == 'story:comment'">
+										<p class="my-0">
+											<a :href="getProfileUrl(n.account)" class="font-weight-bold text-dark word-break" :title="n.account.acct">{{n.account.local == false ? '@':''}}{{truncate(n.account.username)}}</a> commented on your <a class="font-weight-bold" v-bind:href="'/account/direct/t/'+n.account.id">story</a>.
+										</p>
+									</div>
+									<div v-else-if="n.type == 'mention'">
+										<p class="my-0">
+											<a :href="getProfileUrl(n.account)" class="font-weight-bold text-dark word-break" :title="n.account.acct">{{n.account.local == false ? '@':''}}{{truncate(n.account.username)}}</a> <a class="font-weight-bold" v-bind:href="mentionUrl(n.status)" @click.prevent="goToPost(n.status)">mentioned</a> you.
+										</p>
+									</div>
+									<div v-else-if="n.type == 'follow'">
+										<p class="my-0">
+											<a :href="getProfileUrl(n.account)" class="font-weight-bold text-dark word-break" :title="n.account.acct">{{n.account.local == false ? '@':''}}{{truncate(n.account.username)}}</a> followed you.
+										</p>
+									</div>
+									<div v-else-if="n.type == 'share'">
+										<p class="my-0">
+											<a :href="getProfileUrl(n.account)" class="font-weight-bold text-dark word-break" :title="n.account.acct">{{n.account.local == false ? '@':''}}{{truncate(n.account.username)}}</a> shared your <a class="font-weight-bold" :href="getPostUrl(n.status)" @click.prevent="goToPost(n.status)">post</a>.
+										</p>
+									</div>
+									<div v-else-if="n.type == 'modlog'">
+										<p class="my-0">
+											<a :href="getProfileUrl(n.account)" class="font-weight-bold text-dark word-break" :title="n.account.acct">{{truncate(n.account.username)}}</a> updated a <a class="font-weight-bold" v-bind:href="n.modlog.url">modlog</a>.
+										</p>
+									</div>
+									<div v-else-if="n.type == 'tagged'">
+										<p class="my-0">
+											<a :href="getProfileUrl(n.account)" class="font-weight-bold text-dark word-break" :title="n.account.acct">{{n.account.local == false ? '@':''}}{{truncate(n.account.username)}}</a> tagged you in a <a class="font-weight-bold" v-bind:href="n.tagged.post_url">post</a>.
+										</p>
+									</div>
+									<div v-else-if="n.type == 'direct'">
+										<p class="my-0">
+											<a :href="getProfileUrl(n.account)" class="font-weight-bold text-dark word-break" :title="n.account.acct">{{n.account.local == false ? '@':''}}{{truncate(n.account.username)}}</a> sent a <router-link class="font-weight-bold" :to="'/i/web/direct/thread/'+n.account.id">dm</router-link>.
+										</p>
+									</div>
+
+									<div v-else-if="n.type == 'group.join.approved'">
+										<p class="my-0">
+											Your application to join the <a :href="n.group.url" class="font-weight-bold text-dark word-break" :title="n.group.name">{{truncate(n.group.name)}}</a> group was approved!
+										</p>
+									</div>
+
+									<div v-else-if="n.type == 'group.join.rejected'">
+										<p class="my-0">
+											Your application to join <a :href="n.group.url" class="font-weight-bold text-dark word-break" :title="n.group.name">{{truncate(n.group.name)}}</a> was rejected.
+										</p>
+									</div>
+
+									<div v-else-if="n.type == 'group:invite'">
+										<p class="my-0">
+											<a :href="getProfileUrl(n.account)" class="font-weight-bold text-dark word-break" :title="n.account.acct">{{n.account.local == false ? '@':''}}{{truncate(n.account.username)}}</a> invited you to join <a :href="n.group.url + '/invite/claim'" class="font-weight-bold text-dark word-break" :title="n.group.name">{{n.group.name}}</a>.
+										</p>
+									</div>
+
+									<div v-else>
+										<p class="my-0">
+											We cannot display this notification at this time.
+										</p>
+									</div>
+								</div>
+								<div class="small text-muted font-weight-bold" :title="n.created_at">{{timeAgo(n.created_at)}}</div>
+							</div>
+						</div>
+
+						<div v-if="hasLoaded && feed.length == 0">
+							<p class="small font-weight-bold text-center mb-0">{{ $t('notifications.noneFound') }}</p>
+						</div>
+
+						<div v-else>
+							<intersect v-if="hasLoaded && canLoadMore" @enter="enterIntersect">
+								<placeholder small style="margin-top: -6px" />
+								<placeholder small/>
+								<placeholder small/>
+								<placeholder small/>
+							</intersect>
+
+							<div v-else class="d-block" style="height: 10px;">
+							</div>
+						</div>
+					</template>
+				</div>
+			</div>
+		</div>
+	</div>
+</template>
+
+<script type="text/javascript">
+	import Placeholder from './../partials/placeholders/NotificationPlaceholder.vue';
+	import Intersect from 'vue-intersect';
+
+	export default {
+		props: {
+			profile: {
+				type: Object
+			}
+		},
+
+		components: {
+			"intersect": Intersect,
+			"placeholder": Placeholder
+		},
+
+		data() {
+			return {
+				feed: {},
+				maxId: undefined,
+				isIntersecting: false,
+				canLoadMore: false,
+				isRefreshing: false,
+				hasLoaded: false,
+				isEmpty: false,
+				retryTimeout: undefined,
+				retryAttempts: 0
+			}
+		},
+
+		mounted() {
+			this.init();
+		},
+
+		destroyed() {
+			clearTimeout(this.retryTimeout);
+		},
+
+		methods: {
+			init() {
+				if(this.retryAttempts == 3) {
+					this.hasLoaded = true;
+					this.isEmpty = true;
+					clearTimeout(this.retryTimeout);
+					return;
+				}
+				axios.get('/api/pixelfed/v1/notifications', {
+					params: {
+						limit: 9,
+					}
+				})
+				.then(res => {
+					if(!res || !res.data || !res.data.length) {
+						this.retryAttempts = this.retryAttempts + 1;
+						this.retryTimeout = setTimeout(() => this.init(), this.retryAttempts * 1500);
+						return;
+					}
+					let data = res.data.filter(n => {
+						if(n.type == 'share' && !n.status) {
+							return false;
+						}
+						if(n.type == 'comment' && !n.status) {
+							return false;
+						}
+						if(n.type == 'mention' && !n.status) {
+							return false;
+						}
+						if(n.type == 'favourite' && !n.status) {
+							return false;
+						}
+						if(n.type == 'follow' && !n.account) {
+							return false;
+						}
+						if(n.type == 'modlog' && !n.modlog) {
+							return false;
+						}
+						return true;
+					});
+
+					if(!res.data.length) {
+						this.canLoadMore = false;
+					} else {
+						this.canLoadMore = true;
+					}
+
+					if(this.retryTimeout || this.retryAttempts) {
+						this.retryAttempts = 0;
+						clearTimeout(this.retryTimeout);
+					}
+					this.maxId = res.data[res.data.length - 1].id;
+					this.feed = data;
+
+					this.hasLoaded = true;
+					setTimeout(() => {
+						this.isRefreshing = false;
+					}, 15000);
+				});
+			},
+
+			refreshNotifications() {
+				event.currentTarget.blur();
+				this.isRefreshing = true;
+				this.init();
+			},
+
+			enterIntersect() {
+				if(this.isIntersecting || !this.canLoadMore) {
+					return;
+				}
+
+				this.isIntersecting = true;
+
+				axios.get('/api/pixelfed/v1/notifications', {
+					params: {
+						limit: 9,
+						max_id: this.maxId
+					}
+				})
+				.then(res => {
+					if(!res.data || !res.data.length) {
+						this.canLoadMore = false;
+						this.isIntersecting = false;
+						return;
+					}
+					let data = res.data.filter(n => {
+						if(n.type == 'share' && !n.status) {
+							return false;
+						}
+						if(n.type == 'comment' && !n.status) {
+							return false;
+						}
+						if(n.type == 'mention' && !n.status) {
+							return false;
+						}
+						if(n.type == 'favourite' && !n.status) {
+							return false;
+						}
+						if(n.type == 'follow' && !n.account) {
+							return false;
+						}
+						if(n.type == 'modlog' && !n.modlog) {
+							return false;
+						}
+						return true;
+					});
+
+					if(!res.data.length) {
+						this.canLoadMore = false;
+						return;
+					}
+
+					this.maxId = res.data[res.data.length - 1].id;
+					this.feed.push(...data);
+
+					this.$nextTick(() => {
+					   this.isIntersecting = false;
+					})
+				});
+			},
+
+			truncate(text) {
+				if(text.length <= 15) {
+					return text;
+				}
+
+				return text.slice(0,15) + '...'
+			},
+
+			timeAgo(ts) {
+				return window.App.util.format.timeAgo(ts);
+			},
+
+			mentionUrl(status) {
+				let username = status.account.username;
+				let id = status.id;
+				return '/p/' + username + '/' + id;
+			},
+
+			redirect(url) {
+				window.location.href = url;
+			},
+
+			notificationPreview(n) {
+				if(!n.status || !n.status.hasOwnProperty('media_attachments') || !n.status.media_attachments.length) {
+					return '/storage/no-preview.png';
+				}
+				return n.status.media_attachments[0].preview_url;
+			},
+
+			getProfileUrl(account) {
+				return '/i/web/profile/' + account.id;
+			},
+
+			getPostUrl(status) {
+				if(!status) {
+					return;
+				}
+
+				return '/i/web/post/' + status.id;
+			},
+
+			goToPost(status) {
+				this.$router.push({
+					name: 'post',
+					path: `/i/web/post/${status.id}`,
+					params: {
+						id: status.id,
+						cachedStatus: status,
+						cachedProfile: this.profile
+					}
+				})
+			},
+
+			goToProfile(account) {
+				this.$router.push({
+					name: 'profile',
+					path: `/i/web/profile/${account.id}`,
+					params: {
+						id: account.id,
+						cachedProfile: account,
+						cachedUser: this.profile
+					}
+				})
+			},
+		}
+	}
+</script>
+
+<style lang="scss">
+	.notifications-component {
+		&-feed {
+			min-height: 50px;
+			max-height: 300px;
+			overflow-y: auto;
+
+			-ms-overflow-style: none;
+			scrollbar-width: none;
+			overflow-y: scroll;
+
+			&::-webkit-scrollbar {
+				display: none;
+			}
+
+		}
+		.card {
+			width: 100%;
+			position: relative;
+		}
+
+		.card-body {
+			width: 100%;
+		}
+	}
+</style>