Merge pull request #4224 from pixelfed/staging

Staging
This commit is contained in:
daniel 2023-03-05 04:33:44 -07:00 committed by GitHub
commit 1518d632d9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
52 changed files with 995 additions and 3284 deletions

View file

@ -118,6 +118,13 @@
- Update ApiV1Controller, fix media update. Fixes #4196 ([f3164650](https://github.com/pixelfed/pixelfed/commit/f3164650)) - Update ApiV1Controller, fix media update. Fixes #4196 ([f3164650](https://github.com/pixelfed/pixelfed/commit/f3164650))
- Update SearchApiV2Service, fix hashtag search. ([1992b5bc](https://github.com/pixelfed/pixelfed/commit/1992b5bc)) - Update SearchApiV2Service, fix hashtag search. ([1992b5bc](https://github.com/pixelfed/pixelfed/commit/1992b5bc))
- Update ApiV1Controller, allow optional mastodonMode on v2/search endpoint. ([f4a69631](https://github.com/pixelfed/pixelfed/commit/f4a69631)) - Update ApiV1Controller, allow optional mastodonMode on v2/search endpoint. ([f4a69631](https://github.com/pixelfed/pixelfed/commit/f4a69631))
- Update ApiV1Controller, add cursor pagination and pagination link headers to account/{id}/followers and account/{id}/following endpoints with legacy support for `page=` simple pagination ([713aa5fd](https://github.com/pixelfed/pixelfed/commit/713aa5fd))
- Update legacy Profile component to use new cursor pagination for following/follower modals ([7a1495e6](https://github.com/pixelfed/pixelfed/commit/7a1495e6))
- Update ApiV1Controller, fix link header pagination in /api/v1/statuses/{id}/favourited_by ([adc82eca](https://github.com/pixelfed/pixelfed/commit/adc82eca))
- Update ApiV1Controller, fix link header pagination in /api/v1/statuses/{id}/reblogged_by ([e346b675](https://github.com/pixelfed/pixelfed/commit/e346b675))
- Update ApiV1Controller, fix following/follower entities, use masto schema by default and update components accordingly ([4716c280](https://github.com/pixelfed/pixelfed/commit/4716c280))
- Update FollowerController, remove deprecated /i/follow endpoint ([4739d614](https://github.com/pixelfed/pixelfed/commit/4739d614))
- Update queue config, set "after_commit" to true ([304ea956](https://github.com/pixelfed/pixelfed/commit/304ea956))
- ([](https://github.com/pixelfed/pixelfed/commit/)) - ([](https://github.com/pixelfed/pixelfed/commit/))
## [v0.11.4 (2022-10-04)](https://github.com/pixelfed/pixelfed/compare/v0.11.3...v0.11.4) ## [v0.11.4 (2022-10-04)](https://github.com/pixelfed/pixelfed/compare/v0.11.3...v0.11.4)

View file

@ -467,6 +467,11 @@ class ApiV1Controller extends Controller
$account = AccountService::get($id); $account = AccountService::get($id);
abort_if(!$account, 404); abort_if(!$account, 404);
$pid = $request->user()->profile_id; $pid = $request->user()->profile_id;
$this->validate($request, [
'limit' => 'sometimes|integer|min:1|max:80'
]);
$limit = $request->input('limit', 10);
$napi = $request->has(self::PF_API_ENTITY_KEY);
if(intval($pid) !== intval($account['id'])) { if(intval($pid) !== intval($account['id'])) {
if($account['locked']) { if($account['locked']) {
@ -479,18 +484,56 @@ class ApiV1Controller extends Controller
return []; return [];
} }
if($request->has('page') && $request->page >= 5) { if($request->has('page') && $request->user()->is_admin == false) {
return []; $page = (int) $request->input('page');
if(($page * $limit) >= 100) {
return [];
}
} }
} }
if($request->has('page')) {
$res = DB::table('followers')
->select('id', 'profile_id', 'following_id')
->whereFollowingId($account['id'])
->orderByDesc('id')
->simplePaginate($limit)
->map(function($follower) use($napi) {
return $napi ? AccountService::get($follower->profile_id, true) : AccountService::getMastodon($follower->profile_id, true);
})
->filter(function($account) {
return $account && isset($account['id']);
})
->values()
->toArray();
$res = DB::table('followers') return $this->json($res);
}
$paginator = DB::table('followers')
->select('id', 'profile_id', 'following_id') ->select('id', 'profile_id', 'following_id')
->whereFollowingId($account['id']) ->whereFollowingId($account['id'])
->orderByDesc('id') ->orderByDesc('id')
->simplePaginate(10) ->cursorPaginate($limit)
->map(function($follower) { ->withQueryString();
return AccountService::getMastodon($follower->profile_id);
$link = null;
if($paginator->onFirstPage()) {
if($paginator->hasMorePages()) {
$link = '<'.$paginator->nextPageUrl().'>; rel="prev"';
}
} else {
if($paginator->previousPageUrl()) {
$link = '<'.$paginator->previousPageUrl().'>; rel="next"';
}
if($paginator->hasMorePages()) {
$link .= ($link ? ', ' : '') . '<'.$paginator->nextPageUrl().'>; rel="prev"';
}
}
$res = $paginator->map(function($follower) use($napi) {
return $napi ? AccountService::get($follower->profile_id, true) : AccountService::getMastodon($follower->profile_id, true);
}) })
->filter(function($account) { ->filter(function($account) {
return $account && isset($account['id']); return $account && isset($account['id']);
@ -498,7 +541,8 @@ class ApiV1Controller extends Controller
->values() ->values()
->toArray(); ->toArray();
return $this->json($res); $headers = isset($link) ? ['Link' => $link] : [];
return $this->json($res, 200, $headers);
} }
/** /**
@ -514,6 +558,11 @@ class ApiV1Controller extends Controller
$account = AccountService::get($id); $account = AccountService::get($id);
abort_if(!$account, 404); abort_if(!$account, 404);
$pid = $request->user()->profile_id; $pid = $request->user()->profile_id;
$this->validate($request, [
'limit' => 'sometimes|integer|min:1|max:80'
]);
$limit = $request->input('limit', 10);
$napi = $request->has(self::PF_API_ENTITY_KEY);
if(intval($pid) !== intval($account['id'])) { if(intval($pid) !== intval($account['id'])) {
if($account['locked']) { if($account['locked']) {
@ -526,18 +575,56 @@ class ApiV1Controller extends Controller
return []; return [];
} }
if($request->has('page') && $request->page >= 5) { if($request->has('page') && $request->user()->is_admin == false) {
return []; $page = (int) $request->input('page');
if(($page * $limit) >= 100) {
return [];
}
} }
} }
$res = DB::table('followers') if($request->has('page')) {
$res = DB::table('followers')
->select('id', 'profile_id', 'following_id')
->whereProfileId($account['id'])
->orderByDesc('id')
->simplePaginate($limit)
->map(function($follower) use($napi) {
return $napi ? AccountService::get($follower->following_id, true) : AccountService::getMastodon($follower->following_id, true);
})
->filter(function($account) {
return $account && isset($account['id']);
})
->values()
->toArray();
return $this->json($res);
}
$paginator = DB::table('followers')
->select('id', 'profile_id', 'following_id') ->select('id', 'profile_id', 'following_id')
->whereProfileId($account['id']) ->whereProfileId($account['id'])
->orderByDesc('id') ->orderByDesc('id')
->simplePaginate(10) ->cursorPaginate($limit)
->map(function($follower) { ->withQueryString();
return AccountService::get($follower->following_id);
$link = null;
if($paginator->onFirstPage()) {
if($paginator->hasMorePages()) {
$link = '<'.$paginator->nextPageUrl().'>; rel="prev"';
}
} else {
if($paginator->previousPageUrl()) {
$link = '<'.$paginator->previousPageUrl().'>; rel="next"';
}
if($paginator->hasMorePages()) {
$link .= ($link ? ', ' : '') . '<'.$paginator->nextPageUrl().'>; rel="prev"';
}
}
$res = $paginator->map(function($follower) use($napi) {
return $napi ? AccountService::get($follower->following_id, true) : AccountService::getMastodon($follower->following_id, true);
}) })
->filter(function($account) { ->filter(function($account) {
return $account && isset($account['id']); return $account && isset($account['id']);
@ -545,7 +632,8 @@ class ApiV1Controller extends Controller
->values() ->values()
->toArray(); ->toArray();
return $this->json($res); $headers = isset($link) ? ['Link' => $link] : [];
return $this->json($res, 200, $headers);
} }
/** /**
@ -2496,13 +2584,17 @@ class ApiV1Controller extends Controller
abort_if(!$request->user(), 403); abort_if(!$request->user(), 403);
$this->validate($request, [ $this->validate($request, [
'limit' => 'nullable|integer|min:1|max:100' 'limit' => 'sometimes|integer|min:1|max:80'
]); ]);
$limit = $request->input('limit') ?? 10; $limit = $request->input('limit', 10);
$user = $request->user(); $user = $request->user();
$pid = $user->profile_id;
$status = Status::findOrFail($id); $status = Status::findOrFail($id);
$author = intval($status->profile_id) === intval($user->profile_id) || $user->is_admin; $account = AccountService::get($status->profile_id, true);
abort_if(!$account, 404);
$author = intval($status->profile_id) === intval($pid) || $user->is_admin;
$napi = $request->has(self::PF_API_ENTITY_KEY);
abort_if( abort_if(
!$status->type || !$status->type ||
@ -2512,10 +2604,14 @@ class ApiV1Controller extends Controller
if(!$author) { if(!$author) {
if($status->scope == 'private') { if($status->scope == 'private') {
abort_if(!FollowerService::follows($user->profile_id, $status->profile_id), 403); abort_if(!FollowerService::follows($pid, $status->profile_id), 403);
} else { } else {
abort_if(!in_array($status->scope, ['public','unlisted']), 403); abort_if(!in_array($status->scope, ['public','unlisted']), 403);
} }
if($request->has('cursor')) {
return $this->json([]);
}
} }
$res = Status::where('reblog_of_id', $status->id) $res = Status::where('reblog_of_id', $status->id)
@ -2530,26 +2626,34 @@ class ApiV1Controller extends Controller
$headers = []; $headers = [];
if($author && $res->hasPages()) { if($author && $res->hasPages()) {
$links = ''; $links = '';
if($res->previousPageUrl()) { if($res->onFirstPage()) {
$links = '<' . $res->previousPageUrl() .'>; rel="prev"'; if($res->nextPageUrl()) {
} $links = '<' . $res->nextPageUrl() .'>; rel="prev"';
}
if($res->nextPageUrl()) { } else {
if(!empty($links)) { if($res->previousPageUrl()) {
$links .= ', '; $links = '<' . $res->previousPageUrl() .'>; rel="next"';
}
if($res->nextPageUrl()) {
if(!empty($links)) {
$links .= ', ';
}
$links .= '<' . $res->nextPageUrl() .'>; rel="prev"';
} }
$links .= '<' . $res->nextPageUrl() .'>; rel="next"';
} }
$headers = ['Link' => $links]; $headers = ['Link' => $links];
} }
$res = $res->map(function($status) use($user) { $res = $res->map(function($status) use($pid, $napi) {
$account = AccountService::getMastodon($status->profile_id, true); $account = $napi ? AccountService::get($status->profile_id, true) : AccountService::getMastodon($status->profile_id, true);
if(!$account) { if(!$account) {
return false; return false;
} }
$account['follows'] = $status->profile_id == $user->profile_id ? null : FollowerService::follows($user->profile_id, $status->profile_id); if($napi) {
$account['follows'] = $status->profile_id == $pid ? null : FollowerService::follows($pid, $status->profile_id);
}
return $account; return $account;
}) })
->filter(function($account) { ->filter(function($account) {
@ -2572,13 +2676,17 @@ class ApiV1Controller extends Controller
abort_if(!$request->user(), 403); abort_if(!$request->user(), 403);
$this->validate($request, [ $this->validate($request, [
'limit' => 'nullable|integer|min:1|max:100' 'limit' => 'nullable|integer|min:1|max:80'
]); ]);
$limit = $request->input('limit') ?? 10; $limit = $request->input('limit', 10);
$user = $request->user(); $user = $request->user();
$pid = $user->profile_id;
$status = Status::findOrFail($id); $status = Status::findOrFail($id);
$author = intval($status->profile_id) === intval($user->profile_id) || $user->is_admin; $account = AccountService::get($status->profile_id, true);
abort_if(!$account, 404);
$author = intval($status->profile_id) === intval($pid) || $user->is_admin;
$napi = $request->has(self::PF_API_ENTITY_KEY);
abort_if( abort_if(
!$status->type || !$status->type ||
@ -2588,7 +2696,7 @@ class ApiV1Controller extends Controller
if(!$author) { if(!$author) {
if($status->scope == 'private') { if($status->scope == 'private') {
abort_if(!FollowerService::follows($user->profile_id, $status->profile_id), 403); abort_if(!FollowerService::follows($pid, $status->profile_id), 403);
} else { } else {
abort_if(!in_array($status->scope, ['public','unlisted']), 403); abort_if(!in_array($status->scope, ['public','unlisted']), 403);
} }
@ -2610,29 +2718,39 @@ class ApiV1Controller extends Controller
$headers = []; $headers = [];
if($author && $res->hasPages()) { if($author && $res->hasPages()) {
$links = ''; $links = '';
if($res->previousPageUrl()) {
$links = '<' . $res->previousPageUrl() .'>; rel="prev"';
}
if($res->nextPageUrl()) { if($res->onFirstPage()) {
if(!empty($links)) { if($res->nextPageUrl()) {
$links .= ', '; $links = '<' . $res->nextPageUrl() .'>; rel="prev"';
}
} else {
if($res->previousPageUrl()) {
$links = '<' . $res->previousPageUrl() .'>; rel="next"';
}
if($res->nextPageUrl()) {
if(!empty($links)) {
$links .= ', ';
}
$links .= '<' . $res->nextPageUrl() .'>; rel="prev"';
} }
$links .= '<' . $res->nextPageUrl() .'>; rel="next"';
} }
$headers = ['Link' => $links]; $headers = ['Link' => $links];
} }
$res = $res->map(function($like) use($user) { $res = $res->map(function($like) use($pid, $napi) {
$account = AccountService::getMastodon($like->profile_id, true); $account = $napi ? AccountService::get($like->profile_id, true) : AccountService::getMastodon($like->profile_id, true);
if(!$account) { if(!$account) {
return false; return false;
} }
$account['follows'] = $like->profile_id == $user->profile_id ? null : FollowerService::follows($user->profile_id, $like->profile_id);
if($napi) {
$account['follows'] = $like->profile_id == $pid ? null : FollowerService::follows($pid, $like->profile_id);
}
return $account; return $account;
}) })
->filter(function($account) use($user) { ->filter(function($account) {
return $account && isset($account['id']); return $account && isset($account['id']);
}) })
->values(); ->values();

View file

@ -23,109 +23,7 @@ class FollowerController extends Controller
public function store(Request $request) public function store(Request $request)
{ {
$this->validate($request, [ abort(422, 'Deprecated API Endpoint, use /api/v1/accounts/{id}/follow or /api/v1/accounts/{id}/unfollow instead.');
'item' => 'required|string',
'force' => 'nullable|boolean',
]);
$force = (bool) $request->input('force', true);
$item = (int) $request->input('item');
$url = $this->handleFollowRequest($item, $force);
if($request->wantsJson() == true) {
return response()->json(200);
} else {
return redirect($url);
}
}
protected function handleFollowRequest($item, $force)
{
$user = Auth::user()->profile;
$target = Profile::where('id', '!=', $user->id)->whereNull('status')->findOrFail($item);
$private = (bool) $target->is_private;
$remote = (bool) $target->domain;
$blocked = UserFilter::whereUserId($target->id)
->whereFilterType('block')
->whereFilterableId($user->id)
->whereFilterableType('App\Profile')
->exists();
if($blocked == true) {
abort(400, 'You cannot follow this user.');
}
$isFollowing = Follower::whereProfileId($user->id)->whereFollowingId($target->id)->exists();
if($private == true && $isFollowing == 0) {
if($user->following()->count() >= Follower::MAX_FOLLOWING) {
abort(400, 'You cannot follow more than ' . Follower::MAX_FOLLOWING . ' accounts');
}
if($user->following()->where('followers.created_at', '>', now()->subHour())->count() >= Follower::FOLLOW_PER_HOUR) {
abort(400, 'You can only follow ' . Follower::FOLLOW_PER_HOUR . ' users per hour');
}
$follow = FollowRequest::firstOrCreate([
'follower_id' => $user->id,
'following_id' => $target->id
]);
if($remote == true && config('federation.activitypub.remoteFollow') == true) {
$this->sendFollow($user, $target);
}
FollowerService::add($user->id, $target->id);
} elseif ($private == false && $isFollowing == 0) {
if($user->following()->count() >= Follower::MAX_FOLLOWING) {
abort(400, 'You cannot follow more than ' . Follower::MAX_FOLLOWING . ' accounts');
}
if($user->following()->where('followers.created_at', '>', now()->subHour())->count() >= Follower::FOLLOW_PER_HOUR) {
abort(400, 'You can only follow ' . Follower::FOLLOW_PER_HOUR . ' users per hour');
}
$follower = new Follower();
$follower->profile_id = $user->id;
$follower->following_id = $target->id;
$follower->save();
if($remote == true && config('federation.activitypub.remoteFollow') == true) {
$this->sendFollow($user, $target);
}
FollowerService::add($user->id, $target->id);
FollowPipeline::dispatch($follower);
} else {
if($force == true) {
$request = FollowRequest::whereFollowerId($user->id)->whereFollowingId($target->id)->exists();
$follower = Follower::whereProfileId($user->id)->whereFollowingId($target->id)->exists();
if($remote == true && $request && !$follower) {
$this->sendFollow($user, $target);
}
if($remote == true && $follower) {
$this->sendUndoFollow($user, $target);
}
Follower::whereProfileId($user->id)
->whereFollowingId($target->id)
->delete();
FollowerService::remove($user->id, $target->id);
}
}
Cache::forget('profile:following:'.$target->id);
Cache::forget('profile:followers:'.$target->id);
Cache::forget('profile:following:'.$user->id);
Cache::forget('profile:followers:'.$user->id);
Cache::forget('api:local:exp:rec:'.$user->id);
Cache::forget('user:account:id:'.$target->user_id);
Cache::forget('user:account:id:'.$user->user_id);
Cache::forget('px:profile:followers-v1.3:'.$user->id);
Cache::forget('px:profile:followers-v1.3:'.$target->id);
Cache::forget('px:profile:following-v1.3:'.$user->id);
Cache::forget('px:profile:following-v1.3:'.$target->id);
Cache::forget('profile:follower_count:'.$target->id);
Cache::forget('profile:follower_count:'.$user->id);
Cache::forget('profile:following_count:'.$target->id);
Cache::forget('profile:following_count:'.$user->id);
return $target->url();
} }
public function sendFollow($user, $target) public function sendFollow($user, $target)

View file

@ -62,36 +62,6 @@ class PublicApiController extends Controller
} }
} }
protected function getLikes($status)
{
if(false == Auth::check()) {
return [];
} else {
$profile = Auth::user()->profile;
if($profile->status) {
return [];
}
$likes = $status->likedBy()->orderBy('created_at','desc')->paginate(10);
$collection = new Fractal\Resource\Collection($likes, new AccountTransformer());
return $this->fractal->createData($collection)->toArray();
}
}
protected function getShares($status)
{
if(false == Auth::check()) {
return [];
} else {
$profile = Auth::user()->profile;
if($profile->status) {
return [];
}
$shares = $status->sharedBy()->orderBy('created_at','desc')->paginate(10);
$collection = new Fractal\Resource\Collection($shares, new AccountTransformer());
return $this->fractal->createData($collection)->toArray();
}
}
public function getStatus(Request $request, $id) public function getStatus(Request $request, $id)
{ {
abort_if(!$request->user(), 403); abort_if(!$request->user(), 403);
@ -216,41 +186,6 @@ class PublicApiController extends Controller
return response()->json($res, 200, [], JSON_PRETTY_PRINT); return response()->json($res, 200, [], JSON_PRETTY_PRINT);
} }
public function statusLikes(Request $request, $username, $id)
{
abort_if(!$request->user(), 404);
$status = Status::findOrFail($id);
$this->scopeCheck($status->profile, $status);
$page = $request->input('page');
if($page && $page >= 3 && $request->user()->profile_id != $status->profile_id) {
return response()->json([
'data' => []
]);
}
$likes = $this->getLikes($status);
return response()->json([
'data' => $likes
]);
}
public function statusShares(Request $request, $username, $id)
{
abort_if(!$request->user(), 404);
$profile = Profile::whereUsername($username)->whereNull('status')->firstOrFail();
$status = Status::whereProfileId($profile->id)->findOrFail($id);
$this->scopeCheck($profile, $status);
$page = $request->input('page');
if($page && $page >= 3 && $request->user()->profile_id != $status->profile_id) {
return response()->json([
'data' => []
]);
}
$shares = $this->getShares($status);
return response()->json([
'data' => $shares
]);
}
protected function scopeCheck(Profile $profile, Status $status) protected function scopeCheck(Profile $profile, Status $status)
{ {
if($profile->is_private == true && Auth::check() == false) { if($profile->is_private == true && Auth::check() == false) {
@ -811,68 +746,6 @@ class PublicApiController extends Controller
return response()->json($res); return response()->json($res);
} }
public function accountFollowers(Request $request, $id)
{
abort_if(!$request->user(), 403);
$account = AccountService::get($id, true);
abort_if(!$account, 404);
$pid = $request->user()->profile_id;
if($pid != $account['id']) {
if($account['locked']) {
if(!FollowerService::follows($pid, $account['id'])) {
return [];
}
}
if(AccountService::hiddenFollowers($id)) {
return [];
}
if($request->has('page') && $request->page >= 10) {
return [];
}
}
$res = collect(FollowerService::followersPaginate($account['id'], $request->input('page', 1)))
->map(fn($id) => AccountService::get($id, true))
->filter()
->values();
return response()->json($res);
}
public function accountFollowing(Request $request, $id)
{
abort_if(!$request->user(), 403);
$account = AccountService::get($id, true);
abort_if(!$account, 404);
$pid = $request->user()->profile_id;
if($pid != $account['id']) {
if($account['locked']) {
if(!FollowerService::follows($pid, $account['id'])) {
return [];
}
}
if(AccountService::hiddenFollowing($id)) {
return [];
}
if($request->has('page') && $request->page >= 10) {
return [];
}
}
$res = collect(FollowerService::followingPaginate($account['id'], $request->input('page', 1)))
->map(fn($id) => AccountService::get($id, true))
->filter()
->values();
return response()->json($res);
}
public function accountStatuses(Request $request, $id) public function accountStatuses(Request $request, $id)
{ {
$this->validate($request, [ $this->validate($request, [

View file

@ -63,6 +63,7 @@ return [
'queue' => 'default', 'queue' => 'default',
'retry_after' => 1800, 'retry_after' => 1800,
'block_for' => null, 'block_for' => null,
'after_commit' => true,
], ],
], ],

BIN
public/js/activity.js vendored

Binary file not shown.

Binary file not shown.

BIN
public/js/home.chunk.02577ae670229a49.js vendored Normal file

Binary file not shown.

Binary file not shown.

BIN
public/js/manifest.js vendored

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
public/js/post.chunk.bad88be5fc04f10b.js vendored Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
public/js/profile.js vendored

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
public/js/rempos.js vendored

Binary file not shown.

BIN
public/js/rempro.js vendored

Binary file not shown.

BIN
public/js/search.js vendored

Binary file not shown.

BIN
public/js/status.js vendored

Binary file not shown.

BIN
public/js/timeline.js vendored

Binary file not shown.

BIN
public/js/vendor.js vendored

Binary file not shown.

Binary file not shown.

View file

@ -104,7 +104,7 @@
isFetchingMore: false, isFetchingMore: false,
likes: [], likes: [],
ids: [], ids: [],
page: undefined, cursor: undefined,
isUpdatingFollowState: false, isUpdatingFollowState: false,
followStateIndex: undefined, followStateIndex: undefined,
user: window._sharedData.user user: window._sharedData.user
@ -119,13 +119,14 @@
this.isFetchingMore = false; this.isFetchingMore = false;
this.likes = []; this.likes = [];
this.ids = []; this.ids = [];
this.page = undefined; this.cursor = undefined;
}, },
fetchLikes() { fetchLikes() {
axios.get('/api/v1/statuses/'+this.status.id+'/favourited_by', { axios.get('/api/v1/statuses/'+this.status.id+'/favourited_by', {
params: { params: {
limit: 40 limit: 40,
'_pe': 1
} }
}) })
.then(res => { .then(res => {
@ -133,19 +134,21 @@
this.likes = res.data; this.likes = res.data;
if(res.headers && res.headers.link) { if(res.headers && res.headers.link) {
const links = parseLinkHeader(res.headers.link); const links = parseLinkHeader(res.headers.link);
if(links.next) { if(links.prev) {
this.page = links.next.cursor; this.cursor = links.prev.cursor;
this.canLoadMore = true; this.canLoadMore = true;
} else { } else {
this.canLoadMore = false; this.canLoadMore = false;
} }
} else {
this.canLoadMore = false;
} }
this.isLoading = false; this.isLoading = false;
}); });
}, },
open() { open() {
if(this.page) { if(this.cursor) {
this.clear(); this.clear();
} }
this.isOpen = true; this.isOpen = true;
@ -163,7 +166,8 @@
axios.get('/api/v1/statuses/'+this.status.id+'/favourited_by', { axios.get('/api/v1/statuses/'+this.status.id+'/favourited_by', {
params: { params: {
limit: 10, limit: 10,
cursor: this.page cursor: this.cursor,
'_pe': 1
} }
}).then(res => { }).then(res => {
if(!res.data || !res.data.length) { if(!res.data || !res.data.length) {
@ -179,11 +183,13 @@
}) })
if(res.headers && res.headers.link) { if(res.headers && res.headers.link) {
const links = parseLinkHeader(res.headers.link); const links = parseLinkHeader(res.headers.link);
if(links.next) { if(links.prev) {
this.page = links.next.cursor; this.cursor = links.prev.cursor;
} else { } else {
this.canLoadMore = false; this.canLoadMore = false;
} }
} else {
this.canLoadMore = false;
} }
this.isFetchingMore = false; this.isFetchingMore = false;
}) })

View file

@ -104,7 +104,7 @@
isFetchingMore: false, isFetchingMore: false,
likes: [], likes: [],
ids: [], ids: [],
page: undefined, cursor: undefined,
isUpdatingFollowState: false, isUpdatingFollowState: false,
followStateIndex: undefined, followStateIndex: undefined,
user: window._sharedData.user user: window._sharedData.user
@ -119,13 +119,14 @@
this.isFetchingMore = false; this.isFetchingMore = false;
this.likes = []; this.likes = [];
this.ids = []; this.ids = [];
this.page = undefined; this.cursor = undefined;
}, },
fetchShares() { fetchShares() {
axios.get('/api/v1/statuses/'+this.status.id+'/reblogged_by', { axios.get('/api/v1/statuses/'+this.status.id+'/reblogged_by', {
params: { params: {
limit: 40 limit: 40,
'_pe': 1
} }
}) })
.then(res => { .then(res => {
@ -133,19 +134,21 @@
this.likes = res.data; this.likes = res.data;
if(res.headers && res.headers.link) { if(res.headers && res.headers.link) {
const links = parseLinkHeader(res.headers.link); const links = parseLinkHeader(res.headers.link);
if(links.next) { if(links.prev) {
this.page = links.next.cursor; this.cursor = links.prev.cursor;
this.canLoadMore = true; this.canLoadMore = true;
} else { } else {
this.canLoadMore = false; this.canLoadMore = false;
} }
} else {
this.canLoadMore = false;
} }
this.isLoading = false; this.isLoading = false;
}); });
}, },
open() { open() {
if(this.page) { if(this.cursor) {
this.clear(); this.clear();
} }
this.isOpen = true; this.isOpen = true;
@ -163,7 +166,8 @@
axios.get('/api/v1/statuses/'+this.status.id+'/reblogged_by', { axios.get('/api/v1/statuses/'+this.status.id+'/reblogged_by', {
params: { params: {
limit: 10, limit: 10,
cursor: this.page cursor: this.cursor,
'_pe': 1
} }
}).then(res => { }).then(res => {
if(!res.data || !res.data.length) { if(!res.data || !res.data.length) {
@ -179,11 +183,13 @@
}) })
if(res.headers && res.headers.link) { if(res.headers && res.headers.link) {
const links = parseLinkHeader(res.headers.link); const links = parseLinkHeader(res.headers.link);
if(links.next) { if(links.prev) {
this.page = links.next.cursor; this.cursor = links.prev.cursor;
} else { } else {
this.canLoadMore = false; this.canLoadMore = false;
} }
} else {
this.canLoadMore = false;
} }
this.isFetchingMore = false; this.isFetchingMore = false;
}) })

View file

@ -0,0 +1,261 @@
<template>
<div class="profile-followers-component">
<div class="row justify-content-center">
<div class="col-12 col-md-8">
<div v-if="isLoaded" class="d-flex justify-content-between align-items-center mb-4">
<div>
<button
class="btn btn-outline-dark rounded-pill font-weight-bold"
@click="goBack()">
Back
</button>
</div>
<div class="d-flex align-items-center justify-content-center flex-column w-100 overflow-hidden">
<p class="small text-muted mb-0 text-uppercase font-weight-light cursor-pointer text-truncate text-center" style="width: 70%;" @click="goBack()">&commat;{{ profile.acct }}</p>
<p class="lead font-weight-bold mt-n1 mb-0">{{ $t('profile.followers') }}</p>
</div>
<div>
<a class="btn btn-dark rounded-pill font-weight-bold spacer-btn" href="#">Back</a>
</div>
</div>
<div v-if="isLoaded" class="list-group scroll-card">
<div v-for="(account, idx) in feed" class="list-group-item">
<a
:id="'apop_'+account.id"
:href="account.url"
@click.prevent="goToProfile(account)"
class="text-decoration-none">
<div class="media">
<img
:src="account.avatar"
width="40"
height="40"
style="border-radius: 8px;"
class="mr-3 shadow-sm"
draggable="false"
loading="lazy"
onerror="this.src='/storage/avatars/default.jpg?v=0';this.onerror=null;">
<div class="media-body">
<p class="mb-0 text-truncate">
<span class="text-dark font-weight-bold text-decoration-none" v-html="getUsername(account)"></span>
</p>
<p class="mb-0 mt-n1 text-muted small text-break">&commat;{{ account.acct }}</p>
</div>
</div>
</a>
<b-popover :target="'apop_'+account.id" triggers="hover" placement="left" delay="1000" custom-class="shadow border-0 rounded-px">
<profile-hover-card :profile="account" />
</b-popover>
</div>
<div v-if="canLoadMore">
<intersect @enter="enterIntersect">
<placeholder />
</intersect>
</div>
<div v-if="!canLoadMore && !feed.length">
<div class="list-group-item text-center">
<div v-if="isWarmingCache" class="px-4">
<p class="mb-0 lead font-weight-bold">Loading Followers...</p>
<div class="py-3">
<b-spinner variant="primary" style="width: 1.5rem; height: 1.5rem;" />
</div>
<p class="small text-muted mb-0">Please wait while we collect followers of this account, this shouldn't take long!</p>
</div>
<p v-else class="mb-0 font-weight-bold">No followers yet!</p>
</div>
</div>
</div>
<div v-else class="list-group">
<placeholder />
</div>
</div>
</div>
</div>
</template>
<script type="text/javascript">
import Intersect from 'vue-intersect'
import Placeholder from './../post/LikeListPlaceholder.vue';
import ProfileHoverCard from './ProfileHoverCard.vue';
import { mapGetters } from 'vuex';
import { parseLinkHeader } from '@web3-storage/parse-link-header';
export default {
props: {
profile: {
type: Object
}
},
components: {
ProfileHoverCard,
Intersect,
Placeholder
},
computed: {
...mapGetters([
'getCustomEmoji'
])
},
data() {
return {
isLoaded: false,
feed: [],
page: 1,
cursor: null,
canLoadMore: true,
isFetchingMore: false,
isWarmingCache: false,
cacheWarmTimeout: undefined,
cacheWarmInterations: 0,
}
},
mounted() {
this.fetchFollowers();
},
beforeDestroy() {
clearTimeout(this.cacheWarmTimeout);
},
methods: {
fetchFollowers() {
axios.get('/api/v1/accounts/'+this.profile.id+'/followers', {
params: {
cursor: this.cursor,
'_pe': 1
}
}).then(res => {
if(!res.data.length) {
this.canLoadMore = false;
this.isLoaded = true;
if(this.cursor == null && this.profile.followers_count) {
this.isWarmingCache = true;
this.setCacheWarmTimeout();
}
return;
}
if(res.headers && res.headers.link) {
const links = parseLinkHeader(res.headers.link);
if(links.prev) {
this.cursor = links.prev.cursor;
this.canLoadMore = true;
} else {
this.canLoadMore = false;
}
} else {
this.canLoadMore = false;
}
this.feed.push(...res.data);
this.isLoaded = true;
this.isFetchingMore = false;
if(this.isWarmingCache || this.cacheWarmTimeout) {
this.isWarmingCache = false;
clearTimeout(this.cacheWarmTimeout);
this.cacheWarmTimeout = undefined;
}
})
.catch(err => {
this.canLoadMore = false;
this.isLoaded = true;
this.isFetchingMore = false;
})
},
enterIntersect() {
if(this.isFetchingMore) {
return;
}
this.isFetchingMore = true;
this.fetchFollowers();
},
getUsername(profile) {
let self = this;
let dn = profile.display_name;
if(!dn || !dn.trim().length) {
return profile.username;
}
if(dn.includes(':')) {
let re = /(<a?)?:\w+:(\d{18}>)?/g;
let un = dn.replaceAll(re, function(em) {
let shortcode = em.slice(1, em.length - 1);
let emoji = self.getCustomEmoji.filter(e => {
return e.shortcode == shortcode;
});
return emoji.length ? `<img draggable="false" class="emojione custom-emoji" alt="${emoji[0].shortcode}" title="${emoji[0].shortcode}" src="${emoji[0].url}" data-original="${emoji[0].url}" data-static="${emoji[0].static_url}" width="16" height="16" onerror="this.onerror=null;this.src='/storage/emoji/missing.png';" />`: em;
});
return un;
} else {
return dn;
}
},
goToProfile(account) {
this.$router.push({
path: `/i/web/profile/${account.id}`,
params: {
id: account.id,
cachedProfile: account,
cachedUser: this.profile
}
})
},
goBack() {
this.$emit('back');
},
setCacheWarmTimeout() {
if(this.cacheWarmInterations >= 5) {
this.isWarmingCache = false;
swal('Oops', 'Its taking longer than expected to collect this account followers. Please try again later', 'error');
return;
}
this.cacheWarmTimeout = setTimeout(() => {
this.cacheWarmInterations++;
this.fetchFollowers();
}, 45000);
}
}
}
</script>
<style lang="scss">
.profile-followers-component {
.list-group-item {
border: none;
&:not(:last-child) {
border-bottom: 1px solid rgba(0, 0, 0, 0.125);
}
}
.scroll-card {
max-height: calc(100vh - 250px);
overflow-y: auto;
-ms-overflow-style: none;
scrollbar-width: none;
scroll-behavior: smooth;
&::-webkit-scrollbar {
display: none;
}
}
.spacer-btn {
opacity: 0;
pointer-events: none;
}
}
</style>

View file

@ -0,0 +1,259 @@
<template>
<div class="profile-following-component">
<div class="row justify-content-center">
<div class="col-12 col-md-7">
<div v-if="isLoaded" class="d-flex justify-content-between align-items-center mb-4">
<div>
<button
class="btn btn-outline-dark rounded-pill font-weight-bold"
@click="goBack()">
Back
</button>
</div>
<div class="d-flex align-items-center justify-content-center flex-column w-100 overflow-hidden">
<p class="small text-muted mb-0 text-uppercase font-weight-light cursor-pointer text-truncate text-center" style="width: 70%;" @click="goBack()">&commat;{{ profile.acct }}</p>
<p class="lead font-weight-bold mt-n1 mb-0">{{ $t('profile.following') }}</p>
</div>
<div>
<a class="btn btn-dark rounded-pill font-weight-bold spacer-btn" href="#">Back</a>
</div>
</div>
<div v-if="isLoaded" class="list-group scroll-card">
<div v-for="(account, idx) in feed" class="list-group-item">
<a
:id="'apop_'+account.id"
:href="account.url"
@click.prevent="goToProfile(account)"
class="text-decoration-none">
<div class="media">
<img
:src="account.avatar"
width="40"
height="40"
style="border-radius: 8px;"
class="mr-3 shadow-sm"
draggable="false"
loading="lazy"
onerror="this.src='/storage/avatars/default.jpg?v=0';this.onerror=null;">
<div class="media-body">
<p class="mb-0 text-truncate">
<span class="text-dark font-weight-bold text-decoration-none" v-html="getUsername(account)"></span>
</p>
<p class="mb-0 mt-n1 text-muted small text-break">&commat;{{ account.acct }}</p>
</div>
</div>
</a>
<b-popover :target="'apop_'+account.id" triggers="hover" placement="left" delay="1000" custom-class="shadow border-0 rounded-px">
<profile-hover-card :profile="account" />
</b-popover>
</div>
<div v-if="canLoadMore">
<intersect @enter="enterIntersect">
<placeholder />
</intersect>
</div>
<div v-if="!canLoadMore && !feed.length">
<div class="list-group-item text-center">
<div v-if="isWarmingCache" class="px-4">
<p class="mb-0 lead font-weight-bold">Loading Following...</p>
<div class="py-3">
<b-spinner variant="primary" style="width: 1.5rem; height: 1.5rem;" />
</div>
<p class="small text-muted mb-0">Please wait while we collect following accounts, this shouldn't take long!</p>
</div>
<p v-else class="mb-0 font-weight-bold">No following anyone yet!</p>
</div>
</div>
</div>
<div v-else class="list-group">
<placeholder />
</div>
</div>
</div>
</div>
</template>
<script type="text/javascript">
import Intersect from 'vue-intersect'
import Placeholder from './../post/LikeListPlaceholder.vue';
import ProfileHoverCard from './ProfileHoverCard.vue';
import { mapGetters } from 'vuex';
import { parseLinkHeader } from '@web3-storage/parse-link-header';
export default {
props: {
profile: {
type: Object
}
},
components: {
ProfileHoverCard,
Intersect,
Placeholder
},
computed: {
...mapGetters([
'getCustomEmoji'
])
},
data() {
return {
isLoaded: false,
feed: [],
cursor: null,
canLoadMore: true,
isFetchingMore: false,
cacheWarmTimeout: undefined,
cacheWarmInterations: 0,
}
},
mounted() {
this.fetchFollowers();
},
beforeDestroy() {
clearTimeout(this.cacheWarmTimeout);
},
methods: {
fetchFollowers() {
axios.get('/api/v1/accounts/'+this.profile.id+'/following', {
params: {
cursor: this.cursor,
'_pe': 1
}
}).then(res => {
if(!res.data.length) {
this.canLoadMore = false;
this.isLoaded = true;
if(this.cursor == null && this.profile.following_count) {
this.isWarmingCache = true;
this.setCacheWarmTimeout();
}
return;
}
if(res.headers && res.headers.link) {
const links = parseLinkHeader(res.headers.link);
if(links.prev) {
this.cursor = links.prev.cursor;
this.canLoadMore = true;
} else {
this.canLoadMore = false;
}
} else {
this.canLoadMore = false;
}
this.feed.push(...res.data);
this.isLoaded = true;
this.isFetchingMore = false;
if(this.isWarmingCache || this.cacheWarmTimeout) {
this.isWarmingCache = false;
clearTimeout(this.cacheWarmTimeout);
this.cacheWarmTimeout = undefined;
}
})
.catch(err => {
this.canLoadMore = false;
this.isLoaded = true;
this.isFetchingMore = false;
})
},
enterIntersect() {
if(this.isFetchingMore) {
return;
}
this.isFetchingMore = true;
this.fetchFollowers();
},
getUsername(profile) {
let self = this;
let dn = profile.display_name;
if(!dn || !dn.trim().length) {
return profile.username;
}
if(dn.includes(':')) {
let re = /(<a?)?:\w+:(\d{18}>)?/g;
let un = dn.replaceAll(re, function(em) {
let shortcode = em.slice(1, em.length - 1);
let emoji = self.getCustomEmoji.filter(e => {
return e.shortcode == shortcode;
});
return emoji.length ? `<img draggable="false" class="emojione custom-emoji" alt="${emoji[0].shortcode}" title="${emoji[0].shortcode}" src="${emoji[0].url}" data-original="${emoji[0].url}" data-static="${emoji[0].static_url}" width="16" height="16" onerror="this.onerror=null;this.src='/storage/emoji/missing.png';" />`: em;
});
return un;
} else {
return dn;
}
},
goToProfile(account) {
this.$router.push({
path: `/i/web/profile/${account.id}`,
params: {
id: account.id,
cachedProfile: account,
cachedUser: this.profile
}
})
},
goBack() {
this.$emit('back');
},
setCacheWarmTimeout() {
if(this.cacheWarmInterations >= 5) {
this.isWarmingCache = false;
swal('Oops', 'Its taking longer than expected to collect following accounts. Please try again later', 'error');
return;
}
this.cacheWarmTimeout = setTimeout(() => {
this.cacheWarmInterations++;
this.fetchFollowers();
}, 45000);
}
}
}
</script>
<style lang="scss">
.profile-following-component {
.list-group-item {
border: none;
&:not(:last-child) {
border-bottom: 1px solid rgba(0, 0, 0, 0.125);
}
}
.scroll-card {
max-height: calc(100vh - 250px);
overflow-y: auto;
-ms-overflow-style: none;
scrollbar-width: none;
scroll-behavior: smooth;
&::-webkit-scrollbar {
display: none;
}
}
.spacer-btn {
opacity: 0;
pointer-events: none;
}
}
</style>

View file

@ -122,12 +122,6 @@
</a> </a>
</div> --> </div> -->
<!-- <div v-else-if="n.type == 'follow' && n.relationship.following == false">
<a href="#" class="btn btn-primary py-0 font-weight-bold" @click.prevent="followProfile(n);">
Follow
</a>
</div> -->
<!-- <div v-else-if="n.status && n.status.parent && !n.status.parent.media_attachments && n.type == 'like' && n.relationship.following == false"> <!-- <div v-else-if="n.status && n.status.parent && !n.status.parent.media_attachments && n.type == 'like' && n.relationship.following == false">
<a href="#" class="btn btn-primary py-0 font-weight-bold"> <a href="#" class="btn btn-primary py-0 font-weight-bold">
Follow Follow
@ -290,24 +284,6 @@ export default {
return '/p/' + username + '/' + id; return '/p/' + username + '/' + id;
}, },
followProfile(n) {
let self = this;
let id = n.account.id;
axios.post('/i/follow', {
item: id
}).then(res => {
self.notifications.map(notification => {
if(notification.account.id === id) {
notification.relationship.following = true;
}
});
}).catch(err => {
if(err.response.data.message) {
swal('Error', err.response.data.message, 'error');
}
});
},
viewContext(n) { viewContext(n) {
switch(n.type) { switch(n.type) {
case 'follow': case 'follow':

View file

@ -371,7 +371,7 @@
centered centered
title="Likes" title="Likes"
body-class="list-group-flush py-3 px-0"> body-class="list-group-flush py-3 px-0">
<div class="list-group"> <div v-if="likedLoaded" class="list-group">
<div class="list-group-item border-0 py-1" v-for="(user, index) in likes" :key="'modal_likes_'+index"> <div class="list-group-item border-0 py-1" v-for="(user, index) in likes" :key="'modal_likes_'+index">
<div class="media"> <div class="media">
<a :href="user.url"> <a :href="user.url">
@ -392,44 +392,13 @@
</div> </div>
</div> </div>
</div> </div>
<infinite-loading @infinite="infiniteLikesHandler" spinner="spiral"> <infinite-loading v-if="likesCanLoadMore" @infinite="infiniteLikesHandler" spinner="spiral">
<div slot="no-more"></div> <div slot="no-more"></div>
<div slot="no-results"></div> <div slot="no-results"></div>
</infinite-loading> </infinite-loading>
</div> </div>
</b-modal> <div v-else class="d-flex justify-content-center align-items-center h-100">
<b-modal ref="sharesModal" <b-spinner />
id="s-modal"
hide-footer
centered
title="Shares"
body-class="list-group-flush py-3 px-0">
<div class="list-group">
<div class="list-group-item border-0 py-1" v-for="(user, index) in shares" :key="'modal_shares_'+index">
<div class="media">
<a :href="user.url">
<img class="mr-3 rounded-circle box-shadow" :src="user.avatar" :alt="user.username + 's avatar'" width="30px">
</a>
<div class="media-body">
<div class="d-inline-block">
<p class="mb-0" style="font-size: 14px">
<a :href="user.url" class="font-weight-bold text-dark">
{{user.username}}
</a>
</p>
<p class="text-muted mb-0" style="font-size: 14px">
{{user.display_name}}
</a>
</p>
</div>
<p class="float-right"><!-- <a class="btn btn-primary font-weight-bold py-1" href="#">Follow</a> --></p>
</div>
</div>
</div>
<infinite-loading @infinite="infiniteSharesHandler" spinner="spiral">
<div slot="no-more"></div>
<div slot="no-results"></div>
</infinite-loading>
</div> </div>
</b-modal> </b-modal>
<b-modal ref="lightboxModal" <b-modal ref="lightboxModal"
@ -515,8 +484,6 @@
size="sm" size="sm"
body-class="list-group-flush p-0 rounded"> body-class="list-group-flush p-0 rounded">
<div class="list-group text-center"> <div class="list-group text-center">
<!-- <div v-if="user && user.id != status.account.id && relationship && relationship.following" class="list-group-item rounded cursor-pointer font-weight-bold text-danger" @click="ctxMenuUnfollow()">Unfollow</div>
<div v-if="user && user.id != status.account.id && relationship && !relationship.following" class="list-group-item rounded cursor-pointer font-weight-bold text-primary" @click="ctxMenuFollow()">Follow</div> -->
<div v-if="status && status.local == true" class="list-group-item rounded cursor-pointer" @click="showEmbedPostModal()">Embed</div> <div v-if="status && status.local == true" class="list-group-item rounded cursor-pointer" @click="showEmbedPostModal()">Embed</div>
<div class="list-group-item rounded cursor-pointer" @click="ctxMenuCopyLink()">Copy Link</div> <div class="list-group-item rounded cursor-pointer" @click="ctxMenuCopyLink()">Copy Link</div>
<div v-if="status && user.id == status.account.id" class="list-group-item rounded cursor-pointer" @click="toggleCommentVisibility">{{ showComments ? 'Disable' : 'Enable'}} Comments</div> <div v-if="status && user.id == status.account.id" class="list-group-item rounded cursor-pointer" @click="toggleCommentVisibility">{{ showComments ? 'Disable' : 'Enable'}} Comments</div>
@ -667,6 +634,7 @@ import VueTribute from 'vue-tribute';
import PollCard from './partials/PollCard.vue'; import PollCard from './partials/PollCard.vue';
import CommentFeed from './partials/CommentFeed.vue'; import CommentFeed from './partials/CommentFeed.vue';
import StatusCard from './partials/StatusCard.vue'; import StatusCard from './partials/StatusCard.vue';
import { parseLinkHeader } from '@web3-storage/parse-link-header';
pixelfed.postComponent = {}; pixelfed.postComponent = {};
@ -702,9 +670,10 @@ export default {
shared: false shared: false
}, },
likes: [], likes: [],
likesPage: 1, likesCursor: null,
likesCanLoadMore: true,
likedLoaded: false,
shares: [], shares: [],
sharesPage: 1,
lightboxMedia: false, lightboxMedia: false,
replyText: '', replyText: '',
replyStatus: {}, replyStatus: {},
@ -847,8 +816,6 @@ export default {
let img = `<img draggable="false" class="emojione custom-emoji" alt="${emoji.shortcode}" title="${emoji.shortcode}" src="${emoji.url}" data-original="${emoji.url}" data-static="${emoji.static_url}" width="18" height="18" onerror="this.onerror=null;this.src='/storage/emoji/missing.png';" />`; let img = `<img draggable="false" class="emojione custom-emoji" alt="${emoji.shortcode}" title="${emoji.shortcode}" src="${emoji.url}" data-original="${emoji.url}" data-static="${emoji.static_url}" width="18" height="18" onerror="this.onerror=null;this.src='/storage/emoji/missing.png';" />`;
self.content = self.content.replace(`:${emoji.shortcode}:`, img); self.content = self.content.replace(`:${emoji.shortcode}:`, img);
}); });
self.likesPage = 2;
self.sharesPage = 2;
self.showCaption = !response.data.status.sensitive; self.showCaption = !response.data.status.sensitive;
if(self.status.comments_disabled == false) { if(self.status.comments_disabled == false) {
self.showComments = true; self.showComments = true;
@ -886,59 +853,68 @@ export default {
window.location.href = '/login?next=' + encodeURIComponent('/p/' + this.status.shortcode); window.location.href = '/login?next=' + encodeURIComponent('/p/' + this.status.shortcode);
return; return;
} }
if(this.likes.length) { if(this.likes && this.likes.length) {
this.$refs.likesModal.show(); this.$refs.likesModal.show();
return; return;
} }
axios.get('/api/v2/likes/profile/'+this.statusUsername+'/status/'+this.statusId) axios.get('/api/v1/statuses/'+ this.statusId + '/favourited_by', {
params: {
limit: 40,
'_pe': 1
}
})
.then(res => { .then(res => {
this.likes = res.data.data; this.likes = res.data;
this.$refs.likesModal.show();
});
},
sharesModal() { if(res.headers && res.headers.link) {
if(this.status.reblogs_count == 0 || $('body').hasClass('loggedIn') == false) { const links = parseLinkHeader(res.headers.link);
window.location.href = '/login?next=' + encodeURIComponent('/p/' + this.status.shortcode); if(links.prev) {
return; this.likesCursor = links.prev.cursor;
} this.likesCanLoadMore = true;
if(this.shares.length) { } else {
this.$refs.sharesModal.show(); this.likesCanLoadMore = false;
return; }
} } else {
axios.get('/api/v2/shares/profile/'+this.statusUsername+'/status/'+this.statusId) this.likesCanLoadMore = false;
.then(res => { }
this.shares = res.data.data; this.$refs.likesModal.show();
this.$refs.sharesModal.show(); })
}); .then(() => {
setTimeout(() => { this.likedLoaded = true }, 1000);
})
}, },
infiniteLikesHandler($state) { infiniteLikesHandler($state) {
let api = '/api/v2/likes/profile/'+this.statusUsername+'/status/'+this.statusId; if(!this.likesCanLoadMore) {
axios.get(api, { $state.complete();
params: { return;
page: this.likesPage, }
},
}).then(({ data }) => {
if (data.data.length > 0) {
this.likes.push(...data.data);
this.likesPage++;
$state.loaded();
} else {
$state.complete();
}
});
},
infiniteSharesHandler($state) { axios.get('/api/v1/statuses/'+ this.statusId + '/favourited_by', {
axios.get('/api/v2/shares/profile/'+this.statusUsername+'/status/'+this.statusId, {
params: { params: {
page: this.sharesPage, cursor: this.likesCursor,
limit: 20,
'_pe': 1
}, },
}).then(({ data }) => { }).then(res => {
if (data.data.length > 0) { if (res && res.data.length) {
this.shares.push(...data.data); this.likes.push(...res.data);
this.sharesPage++; }
if(res.headers && res.headers.link) {
const links = parseLinkHeader(res.headers.link);
if(links.prev) {
this.likesCursor = links.prev.cursor;
this.likesCanLoadMore = true;
} else {
this.likesCanLoadMore = false;
}
} else {
this.likesCanLoadMore = false;
}
return this.likesCanLoadMore;
}).then(res => {
if(res) {
$state.loaded(); $state.loaded();
} else { } else {
$state.complete(); $state.complete();
@ -1627,34 +1603,6 @@ export default {
return; return;
}, },
ctxMenuFollow() {
let id = this.status.account.id;
axios.post('/i/follow', {
item: id
}).then(res => {
let username = this.status.account.acct;
this.relationship.following = true;
this.$refs.ctxModal.hide();
setTimeout(function() {
swal('Follow successful!', 'You are now following ' + username, 'success');
}, 500);
});
},
ctxMenuUnfollow() {
let id = this.status.account.id;
axios.post('/i/follow', {
item: id
}).then(res => {
let username = this.status.account.acct;
this.relationship.following = false;
this.$refs.ctxModal.hide();
setTimeout(function() {
swal('Unfollow successful!', 'You are no longer following ' + username, 'success');
}, 500);
});
},
archivePost(status) { archivePost(status) {
if(window.confirm('Are you sure you want to archive this post?') == false) { if(window.confirm('Are you sure you want to archive this post?') == false) {
return; return;

View file

@ -133,10 +133,10 @@
</a> </a>
</div> </div>
</div> </div>
<p class="d-flex align-items-center mb-1"> <div class="d-md-flex align-items-center mb-1 text-break">
<span class="font-weight-bold mr-1">{{profile.display_name}}</span> <div class="font-weight-bold mr-1">{{profile.display_name}}</div>
<span v-if="profile.pronouns" class="text-muted small">{{profile.pronouns.join('/')}}</span> <div v-if="profile.pronouns" class="text-muted small">{{profile.pronouns.join('/')}}</div>
</p> </div>
<p v-if="profile.note" class="mb-0" v-html="profile.note"></p> <p v-if="profile.note" class="mb-0" v-html="profile.note"></p>
<p v-if="profile.website"><a :href="profile.website" class="profile-website small" rel="me external nofollow noopener" target="_blank">{{formatWebsite(profile.website)}}</a></p> <p v-if="profile.website"><a :href="profile.website" class="profile-website small" rel="me external nofollow noopener" target="_blank">{{formatWebsite(profile.website)}}</a></p>
<p class="d-flex small text-muted align-items-center"> <p class="d-flex small text-muted align-items-center">
@ -154,8 +154,8 @@
</div> </div>
</div> </div>
</div> </div>
<div class="d-block d-md-none my-0 pt-3 border-bottom"> <div v-if="user && user.hasOwnProperty('id')" class="d-block d-md-none my-0 pt-3 border-bottom">
<p v-if="user && user.hasOwnProperty('id')" class="pt-3"> <p class="pt-3">
<button v-if="owner" class="btn btn-outline-secondary bg-white btn-sm py-1 btn-block text-center font-weight-bold text-dark border border-lighter" @click.prevent="redirect('/settings/home')">Edit Profile</button> <button v-if="owner" class="btn btn-outline-secondary bg-white btn-sm py-1 btn-block text-center font-weight-bold text-dark border border-lighter" @click.prevent="redirect('/settings/home')">Edit Profile</button>
<button v-if="!owner && relationship.following" class="btn btn-outline-secondary bg-white btn-sm py-1 px-5 font-weight-bold text-dark border border-lighter" @click="followProfile">&nbsp;&nbsp; Unfollow &nbsp;&nbsp;</button> <button v-if="!owner && relationship.following" class="btn btn-outline-secondary bg-white btn-sm py-1 px-5 font-weight-bold text-dark border border-lighter" @click="followProfile">&nbsp;&nbsp; Unfollow &nbsp;&nbsp;</button>
<button v-if="!owner && !relationship.following" class="btn btn-primary btn-sm py-1 px-5 font-weight-bold" @click="followProfile">{{relationship.followed_by ? 'Follow Back' : '&nbsp;&nbsp;&nbsp;&nbsp; Follow &nbsp;&nbsp;&nbsp;&nbsp;'}}</button> <button v-if="!owner && !relationship.following" class="btn btn-primary btn-sm py-1 px-5 font-weight-bold" @click="followProfile">{{relationship.followed_by ? 'Follow Back' : '&nbsp;&nbsp;&nbsp;&nbsp; Follow &nbsp;&nbsp;&nbsp;&nbsp;'}}</button>
@ -339,16 +339,10 @@
<span class="text-dark">{{profileUsername}}</span> is not following yet</p> <span class="text-dark">{{profileUsername}}</span> is not following yet</p>
</div> </div>
<div v-else> <div v-else>
<div v-if="owner == true" class="list-group-item border-0 pt-0 px-0 mt-n2 mb-3">
<span class="d-flex px-4 pb-0 align-items-center">
<i class="fas fa-search text-lighter"></i>
<input type="text" class="form-control border-0 shadow-0 no-focus" placeholder="Search Following..." v-model="followingModalSearch" v-on:keyup="followingModalSearchHandler">
</span>
</div>
<div class="list-group-item border-0 py-1 mb-1" v-for="(user, index) in following" :key="'following_'+index"> <div class="list-group-item border-0 py-1 mb-1" v-for="(user, index) in following" :key="'following_'+index">
<div class="media"> <div class="media">
<a :href="profileUrlRedirect(user)"> <a :href="profileUrlRedirect(user)">
<img class="mr-3 rounded-circle box-shadow" :src="user.avatar" :alt="user.username + 's avatar'" width="30px" loading="lazy" onerror="this.onerror=null;this.src='/storage/avatars/default.png?v=0'"> <img class="mr-3 rounded-circle box-shadow" :src="user.avatar" :alt="user.username + 's avatar'" width="30px" loading="lazy" onerror="this.src='/storage/avatars/default.jpg?v=0';this.onerror=null;" v-once>
</a> </a>
<div class="media-body text-truncate"> <div class="media-body text-truncate">
<p class="mb-0" style="font-size: 14px"> <p class="mb-0" style="font-size: 14px">
@ -368,7 +362,7 @@
</div> </div>
</div> </div>
</div> </div>
<div v-if="followingModalSearch && following.length == 0" class="list-group-item border-0"> <div v-if="!followingLoading && following.length == 0" class="list-group-item border-0">
<div class="list-group-item border-0 pt-5"> <div class="list-group-item border-0 pt-5">
<p class="p-3 text-center mb-0 lead">No Results Found</p> <p class="p-3 text-center mb-0 lead">No Results Found</p>
</div> </div>
@ -394,7 +388,7 @@
dialog-class="follow-modal" dialog-class="follow-modal"
> >
<div v-if="!followerLoading" class="list-group" style="max-height: 60vh;"> <div v-if="!followerLoading" class="list-group" style="max-height: 60vh;">
<div v-if="!followers.length" class="list-group-item border-0"> <div v-if="!followerLoading && !followers.length" class="list-group-item border-0">
<p class="text-center mb-0 font-weight-bold text-muted py-5"> <p class="text-center mb-0 font-weight-bold text-muted py-5">
<span class="text-dark">{{profileUsername}}</span> has no followers yet</p> <span class="text-dark">{{profileUsername}}</span> has no followers yet</p>
</div> </div>
@ -403,7 +397,7 @@
<div class="list-group-item border-0 py-1 mb-1" v-for="(user, index) in followers" :key="'follower_'+index"> <div class="list-group-item border-0 py-1 mb-1" v-for="(user, index) in followers" :key="'follower_'+index">
<div class="media mb-0"> <div class="media mb-0">
<a :href="profileUrlRedirect(user)"> <a :href="profileUrlRedirect(user)">
<img class="mr-3 rounded-circle box-shadow" :src="user.avatar" :alt="user.username + 's avatar'" width="30px" height="30px" loading="lazy" onerror="this.onerror=null;this.src='/storage/avatars/default.png?v=0'"> <img class="mr-3 rounded-circle box-shadow" :src="user.avatar" :alt="user.username + 's avatar'" width="30px" height="30px" loading="lazy" onerror="this.src='/storage/avatars/default.jpg?v=0';this.onerror=null;" v-once>
</a> </a>
<div class="media-body mb-0"> <div class="media-body mb-0">
<p class="mb-0" style="font-size: 14px"> <p class="mb-0" style="font-size: 14px">
@ -593,6 +587,7 @@
<script type="text/javascript"> <script type="text/javascript">
import VueMasonry from 'vue-masonry-css' import VueMasonry from 'vue-masonry-css'
import StatusCard from './partials/StatusCard.vue'; import StatusCard from './partials/StatusCard.vue';
import { parseLinkHeader } from '@web3-storage/parse-link-header';
export default { export default {
props: [ props: [
@ -623,11 +618,11 @@
modalStatus: false, modalStatus: false,
relationship: {}, relationship: {},
followers: [], followers: [],
followerCursor: 1, followerCursor: null,
followerMore: true, followerMore: true,
followerLoading: true, followerLoading: true,
following: [], following: [],
followingCursor: 1, followingCursor: null,
followingMore: true, followingMore: true,
followingLoading: true, followingLoading: true,
warning: false, warning: false,
@ -641,8 +636,6 @@
ctxEmbedPayload: null, ctxEmbedPayload: null,
copiedEmbed: false, copiedEmbed: false,
hasStory: null, hasStory: null,
followingModalSearch: null,
followingModalSearchCache: null,
followingModalTab: 'following', followingModalTab: 'following',
bookmarksLoading: true, bookmarksLoading: true,
archives: [], archives: [],
@ -1056,19 +1049,22 @@
if($('body').hasClass('loggedIn') == false) { if($('body').hasClass('loggedIn') == false) {
return; return;
} }
axios.post('/i/follow', { this.$refs.visitorContextMenu.hide();
item: this.profileId const curState = this.relationship.following;
}).then(res => { const apiUrl = curState ?
this.$refs.visitorContextMenu.hide(); '/api/v1/accounts/' + this.profileId + '/unfollow' :
if(this.relationship.following) { '/api/v1/accounts/' + this.profileId + '/follow';
axios.post(apiUrl)
.then(res => {
if(curState) {
this.profile.followers_count--; this.profile.followers_count--;
if(this.profile.locked == true) { if(this.profile.locked) {
window.location.href = '/'; location.reload();
} }
} else { } else {
this.profile.followers_count++; this.profile.followers_count++;
} }
this.relationship.following = !this.relationship.following; this.relationship = res.data;
}).catch(err => { }).catch(err => {
if(err.response.data.message) { if(err.response.data.message) {
swal('Error', err.response.data.message, 'error'); swal('Error', err.response.data.message, 'error');
@ -1084,23 +1080,34 @@
if(this.profileSettings.following.list == false) { if(this.profileSettings.following.list == false) {
return; return;
} }
if(this.followingCursor > 1) { if(this.followingCursor) {
this.$refs.followingModal.show(); this.$refs.followingModal.show();
return; return;
} else { } else {
axios.get('/api/pixelfed/v1/accounts/'+this.profileId+'/following', { axios.get('/api/v1/accounts/'+this.profileId+'/following', {
params: { params: {
page: this.followingCursor cursor: this.followingCursor,
limit: 40,
'_pe': 1
} }
}) })
.then(res => { .then(res => {
this.following = res.data; this.following = res.data;
this.followingModalSearchCache = res.data;
this.followingCursor++; if(res.headers && res.headers.link) {
if(res.data.length < 10) { const links = parseLinkHeader(res.headers.link);
if(links.prev) {
this.followingCursor = links.prev.cursor;
this.followingMore = true;
} else {
this.followingMore = false;
}
} else {
this.followingMore = false; this.followingMore = false;
} }
this.followingLoading = false; })
.then(() => {
setTimeout(() => { this.followingLoading = false }, 1000);
}); });
this.$refs.followingModal.show(); this.$refs.followingModal.show();
return; return;
@ -1119,19 +1126,30 @@
this.$refs.followerModal.show(); this.$refs.followerModal.show();
return; return;
} else { } else {
axios.get('/api/pixelfed/v1/accounts/'+this.profileId+'/followers', { axios.get('/api/v1/accounts/'+this.profileId+'/followers', {
params: { params: {
page: this.followerCursor cursor: this.followerCursor,
limit: 40,
'_pe': 1
} }
}) })
.then(res => { .then(res => {
this.followers.push(...res.data); this.followers.push(...res.data);
this.followerCursor++; if(res.headers && res.headers.link) {
if(res.data.length < 10) { const links = parseLinkHeader(res.headers.link);
if(links.prev) {
this.followerCursor = links.prev.cursor;
this.followerMore = true;
} else {
this.followerMore = false;
}
} else {
this.followerMore = false; this.followerMore = false;
} }
this.followerLoading = false;
}) })
.then(() => {
setTimeout(() => { this.followerLoading = false }, 1000);
});
this.$refs.followerModal.show(); this.$refs.followerModal.show();
return; return;
} }
@ -1142,20 +1160,27 @@
window.location.href = encodeURI('/login?next=/' + this.profile.username + '/'); window.location.href = encodeURI('/login?next=/' + this.profile.username + '/');
return; return;
} }
axios.get('/api/pixelfed/v1/accounts/'+this.profile.id+'/following', { axios.get('/api/v1/accounts/'+this.profile.id+'/following', {
params: { params: {
page: this.followingCursor, cursor: this.followingCursor,
fbu: this.followingModalSearch limit: 40,
'_pe': 1
} }
}) })
.then(res => { .then(res => {
if(res.data.length > 0) { if(res.data.length > 0) {
this.following.push(...res.data); this.following.push(...res.data);
this.followingCursor++;
this.followingModalSearchCache = this.following;
} }
if(res.data.length < 10) {
this.followingModalSearchCache = this.following; if(res.headers && res.headers.link) {
const links = parseLinkHeader(res.headers.link);
if(links.prev) {
this.followingCursor = links.prev.cursor;
this.followingMore = true;
} else {
this.followingMore = false;
}
} else {
this.followingMore = false; this.followingMore = false;
} }
}); });
@ -1165,17 +1190,27 @@
if($('body').hasClass('loggedIn') == false) { if($('body').hasClass('loggedIn') == false) {
return; return;
} }
axios.get('/api/pixelfed/v1/accounts/'+this.profile.id+'/followers', { axios.get('/api/v1/accounts/'+this.profile.id+'/followers', {
params: { params: {
page: this.followerCursor cursor: this.followerCursor,
limit: 40,
'_pe': 1
} }
}) })
.then(res => { .then(res => {
if(res.data.length > 0) { if(res.data.length > 0) {
this.followers.push(...res.data); this.followers.push(...res.data);
this.followerCursor++;
} }
if(res.data.length < 10) {
if(res.headers && res.headers.link) {
const links = parseLinkHeader(res.headers.link);
if(links.prev) {
this.followerCursor = links.prev.cursor;
this.followerMore = true;
} else {
this.followerMore = false;
}
} else {
this.followerMore = false; this.followerMore = false;
} }
}); });
@ -1186,9 +1221,11 @@
}, },
followModalAction(id, index, type = 'following') { followModalAction(id, index, type = 'following') {
axios.post('/i/follow', { const apiUrl = type === 'following' ?
item: id '/api/v1/accounts/' + id + '/unfollow' :
}).then(res => { '/api/v1/accounts/' + id + '/follow';
axios.post(apiUrl)
.then(res => {
if(type == 'following') { if(type == 'following') {
this.following.splice(index, 1); this.following.splice(index, 1);
this.profile.following_count--; this.profile.following_count--;
@ -1284,28 +1321,6 @@
window.location.href = '/stories/' + this.profileUsername + '?t=4'; window.location.href = '/stories/' + this.profileUsername + '?t=4';
}, },
followingModalSearchHandler() {
let self = this;
let q = this.followingModalSearch;
if(q.length == 0) {
this.following = this.followingModalSearchCache;
this.followingModalSearch = null;
}
if(q.length > 0) {
let url = '/api/pixelfed/v1/accounts/' +
self.profileId + '/following?page=1&fbu=' +
q;
axios.get(url).then(res => {
this.following = res.data;
}).catch(err => {
self.following = self.followingModalSearchCache;
self.followingModalSearch = null;
});
}
},
truncate(str, len) { truncate(str, len) {
return _.truncate(str, { return _.truncate(str, {
length: len length: len

File diff suppressed because it is too large Load diff

View file

@ -1,746 +0,0 @@
<template>
<div>
<div v-if="relationship && relationship.blocking && warning" class="bg-white pt-3 border-bottom">
<div class="container">
<p class="text-center font-weight-bold">You are blocking this account</p>
<p class="text-center font-weight-bold">Click <a href="#" class="cursor-pointer" @click.prevent="warning = false;">here</a> to view profile</p>
</div>
</div>
<div v-if="loading" style="height: 80vh;" class="d-flex justify-content-center align-items-center">
<img src="/img/pixelfed-icon-grey.svg" class="">
</div>
<div v-if="!loading && !warning" class="container">
<div class="row">
<div class="col-12 col-md-4 pt-5">
<div class="card shadow-none border">
<div class="card-header p-0 m-0">
<img v-if="profile.header_bg" :src="profile.header_bg" style="width: 100%; height: 140px; object-fit: cover;">
<div v-else class="bg-primary" style="width: 100%;height: 140px;"></div>
</div>
<div class="card-body pb-0">
<div class="mt-n5 mb-3">
<img class="rounded-circle p-1 border mt-n4 bg-white shadow" :src="profile.avatar" width="90px" height="90px;" onerror="this.onerror=null;this.src='/storage/avatars/default.jpg?v=0';">
<span class="float-right mt-n1">
<span>
<button v-if="relationship && relationship.following == false" class="btn btn-outline-light py-0 px-3 mt-n1" style="font-size:13px; font-weight: 500;" @click="followProfile();">Follow</button>
<button v-if="relationship && relationship.following == true" class="btn btn-outline-light py-0 px-3 mt-n1" style="font-size:13px; font-weight: 500;" @click="unfollowProfile();">Unfollow</button>
</span>
<span class="mx-2">
<a :href="'/account/direct/t/' + profile.id" class="btn btn-outline-light btn-sm mt-n1" style="padding-top:2px;padding-bottom:1px;">
<i class="far fa-comment-dots cursor-pointer" style="font-size:13px;"></i>
</a>
</span>
<span>
<button class="btn btn-outline-light btn-sm mt-n1" @click="showCtxMenu()" style="padding-top:2px;padding-bottom:1px;">
<i class="fas fa-cog cursor-pointer" style="font-size:13px;"></i>
</button>
</span>
</span>
</div>
<p class="pl-2 h4 font-weight-bold mb-1">{{profile.display_name}}</p>
<p class="pl-2 font-weight-bold mb-2"><a class="text-muted" :href="profile.url" @click.prevent="urlRedirectHandler(profile.url)">{{profile.acct}}</a></p>
<p class="pl-2 text-muted small d-flex justify-content-between">
<span>
<span class="font-weight-bold text-dark">{{profile.statuses_count}}</span>
<span>Posts</span>
</span>
<span class="cursor-pointer" @click="followingModal()">
<span class="font-weight-bold text-dark">{{profile.following_count}}</span>
<span>Following</span>
</span>
<span class="cursor-pointer" @click="followersModal()">
<span class="font-weight-bold text-dark">{{profile.followers_count}}</span>
<span>Followers</span>
</span>
</p>
<p class="pl-2 text-muted small pt-2" v-html="profile.note"></p>
</div>
</div>
<p class="small text-lighter p-2">Last updated: <time :datetime="profile.last_fetched_at">{{timeAgo(profile.last_fetched_at, 'ago')}}</time></p>
<p class="card border-left-primary card-body small py-2 text-muted font-weight-bold shadow-none border-top border-bottom border-right">You are viewing a profile from a remote server, it may not contain up-to-date information.</p>
</div>
<div class="col-12 col-md-8 pt-5">
<div class="row">
<div class="col-12" v-for="(status, index) in feed" :key="'remprop' + index">
<status-card
:class="{'border-top': index === 0}"
:status="status" />
</div>
<div v-if="feed.length == 0" class="col-12 mb-2">
<div class="d-flex justify-content-center align-items-center bg-white border rounded" style="height:60vh;">
<div class="text-center">
<p class="lead">We haven't seen any posts from this account.</p>
</div>
</div>
</div>
<div v-else class="col-12 mt-4">
<p v-if="showLoadMore" class="text-center mb-0 px-0">
<button @click="loadMorePosts()" class="btn btn-outline-primary btn-block font-weight-bold">
<span v-if="!loadingMore">Load More</span>
<span v-else>
<div class="spinner-border spinner-border-sm" role="status">
<span class="sr-only">Loading...</span>
</div>
</span>
</button>
</p>
</div>
</div>
</div>
</div>
<b-modal
v-if="profile && following"
ref="followingModal"
id="following-modal"
hide-footer
centered
scrollable
title="Following"
body-class="list-group-flush py-3 px-0"
dialog-class="follow-modal">
<div v-if="!followingLoading" class="list-group" style="max-height: 60vh;">
<div v-if="!following.length" class="list-group-item border-0">
<p class="text-center mb-0 font-weight-bold text-muted py-5">
<span class="text-dark">{{profileUsername}}</span> is not following yet</p>
</div>
<div v-else>
<div v-if="owner == true" class="list-group-item border-0 pt-0 px-0 mt-n2 mb-3">
<span class="d-flex px-4 pb-0 align-items-center">
<i class="fas fa-search text-lighter"></i>
<input type="text" class="form-control border-0 shadow-0 no-focus" placeholder="Search Following..." v-model="followingModalSearch" v-on:keyup="followingModalSearchHandler">
</span>
</div>
<div class="list-group-item border-0 py-1 mb-1" v-for="(user, index) in following" :key="'following_'+index">
<div class="media">
<a :href="profileUrlRedirect(user)">
<img class="mr-3 rounded-circle box-shadow" :src="user.avatar" :alt="user.username + 's avatar'" width="30px" loading="lazy" onerror="this.onerror=null;this.src='/storage/avatars/default.png?v=0'">
</a>
<div class="media-body text-truncate">
<p class="mb-0" style="font-size: 14px">
<a :href="profileUrlRedirect(user)" class="font-weight-bold text-dark">
{{user.username}}
</a>
</p>
<p v-if="!user.local" class="text-muted mb-0 text-break mr-3" style="font-size: 14px" :title="user.acct" data-toggle="dropdown" data-placement="bottom">
<span class="font-weight-bold">{{user.acct.split('@')[0]}}</span><span class="text-lighter">&commat;{{user.acct.split('@')[1]}}</span>
</p>
<p v-else class="text-muted mb-0 text-truncate" style="font-size: 14px">
{{user.display_name ? user.display_name : user.username}}
</p>
</div>
<div v-if="owner">
<a class="btn btn-outline-dark btn-sm font-weight-bold" href="#" @click.prevent="followModalAction(user.id, index, 'following')">Following</a>
</div>
</div>
</div>
<div v-if="followingModalSearch && following.length == 0" class="list-group-item border-0">
<div class="list-group-item border-0 pt-5">
<p class="p-3 text-center mb-0 lead">No Results Found</p>
</div>
</div>
<div v-if="following.length > 0 && followingMore" class="list-group-item text-center" v-on:click="followingLoadMore()">
<p class="mb-0 small text-muted font-weight-light cursor-pointer">Load more</p>
</div>
</div>
</div>
<div v-else class="text-center py-5">
<div class="spinner-border" role="status">
<span class="sr-only">Loading...</span>
</div>
</div>
</b-modal>
<b-modal ref="followerModal"
id="follower-modal"
hide-footer
centered
scrollable
title="Followers"
body-class="list-group-flush py-3 px-0"
dialog-class="follow-modal"
>
<div v-if="!followerLoading" class="list-group" style="max-height: 60vh;">
<div v-if="!followers.length" class="list-group-item border-0">
<p class="text-center mb-0 font-weight-bold text-muted py-5">
<span class="text-dark">{{profileUsername}}</span> has no followers yet</p>
</div>
<div v-else>
<div class="list-group-item border-0 py-1 mb-1" v-for="(user, index) in followers" :key="'follower_'+index">
<div class="media mb-0">
<a :href="profileUrlRedirect(user)">
<img class="mr-3 rounded-circle box-shadow" :src="user.avatar" :alt="user.username + 's avatar'" width="30px" height="30px" loading="lazy" onerror="this.onerror=null;this.src='/storage/avatars/default.png?v=0'">
</a>
<div class="media-body mb-0">
<p class="mb-0" style="font-size: 14px">
<a :href="profileUrlRedirect(user)" class="font-weight-bold text-dark">
{{user.username}}
</a>
</p>
<p v-if="!user.local" class="text-muted mb-0 text-break mr-3" style="font-size: 14px" :title="user.acct" data-toggle="dropdown" data-placement="bottom">
<span class="font-weight-bold">{{user.acct.split('@')[0]}}</span><span class="text-lighter">&commat;{{user.acct.split('@')[1]}}</span>
</p>
<p v-else class="text-muted mb-0 text-truncate" style="font-size: 14px">
{{user.display_name ? user.display_name : user.username}}
</p>
</div>
<!-- <button class="btn btn-primary font-weight-bold btn-sm py-1">FOLLOW</button> -->
</div>
</div>
<div v-if="followers.length && followerMore" class="list-group-item text-center" v-on:click="followersLoadMore()">
<p class="mb-0 small text-muted font-weight-light cursor-pointer">Load more</p>
</div>
</div>
</div>
<div v-else class="text-center py-5">
<div class="spinner-border" role="status">
<span class="sr-only">Loading...</span>
</div>
</div>
</b-modal>
<b-modal ref="visitorContextMenu"
id="visitor-context-menu"
hide-footer
hide-header
centered
size="sm"
body-class="list-group-flush p-0">
<div class="list-group" v-if="relationship">
<div class="list-group-item cursor-pointer text-center rounded text-dark" @click="copyProfileLink">
Copy Link
</div>
<div v-if="user && !owner && !relationship.muting" class="list-group-item cursor-pointer text-center rounded" @click="muteProfile">
Mute
</div>
<div v-if="user && !owner && relationship.muting" class="list-group-item cursor-pointer text-center rounded" @click="unmuteProfile">
Unmute
</div>
<div v-if="user && !owner" class="list-group-item cursor-pointer text-center rounded text-dark" @click="reportProfile">
Report User
</div>
<div v-if="user && !owner && !relationship.blocking" class="list-group-item cursor-pointer text-center rounded text-dark" @click="blockProfile">
Block
</div>
<div v-if="user && !owner && relationship.blocking" class="list-group-item cursor-pointer text-center rounded text-dark" @click="unblockProfile">
Unblock
</div>
<div class="list-group-item cursor-pointer text-center rounded text-muted" @click="$refs.visitorContextMenu.hide()">
Close
</div>
</div>
</b-modal>
<b-modal ref="ctxModal"
id="ctx-modal"
hide-header
hide-footer
centered
rounded
size="sm"
body-class="list-group-flush p-0 rounded">
<div class="list-group text-center">
<div v-if="ctxMenuStatus && profile.id != profile.id" class="list-group-item rounded cursor-pointer font-weight-bold text-danger" @click="ctxMenuReportPost()">Report inappropriate</div>
<div v-if="ctxMenuStatus && profile.id != profile.id && ctxMenuRelationship && ctxMenuRelationship.following" class="list-group-item rounded cursor-pointer font-weight-bold text-danger" @click="ctxMenuUnfollow()">Unfollow</div>
<div v-if="ctxMenuStatus && profile.id != profile.id && ctxMenuRelationship && !ctxMenuRelationship.following" class="list-group-item rounded cursor-pointer font-weight-bold text-primary" @click="ctxMenuFollow()">Follow</div>
<div class="list-group-item rounded cursor-pointer" @click="ctxMenuGoToPost()">Go to post</div>
<div class="list-group-item rounded cursor-pointer" @click="ctxMenuCopyLink()">Copy Link</div>
<div v-if="profile && profile.is_admin == true" class="list-group-item rounded cursor-pointer" @click="ctxModMenuShow()">Moderation Tools</div>
<div v-if="ctxMenuStatus && (profile.is_admin || profile.id == profile.id)" class="list-group-item rounded cursor-pointer" @click="deletePost(ctxMenuStatus)">Delete</div>
<div class="list-group-item rounded cursor-pointer text-lighter" @click="closeCtxMenu()">Cancel</div>
</div>
</b-modal>
</div>
</div>
</template>
<script type="text/javascript">
import StatusCard from './partials/StatusCard.vue';
export default {
props: [
'profile-id',
],
components: {
StatusCard
},
data() {
return {
id: [],
ids: [],
user: false,
profile: {},
feed: [],
min_id: null,
max_id: null,
loading: true,
owner: false,
layoutType: true,
relationship: null,
warning: false,
ctxMenuStatus: false,
ctxMenuRelationship: false,
fetchingRemotePosts: false,
showMutualFollowers: false,
loadingMore: false,
showLoadMore: true,
followers: [],
followerCursor: 1,
followerMore: true,
followerLoading: true,
following: [],
followingCursor: 1,
followingMore: true,
followingLoading: true,
followingModalSearch: null,
followingModalSearchCache: null,
followingModalTab: 'following',
}
},
beforeMount() {
this.fetchRelationships();
this.fetchProfile();
},
updated() {
document.querySelectorAll('.hashtag').forEach(function(i, e) {
i.href = App.util.format.rewriteLinks(i);
});
},
methods: {
fetchProfile() {
axios.get('/api/pixelfed/v1/accounts/verify_credentials').then(res => {
this.user = res.data
window._sharedData.curUser = res.data;
window.App.util.navatar();
});
axios.get('/api/pixelfed/v1/accounts/' + this.profileId)
.then(res => {
this.profile = res.data;
this.fetchPosts();
});
},
fetchPosts() {
let apiUrl = '/api/pixelfed/v1/accounts/' + this.profileId + '/statuses';
axios.get(apiUrl, {
params: {
only_media: true,
min_id: 1,
}
})
.then(res => {
let data = res.data
.filter(status => status.media_attachments.length > 0);
let ids = data.map(status => status.id);
this.ids = ids;
this.min_id = Math.max(...ids);
this.max_id = Math.min(...ids);
this.feed = data;
this.loading = false;
//this.loadSponsor();
}).catch(err => {
swal('Oops, something went wrong',
'Please release the page.',
'error');
});
},
loadMorePosts() {
this.loadingMore = true;
let apiUrl = '/api/pixelfed/v1/accounts/' + this.profileId + '/statuses';
axios.get(apiUrl, {
params: {
only_media: true,
max_id: this.max_id,
}
})
.then(res => {
let data = res.data
.filter(status => this.ids.indexOf(status.id) === -1)
.filter(status => status.media_attachments.length > 0)
.map(status => {
return {
id: status.id,
caption: {
text: status.content_text,
html: status.content
},
count: {
likes: status.favourites_count,
shares: status.reblogs_count,
comments: status.reply_count
},
thumb: status.media_attachments[0].url,
media: status.media_attachments,
timestamp: status.created_at,
type: status.pf_type,
url: status.url,
sensitive: status.sensitive,
cw: status.sensitive,
spoiler_text: status.spoiler_text
}
});
let ids = data.map(status => status.id);
this.ids.push(...ids);
this.max_id = Math.min(...ids);
this.feed.push(...data);
this.loadingMore = false;
}).catch(err => {
this.loadingMore = false;
this.showLoadMore = false;
});
},
fetchRelationships() {
if(document.querySelectorAll('body')[0].classList.contains('loggedIn') == false) {
return;
}
axios.get('/api/pixelfed/v1/accounts/relationships', {
params: {
'id[]': this.profileId
}
}).then(res => {
if(res.data.length) {
this.relationship = res.data[0];
if(res.data[0].blocking == true) {
this.loading = false;
this.warning = true;
}
}
});
},
postPreviewUrl(post) {
return 'background: url("'+post.thumb+'");background-size:cover';
},
timestampFormat(timestamp) {
let ts = new Date(timestamp);
return ts.toDateString() + ' ' + ts.toLocaleTimeString();
},
remoteProfileUrl(profile) {
return '/i/web/profile/_/' + profile.id;
},
remotePostUrl(status) {
return '/i/web/post/_/' + this.profile.id + '/' + status.id;
},
followProfile() {
axios.post('/i/follow', {
item: this.profileId
}).then(res => {
swal('Followed', 'You are now following ' + this.profile.username +'!', 'success');
this.relationship.following = true;
}).catch(err => {
swal('Oops!', 'Something went wrong, please try again later.', 'error');
});
},
unfollowProfile() {
axios.post('/i/follow', {
item: this.profileId
}).then(res => {
swal('Unfollowed', 'You are no longer following ' + this.profile.username +'.', 'warning');
this.relationship.following = false;
}).catch(err => {
swal('Oops!', 'Something went wrong, please try again later.', 'error');
});
},
showCtxMenu() {
this.$refs.visitorContextMenu.show();
},
copyProfileLink() {
navigator.clipboard.writeText(window.location.href);
this.$refs.visitorContextMenu.hide();
},
muteProfile() {
let id = this.profileId;
axios.post('/i/mute', {
type: 'user',
item: id
}).then(res => {
this.fetchRelationships();
this.$refs.visitorContextMenu.hide();
swal('Success', 'You have successfully muted ' + this.profile.acct, 'success');
}).catch(err => {
swal('Error', 'Something went wrong. Please try again later.', 'error');
});
this.$refs.visitorContextMenu.hide();
},
unmuteProfile() {
let id = this.profileId;
axios.post('/i/unmute', {
type: 'user',
item: id
}).then(res => {
this.fetchRelationships();
this.$refs.visitorContextMenu.hide();
swal('Success', 'You have successfully unmuted ' + this.profile.acct, 'success');
}).catch(err => {
swal('Error', 'Something went wrong. Please try again later.', 'error');
});
this.$refs.visitorContextMenu.hide();
},
blockProfile() {
let id = this.profileId;
axios.post('/i/block', {
type: 'user',
item: id
}).then(res => {
this.warning = true;
this.fetchRelationships();
this.$refs.visitorContextMenu.hide();
swal('Success', 'You have successfully blocked ' + this.profile.acct, 'success');
}).catch(err => {
swal('Error', 'Something went wrong. Please try again later.', 'error');
});
this.$refs.visitorContextMenu.hide();
},
unblockProfile() {
let id = this.profileId;
axios.post('/i/unblock', {
type: 'user',
item: id
}).then(res => {
this.warning = false;
this.fetchRelationships();
this.$refs.visitorContextMenu.hide();
swal('Success', 'You have successfully unblocked ' + this.profile.acct, 'success');
}).catch(err => {
swal('Error', 'Something went wrong. Please try again later.', 'error');
});
this.$refs.visitorContextMenu.hide();
},
reportProfile() {
window.location.href = '/l/i/report?type=profile&id=' + this.profileId;
this.$refs.visitorContextMenu.hide();
},
ctxMenu(status) {
this.ctxMenuStatus = status;
let self = this;
axios.get('/api/pixelfed/v1/accounts/relationships', {
params: {
'id[]': self.profileId
}
}).then(res => {
self.ctxMenuRelationship = res.data[0];
self.$refs.ctxModal.show();
});
},
closeCtxMenu() {
this.ctxMenuStatus = false;
this.ctxMenuRelationship = false;
this.$refs.ctxModal.hide();
},
ctxMenuCopyLink() {
let status = this.ctxMenuStatus;
navigator.clipboard.writeText(status.url);
this.closeCtxMenu();
return;
},
ctxMenuGoToPost() {
let status = this.ctxMenuStatus;
window.location.href = this.statusUrl(status);
this.closeCtxMenu();
return;
},
statusUrl(status) {
return '/i/web/post/_/' + this.profile.id + '/' + status.id;
},
deletePost(status) {
if(this.user.is_admin == false) {
return;
}
if(window.confirm('Are you sure you want to delete this post?') == false) {
return;
}
axios.post('/i/delete', {
type: 'status',
item: status.id
}).then(res => {
this.feed = this.feed.filter(s => {
return s.id != status.id;
});
this.$refs.ctxModal.hide();
}).catch(err => {
swal('Error', 'Something went wrong. Please try again later.', 'error');
});
},
manuallyFetchRemotePosts($event) {
this.fetchingRemotePosts = true;
event.target.blur();
swal(
'Fetching Remote Posts',
'Check back in a few minutes!',
'info'
);
},
timeAgo(ts, suffix = false) {
if(ts == null) {
return 'never';
}
suffix = suffix ? ' ' + suffix : '';
return App.util.format.timeAgo(ts) + suffix;
},
urlRedirectHandler(url) {
let p = new URL(url);
let path = '';
if(p.hostname == window.location.hostname) {
path = url;
} else {
path = '/i/redirect?url=';
path += encodeURI(url);
}
window.location.href = path;
},
followingModal() {
if(this.followingCursor > 1) {
this.$refs.followingModal.show();
return;
} else {
axios.get('/api/pixelfed/v1/accounts/'+this.profileId+'/following', {
params: {
page: this.followingCursor
}
})
.then(res => {
this.following = res.data;
this.followingModalSearchCache = res.data;
this.followingCursor++;
if(res.data.length < 10) {
this.followingMore = false;
}
this.followingLoading = false;
});
this.$refs.followingModal.show();
return;
}
},
followersModal() {
if(this.followerCursor > 1) {
this.$refs.followerModal.show();
return;
} else {
axios.get('/api/pixelfed/v1/accounts/'+this.profileId+'/followers', {
params: {
page: this.followerCursor
}
})
.then(res => {
this.followers.push(...res.data);
this.followerCursor++;
if(res.data.length < 10) {
this.followerMore = false;
}
this.followerLoading = false;
})
this.$refs.followerModal.show();
return;
}
},
followingLoadMore() {
axios.get('/api/pixelfed/v1/accounts/'+this.profile.id+'/following', {
params: {
page: this.followingCursor,
fbu: this.followingModalSearch
}
})
.then(res => {
if(res.data.length > 0) {
this.following.push(...res.data);
this.followingCursor++;
this.followingModalSearchCache = this.following;
}
if(res.data.length < 10) {
this.followingModalSearchCache = this.following;
this.followingMore = false;
}
});
},
followersLoadMore() {
axios.get('/api/pixelfed/v1/accounts/'+this.profile.id+'/followers', {
params: {
page: this.followerCursor
}
})
.then(res => {
if(res.data.length > 0) {
this.followers.push(...res.data);
this.followerCursor++;
}
if(res.data.length < 10) {
this.followerMore = false;
}
});
},
profileUrlRedirect(profile) {
if(profile.local == true) {
return profile.url;
}
return '/i/web/profile/_/' + profile.id;
},
followingModalSearchHandler() {
let self = this;
let q = this.followingModalSearch;
if(q.length == 0) {
this.following = this.followingModalSearchCache;
this.followingModalSearch = null;
}
if(q.length > 0) {
let url = '/api/pixelfed/v1/accounts/' +
self.profileId + '/following?page=1&fbu=' +
q;
axios.get(url).then(res => {
this.following = res.data;
}).catch(err => {
self.following = self.followingModalSearchCache;
self.followingModalSearch = null;
});
}
},
}
}
</script>
<style type="text/css" scoped>
@media (min-width: 1200px) {
.container {
max-width: 1050px;
}
}
</style>

View file

@ -379,26 +379,6 @@ export default {
this.searchContext(this.analysis); this.searchContext(this.analysis);
}, },
followProfile(profile, index) {
this.loading = true;
axios.post('/i/follow', {
item: profile.entity.id
}).then(res => {
if(profile.entity.local == true) {
this.fetchSearchResults();
return;
} else {
this.loading = false;
this.results.profiles[index].entity.follow_request = true;
return;
}
}).catch(err => {
if(err.response.data.message) {
swal('Error', err.response.data.message, 'error');
}
});
},
searchLexer() { searchLexer() {
let q = this.query; let q = this.query;

View file

@ -283,21 +283,21 @@
</div> --> </div> -->
</div> </div>
<div class="card-footer bg-transparent border-0 pt-0 pb-1"> <div class="card-footer bg-transparent border-0 pt-0 pb-1">
<div class="d-flex justify-content-between text-center"> <div class="d-flex justify-content-between text-center">
<span class="cursor-pointer" @click="redirect(profile.url)"> <span class="cursor-pointer" @click="redirect(profile.url)">
<p class="mb-0 font-weight-bold">{{formatCount(profile.statuses_count)}}</p> <p class="mb-0 font-weight-bold">{{formatCount(profile.statuses_count)}}</p>
<p class="mb-0 small text-muted">Posts</p> <p class="mb-0 small text-muted">Posts</p>
</span> </span>
<span class="cursor-pointer" @click="redirect(profile.url+'?md=followers')"> <span class="cursor-pointer" @click="redirect(profile.url+'?md=followers')">
<p class="mb-0 font-weight-bold">{{formatCount(profile.followers_count)}}</p> <p class="mb-0 font-weight-bold">{{formatCount(profile.followers_count)}}</p>
<p class="mb-0 small text-muted">Followers</p> <p class="mb-0 small text-muted">Followers</p>
</span> </span>
<span class="cursor-pointer" @click="redirect(profile.url+'?md=following')"> <span class="cursor-pointer" @click="redirect(profile.url+'?md=following')">
<p class="mb-0 font-weight-bold">{{formatCount(profile.following_count)}}</p> <p class="mb-0 font-weight-bold">{{formatCount(profile.following_count)}}</p>
<p class="mb-0 small text-muted">Following</p> <p class="mb-0 small text-muted">Following</p>
</span> </span>
</div> </div>
</div> </div>
</div> </div>
<div v-show="modes.notify == true && !loading" class="mb-4"> <div v-show="modes.notify == true && !loading" class="mb-4">
@ -324,7 +324,6 @@
</p> </p>
<p class="mb-0 small text-muted">{{rec.message}}</p> <p class="mb-0 small text-muted">{{rec.message}}</p>
</div> </div>
<a class="font-weight-bold small" href="#" @click.prevent="expRecFollow(rec.id, index)">Follow</a>
</div> </div>
</div> </div>
</div> </div>
@ -761,24 +760,6 @@
}) })
}, },
expRecFollow(id, index) {
return;
if(this.config.ab.rec == false) {
return;
}
axios.post('/i/follow', {
item: id
}).then(res => {
this.suggestions.splice(index, 1);
}).catch(err => {
if(err.response.data.message) {
swal('Error', err.response.data.message, 'error');
}
});
},
owner(status) { owner(status) {
return this.profile.id === status.account.id; return this.profile.id === status.account.id;
}, },
@ -865,34 +846,34 @@
}, },
commentFocus(status, $event) { commentFocus(status, $event) {
if(status.comments_disabled) { if(status.comments_disabled) {
return; return;
} }
// if(this.status && this.status.id == status.id) { // if(this.status && this.status.id == status.id) {
// this.$refs.replyModal.show(); // this.$refs.replyModal.show();
// return; // return;
// } // }
this.status = status; this.status = status;
this.replies = {}; this.replies = {};
this.replyStatus = {}; this.replyStatus = {};
this.replyText = ''; this.replyText = '';
this.replyId = status.id; this.replyId = status.id;
this.replyStatus = status; this.replyStatus = status;
// this.$refs.replyModal.show(); // this.$refs.replyModal.show();
this.fetchStatusComments(status, ''); this.fetchStatusComments(status, '');
$('nav').hide(); $('nav').hide();
$('footer').hide(); $('footer').hide();
$('.mobile-footer-spacer').attr('style', 'display:none !important'); $('.mobile-footer-spacer').attr('style', 'display:none !important');
$('.mobile-footer').attr('style', 'display:none !important'); $('.mobile-footer').attr('style', 'display:none !important');
this.currentLayout = 'comments'; this.currentLayout = 'comments';
window.history.pushState({}, '', this.statusUrl(status)); window.history.pushState({}, '', this.statusUrl(status));
return; return;
}, },
fetchStatusComments(status, card) { fetchStatusComments(status, card) {
let url = '/api/v2/comments/'+status.account.id+'/status/'+status.id; let url = '/api/v2/comments/'+status.account.id+'/status/'+status.id;
axios.get(url) axios.get(url)
.then(response => { .then(response => {

View file

@ -9,8 +9,6 @@
size="sm" size="sm"
body-class="list-group-flush p-0 rounded"> body-class="list-group-flush p-0 rounded">
<div class="list-group text-center"> <div class="list-group text-center">
<!-- <div v-if="status && status.account.id != profile.id && ctxMenuRelationship && ctxMenuRelationship.following" class="list-group-item rounded cursor-pointer font-weight-bold text-danger" @click="ctxMenuUnfollow()">Unfollow</div>
<div v-if="status && status.account.id != profile.id && ctxMenuRelationship && !ctxMenuRelationship.following" class="list-group-item rounded cursor-pointer font-weight-bold text-primary" @click="ctxMenuFollow()">Follow</div> -->
<div v-if="status.visibility !== 'archived'" class="list-group-item rounded cursor-pointer" @click="ctxMenuGoToPost()">View Post</div> <div v-if="status.visibility !== 'archived'" class="list-group-item rounded cursor-pointer" @click="ctxMenuGoToPost()">View Post</div>
<div v-if="status.visibility !== 'archived'" class="list-group-item rounded cursor-pointer" @click="ctxMenuGoToProfile()">View Profile</div> <div v-if="status.visibility !== 'archived'" class="list-group-item rounded cursor-pointer" @click="ctxMenuGoToProfile()">View Profile</div>
<!-- <div v-if="status && status.local == true && !status.in_reply_to_id" class="list-group-item rounded cursor-pointer" @click="ctxMenuEmbed()">Embed</div> <!-- <div v-if="status && status.local == true && !status.in_reply_to_id" class="list-group-item rounded cursor-pointer" @click="ctxMenuEmbed()">Embed</div>
@ -289,37 +287,6 @@
return; return;
}, },
ctxMenuFollow() {
let id = this.ctxMenuStatus.account.id;
axios.post('/i/follow', {
item: id
}).then(res => {
let username = this.ctxMenuStatus.account.acct;
this.closeCtxMenu();
setTimeout(function() {
swal('Follow successful!', 'You are now following ' + username, 'success');
}, 500);
});
},
ctxMenuUnfollow() {
let id = this.ctxMenuStatus.account.id;
axios.post('/i/follow', {
item: id
}).then(res => {
let username = this.ctxMenuStatus.account.acct;
if(this.scope == 'home') {
this.feed = this.feed.filter(s => {
return s.account.id != this.ctxMenuStatus.account.id;
});
}
this.closeCtxMenu();
setTimeout(function() {
swal('Unfollow successful!', 'You are no longer following ' + username, 'success');
}, 500);
});
},
ctxMenuReportPost() { ctxMenuReportPost() {
this.$refs.ctxModal.hide(); this.$refs.ctxModal.hide();
this.$refs.ctxReport.show(); this.$refs.ctxReport.show();

View file

@ -71,14 +71,7 @@
<a v-if="status.place" class="small text-decoration-none text-muted" :href="'/discover/places/'+status.place.id+'/'+status.place.slug" title="Location" data-toggle="tooltip"><i class="fas fa-map-marked-alt"></i> {{status.place.name}}, {{status.place.country}}</a> <a v-if="status.place" class="small text-decoration-none text-muted" :href="'/discover/places/'+status.place.id+'/'+status.place.slug" title="Location" data-toggle="tooltip"><i class="fas fa-map-marked-alt"></i> {{status.place.name}}, {{status.place.country}}</a>
</div> </div>
</div> </div>
<div v-if="canFollow(status)">
<span class="px-2"></span>
<button class="btn btn-primary btn-sm font-weight-bold py-1 px-3 rounded-lg" @click="follow(status.account.id)"><i class="far fa-user-plus mr-1"></i> Follow</button>
</div>
<div v-if="status.hasOwnProperty('relationship') && status.relationship.hasOwnProperty('following') && status.relationship.following">
<span class="px-2"></span>
<button class="btn btn-outline-primary btn-sm font-weight-bold py-1 px-3 rounded-lg" @click="unfollow(status.account.id)"><i class="far fa-user-check mr-1"></i> Following</button>
</div>
<div class="text-right" style="flex-grow:1;"> <div class="text-right" style="flex-grow:1;">
<button class="btn btn-link text-dark py-0" type="button" @click="ctxMenu()"> <button class="btn btn-link text-dark py-0" type="button" @click="ctxMenu()">
<span class="fas fa-ellipsis-h text-lighter"></span> <span class="fas fa-ellipsis-h text-lighter"></span>
@ -397,52 +390,6 @@
statusDeleted(status) { statusDeleted(status) {
this.$emit('status-delete', status); this.$emit('status-delete', status);
},
canFollow(status) {
if(!status.hasOwnProperty('relationship')) {
return false;
}
if(!status.hasOwnProperty('account') || !status.account.hasOwnProperty('id')) {
return false;
}
if(status.account.id == this.profile.id) {
return false;
}
return !status.relationship.following;
},
follow(id) {
event.currentTarget.blur();
axios.post('/i/follow', {
item: id
}).then(res => {
this.status.relationship.following = true;
this.$emit('followed', id);
}).catch(err => {
if(err.response.data.message) {
swal('Error', err.response.data.message, 'error');
}
});
},
unfollow(id) {
event.currentTarget.blur();
axios.post('/i/follow', {
item: id
}).then(res => {
this.status.relationship.following = false;
this.$emit('unfollowed', id);
}).catch(err => {
if(err.response.data.message) {
swal('Error', err.response.data.message, 'error');
}
});
} }
} }
} }

View file

@ -1,34 +0,0 @@
Vue.component(
'photo-presenter',
require('./components/presenter/PhotoPresenter.vue').default
);
Vue.component(
'video-presenter',
require('./components/presenter/VideoPresenter.vue').default
);
Vue.component(
'photo-album-presenter',
require('./components/presenter/PhotoAlbumPresenter.vue').default
);
Vue.component(
'video-album-presenter',
require('./components/presenter/VideoAlbumPresenter.vue').default
);
Vue.component(
'mixed-album-presenter',
require('./components/presenter/MixedAlbumPresenter.vue').default
);
Vue.component(
'post-menu',
require('./components/PostMenu.vue').default
);
Vue.component(
'remote-post',
require('./components/RemotePost.vue').default
);

View file

@ -1,29 +0,0 @@
Vue.component(
'photo-presenter',
require('./components/presenter/PhotoPresenter.vue').default
);
Vue.component(
'video-presenter',
require('./components/presenter/VideoPresenter.vue').default
);
Vue.component(
'photo-album-presenter',
require('./components/presenter/PhotoAlbumPresenter.vue').default
);
Vue.component(
'video-album-presenter',
require('./components/presenter/VideoAlbumPresenter.vue').default
);
Vue.component(
'mixed-album-presenter',
require('./components/presenter/MixedAlbumPresenter.vue').default
);
Vue.component(
'remote-profile',
require('./components/RemoteProfile.vue').default
);

View file

@ -1,121 +0,0 @@
@extends('layouts.app',['title' => $user->username . " on " . config('app.name')])
@section('content')
@if (session('error'))
<div class="alert alert-danger text-center font-weight-bold mb-0">
{{ session('error') }}
</div>
@endif
@include('profile.partial.user-info')
@if(true === $owner)
<div>
<ul class="nav nav-topbar d-flex justify-content-center border-0">
<li class="nav-item">
<a class="nav-link {{request()->is($user->username) ? 'active': ''}} font-weight-bold text-uppercase" href="{{$user->url()}}">Posts</a>
</li>
{{-- <li class="nav-item">
<a class="nav-link {{request()->is('*/collections') ? 'active': ''}} font-weight-bold text-uppercase" href="{{$user->url()}}/collections">Collections</a>
</li> --}}
<li class="nav-item">
<a class="nav-link {{request()->is('*/saved') ? 'active':''}} font-weight-bold text-uppercase" href="{{$user->url('/saved')}}">Saved</a>
</li>
</ul>
</div>
@endif
<div class="container">
@if($owner && request()->is('*/saved'))
<div class="col-12">
<p class="text-muted font-weight-bold small">{{__('profile.savedWarning')}}</p>
</div>
@endif
<div class="profile-timeline mt-2 mt-md-4">
<div class="row">
@if($timeline->count() > 0)
@foreach($timeline 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}}">
@switch($status->viewType())
@case('album')
@case('photo:album')
<span class="float-right mr-3" style="color:#fff;position:relative;margin-top:10px;z-index: 999999;opacity:0.6;text-shadow: 3px 3px 16px #272634;"><i class="fas fa-images fa-2x"></i></span>
@break
@case('video')
<span class="float-right mr-3" style="color:#fff;position:relative;margin-top:10px;z-index: 999999;opacity:0.6;text-shadow: 3px 3px 16px #272634;"><i class="fas fa-video fa-2x"></i></span>
@break
@case('video-album')
<span class="float-right mr-3" style="color:#fff;position:relative;margin-top:10px;z-index: 999999;opacity:0.6;text-shadow: 3px 3px 16px #272634;"><i class="fas fa-film fa-2x"></i></span>
@break
@endswitch
<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>
<span class="far fa-heart fa-lg p-2 d-flex-inline"></span>
<span class="d-flex-inline">{{App\Util\Lexer\PrettyNumber::convert($status->likes_count)}}</span>
</span>
<span>
<span class="far fa-comment fa-lg p-2 d-flex-inline"></span>
<span class="d-flex-inline">{{App\Util\Lexer\PrettyNumber::convert($status->comments_count)}}</span>
</span>
</h5>
</div>
</div>
</a>
</div>
@endforeach
</div>
</div>
<div class="pagination-container">
<div class="d-flex justify-content-center">
{{$timeline->links()}}
</div>
</div>
@else
<div class="col-12">
<div class="card">
<div class="card-body py-5 my-5">
<div class="d-flex my-5 py-5 justify-content-center align-items-center">
@if($owner && request()->is('*/saved'))
<p class="lead font-weight-bold">{{ __('profile.emptySaved') }}</p>
@else
<p class="lead font-weight-bold">{{ __('profile.emptyTimeline') }}</p>
@endif
</div>
</div>
</div>
</div>
</div>
@endif
</div>
@endsection
@push('meta')<meta property="og:description" content="{{$user->bio}}">
<meta property="og:image" content="{{$user->avatarUrl()}}">
<link href="{{$user->permalink('.atom')}}" rel="alternate" title="{{$user->username}} on Pixelfed" type="application/atom+xml">
@if(false == $settings->crawlable || $user->remote_url)
<meta name="robots" content="noindex, nofollow">
@endif
@endpush
@push('scripts')
<script type="text/javascript">
$(document).ready(function() {
$('.pagination-container').hide();
$('.pagination').hide();
let elem = document.querySelector('.profile-timeline');
let infScroll = new InfiniteScroll( elem, {
path: '.pagination__next',
append: '.profile-timeline .row',
status: '.page-load-status',
history: false,
});
});
</script>
@endpush

View file

@ -10,28 +10,20 @@
<div class="profile-details"> <div class="profile-details">
<div class="username-bar pb-2 d-flex align-items-center"> <div class="username-bar pb-2 d-flex align-items-center">
<span class="font-weight-ultralight h3">{{$user->username}}</span> <span class="font-weight-ultralight h3">{{$user->username}}</span>
@if(Auth::check() && $is_following == true) @auth
@if($is_following == true)
<span class="pl-4"> <span class="pl-4">
<form class="follow-form" method="post" action="/i/follow" style="display: inline;" data-id="{{$user->id}}" data-action="unfollow"> <button class="btn btn-outline-secondary font-weight-bold px-4 py-0" type="button" onclick="unfollowProfile()">Unfollow</button>
@csrf
<input type="hidden" name="item" value="{{$user->id}}">
<button class="btn btn-outline-secondary font-weight-bold px-4 py-0" type="submit">Unfollow</button>
</form>
</span> </span>
@elseif(Auth::check() && $requested == true) @elseif($requested == true)
<span class="pl-4"> <span class="pl-4">
<button class="btn btn-outline-secondary font-weight-bold px-4 py-0 disabled" disabled type="button">Follow Requested</button> <button class="btn btn-outline-secondary font-weight-bold px-4 py-0" type="button" onclick="unfollowProfile()">Follow Requested</button>
</span> </span>
@elseif(Auth::check() && $is_following == false) @elseif($is_following == false)
<span class="pl-4"> <span class="pl-4">
<form class="follow-form" method="post" action="/i/follow" style="display: inline;" data-id="{{$user->id}}" data-action="follow"> <button class="btn btn-primary font-weight-bold px-4 py-0" type="button" onclick="followProfile()">Follow</button>
@csrf
<input type="hidden" name="item" value="{{$user->id}}">
<button class="btn btn-primary font-weight-bold px-4 py-0" type="submit">Follow</button>
</form>
</span> </span>
@endif @endif
@auth
<span class="pl-4"> <span class="pl-4">
<i class="fas fa-cog fa-lg text-muted cursor-pointer" data-toggle="modal" data-target="#ctxProfileMenu"></i> <i class="fas fa-cog fa-lg text-muted cursor-pointer" data-toggle="modal" data-target="#ctxProfileMenu"></i>
<div class="modal" tabindex="-1" role="dialog" id="ctxProfileMenu"> <div class="modal" tabindex="-1" role="dialog" id="ctxProfileMenu">
@ -81,6 +73,7 @@
swal('Muted Profile', 'You have successfully muted this profile.', 'success'); swal('Muted Profile', 'You have successfully muted this profile.', 'success');
}); });
} }
function blockProfile() { function blockProfile() {
axios.post('/i/block', { axios.post('/i/block', {
type: 'user', type: 'user',
@ -92,6 +85,20 @@
}); });
} }
function followProfile() {
axios.post('/api/v1/accounts/{{$user->id}}/follow')
.then(res => {
location.reload();
})
}
function unfollowProfile() {
axios.post('/api/v1/accounts/{{$user->id}}/unfollow')
.then(res => {
location.reload();
})
}
</script> </script>
@endauth @endauth
@endpush @endpush

View file

@ -1,92 +0,0 @@
<div class="bg-white py-5 border-bottom">
<div class="container">
<div class="row">
<div class="col-12 col-md-4 d-flex">
<div class="profile-avatar mx-auto">
<img class="rounded-circle box-shadow" src="{{$user->avatarUrl()}}" width="172px" height="172px">
</div>
</div>
<div class="col-12 col-md-8 d-flex align-items-center">
<div class="profile-details">
<div class="username-bar pb-2 d-flex align-items-center">
<span class="font-weight-ultralight h1">{{$user->username}}</span>
@if($is_admin == true)
<span class="pl-4">
<span class="btn btn-outline-danger font-weight-bold py-0">ADMIN</span>
</span>
@endif
@if($owner == true)
<span class="pl-4">
<a class="fas fa-cog fa-lg text-muted" href="{{route('settings')}}"></a>
</span>
@elseif (Auth::check() && $is_following == true)
<span class="pl-4">
<form class="follow-form" method="post" action="/i/follow" style="display: inline;" data-id="{{$user->id}}" data-action="unfollow">
@csrf
<input type="hidden" name="item" value="{{$user->id}}">
<button class="btn btn-outline-secondary font-weight-bold px-4 py-0" type="submit">Unfollow</button>
</form>
</span>
@elseif (Auth::check() && $is_following == false)
<span class="pl-4">
<form class="follow-form" method="post" action="/i/follow" style="display: inline;" data-id="{{$user->id}}" data-action="follow">
@csrf
<input type="hidden" name="item" value="{{$user->id}}">
<button class="btn btn-primary font-weight-bold px-4 py-0" type="submit">Follow</button>
</form>
</span>
@endif
{{-- <span class="pl-4">
<div class="dropdown">
<button class="btn btn-link text-muted dropdown-toggle py-0" type="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false" style="text-decoration: none;">
<i class="fas fa-ellipsis-v"></i>
</button>
<div class="dropdown-menu" aria-labelledby="dropdownMenuButton">
<a class="dropdown-item font-weight-bold" href="#">Report User</a>
<div class="dropdown-divider"></div>
<a class="dropdown-item font-weight-bold" href="#">Mute User</a>
<a class="dropdown-item font-weight-bold" href="#">Block User</a>
<a class="dropdown-item font-weight-bold mute-users" href="#">Mute User & User Followers</a>
<a class="dropdown-item font-weight-bold" href="#">Block User & User Followers</a>
</div>
</div>
</span>
--}}
</div>
<div class="profile-stats pb-3 d-inline-flex lead">
<div class="font-weight-light pr-5">
<a class="text-dark" href="{{$user->url()}}">
<span class="font-weight-bold">{{$user->statusCount()}}</span>
Posts
</a>
</div>
@if($settings->show_profile_follower_count)
<div class="font-weight-light pr-5">
<a class="text-dark" href="{{$user->url('/followers')}}">
<span class="font-weight-bold">{{$user->followerCount(true)}}</span>
Followers
</a>
</div>
@endif
@if($settings->show_profile_following_count)
<div class="font-weight-light pr-5">
<a class="text-dark" href="{{$user->url('/following')}}">
<span class="font-weight-bold">{{$user->followingCount(true)}}</span>
Following
</a>
</div>
@endif
</div>
<p class="lead mb-0 d-flex align-items-center">
<span class="font-weight-bold pr-3">{{$user->name}}</span>
@if($user->remote_url)
<span class="btn btn-outline-secondary btn-sm py-0">REMOTE PROFILE</span>
@endif
</p>
<div class="mb-0 lead" v-pre>{!!str_limit($user->bio, 127)!!}</div>
<p class="mb-0"><a href="{{$user->website}}" class="font-weight-bold" rel="me external nofollow noopener" target="_blank">{{str_limit($user->website, 30)}}</a></p>
</div>
</div>
</div>
</div>
</div>

View file

@ -1,16 +0,0 @@
@extends('layouts.app')
@section('content')
<remote-profile profile-id="{{$profile->id}}"></remote-profile>
@endsection
@push('meta')
<meta name="robots" content="noindex, noimageindex, nofollow, nosnippet, noarchive">
@endpush
@push('scripts')
<script type="text/javascript" src="{{mix('js/rempro.js')}}"></script>
<script type="text/javascript">
App.boot();
</script>
@endpush

View file

@ -131,14 +131,20 @@
break; break;
case 'unfollow': case 'unfollow':
axios.post('/i/follow', { axios.post('/api/v1/accounts/' + id + '/unfollow')
item: id .then(res => {
}).then(res => {
swal( swal(
'Unfollow Successful', 'Unfollow Successful',
'You have successfully unfollowed that user', 'You have successfully unfollowed that user',
'success' 'success'
); );
})
.catch(err => {
swal(
'Error',
'An error occured when attempting to unfollow this user',
'error'
);
}); });
break; break;

View file

@ -11,25 +11,35 @@
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="text-center mt-n5 mb-4"> <div class="text-center mt-n5 mb-4">
<img class="rounded-circle p-1 border mt-n4 bg-white shadow" src="{{$profile->avatarUrl()}}" width="90px" height="90px;"> <img
class="rounded-circle p-1 border mt-n4 bg-white shadow"
src="{{$profile->avatarUrl()}}"
width="90"
height="90"
loading="lazy"
onerror="this.src='/storage/avatars/default.jpg?v=0';this.onerror=null;">
</div> </div>
<p class="text-center lead font-weight-bold mb-1">{{$profile->username}}</p> <p class="text-center lead font-weight-bold mb-1">{{$profile->username}}</p>
<p class="text-center text-muted small text-uppercase mb-4">{{$profile->followerCount()}} followers</p> <p class="text-center text-muted small text-uppercase mb-4">{{$profile->followerCount()}} followers</p>
<div class="d-flex justify-content-center"> <div class="d-flex justify-content-center">
@if($following == true) @if($following == true)
<form class="d-inline-block" action="/i/follow" method="post"> <button
@csrf id="unfollow"
<input type="hidden" name="item" value="{{(string)$profile->id}}"> type="button"
<input type="hidden" name="force" value="0"> class="btn btn-outline-secondary btn-sm py-1 px-4 text-uppercase font-weight-bold mr-3"
<button type="submit" class="btn btn-outline-secondary btn-sm py-1 px-4 text-uppercase font-weight-bold mr-3" style="font-weight: 500">Unfollow</button> style="font-weight: 500"
</form> onclick="unfollowProfile()">
Unfollow
</button>
@else @else
<form class="d-inline-block" action="/i/follow" method="post"> <button
@csrf id="follow"
<input type="hidden" name="item" value="{{(string)$profile->id}}"> type="button"
<input type="hidden" name="force" value="0"> class="btn btn-primary btn-sm py-1 px-4 text-uppercase font-weight-bold mr-3"
<button type="submit" class="btn btn-primary btn-sm py-1 px-4 text-uppercase font-weight-bold mr-3" style="font-weight: 500">Follow</button> style="font-weight: 500"
</form> onclick="followProfile()">
Follow
</button>
@endif @endif
<a class="btn btn-outline-primary btn-sm py-1 px-4 text-uppercase font-weight-bold" href="{{$profile->url()}}" style="font-weight: 500">View Profile</a> <a class="btn btn-outline-primary btn-sm py-1 px-4 text-uppercase font-weight-bold" href="{{$profile->url()}}" style="font-weight: 500">View Profile</a>
</div> </div>
@ -51,3 +61,32 @@
</div> </div>
</div> </div>
@endsection @endsection
@push('scripts')
<script type="text/javascript">
function followProfile() {
let btn = document.querySelector('#follow');
btn.setAttribute('disabled', 'disabled');
axios.post('/api/v1/accounts/{{$profile->id}}/follow')
.then(res => {
setTimeout(() => location.reload(), 1000);
})
.catch(err => {
location.href = '/login?next=' + encodeURI(location.href);
})
}
function unfollowProfile() {
let btn = document.querySelector('#unfollow');
btn.setAttribute('disabled', 'disabled');
axios.post('/api/v1/accounts/{{$profile->id}}/unfollow')
.then(res => {
setTimeout(() => location.reload(), 1000);
})
.catch(err => {
location.href = '/login?next=' + encodeURI(location.href);
})
}
</script>
@endpush

View file

@ -1,17 +0,0 @@
@extends('layouts.app')
@section('content')
<div class="mt-md-4"></div>
<remote-post status-template="{{$status->viewType()}}" status-id="{{$status->id}}" status-username="{{$status->profile->username}}" status-url="{{$status->url()}}" status-profile-url="{{$status->profile->url()}}" status-avatar="{{$status->profile->avatarUrl()}}" status-profile-id="{{$status->profile_id}}" profile-layout="metro"></remote-post>
@endsection
@push('meta')
<meta name="robots" content="noindex, noimageindex, nofollow, nosnippet, noarchive">
@endpush
@push('scripts')
<script type="text/javascript" src="{{ mix('js/rempos.js') }}"></script>
<script type="text/javascript">App.boot()</script>
@endpush

View file

@ -189,8 +189,6 @@ Route::domain(config('pixelfed.domain.app'))->middleware(['validemail', 'twofact
Route::get('profile/{username}/status/{postid}', 'PublicApiController@status'); Route::get('profile/{username}/status/{postid}', 'PublicApiController@status');
Route::get('profile/{username}/status/{postid}/state', 'PublicApiController@statusState'); Route::get('profile/{username}/status/{postid}/state', 'PublicApiController@statusState');
Route::get('comments/{username}/status/{postId}', 'PublicApiController@statusComments'); Route::get('comments/{username}/status/{postId}', 'PublicApiController@statusComments');
Route::get('likes/profile/{username}/status/{id}', 'PublicApiController@statusLikes');
Route::get('shares/profile/{username}/status/{id}', 'PublicApiController@statusShares');
Route::get('status/{id}/replies', 'InternalApiController@statusReplies'); Route::get('status/{id}/replies', 'InternalApiController@statusReplies');
Route::post('moderator/action', 'InternalApiController@modAction'); Route::post('moderator/action', 'InternalApiController@modAction');
Route::get('discover/categories', 'InternalApiController@discoverCategories'); Route::get('discover/categories', 'InternalApiController@discoverCategories');
@ -207,8 +205,6 @@ Route::domain(config('pixelfed.domain.app'))->middleware(['validemail', 'twofact
Route::get('accounts/relationships', 'Api\ApiV1Controller@accountRelationshipsById'); Route::get('accounts/relationships', 'Api\ApiV1Controller@accountRelationshipsById');
Route::get('accounts/search', 'Api\ApiV1Controller@accountSearch'); Route::get('accounts/search', 'Api\ApiV1Controller@accountSearch');
Route::get('accounts/{id}/statuses', 'PublicApiController@accountStatuses'); Route::get('accounts/{id}/statuses', 'PublicApiController@accountStatuses');
Route::get('accounts/{id}/following', 'PublicApiController@accountFollowing');
Route::get('accounts/{id}/followers', 'PublicApiController@accountFollowers');
Route::post('accounts/{id}/block', 'Api\ApiV1Controller@accountBlockById'); Route::post('accounts/{id}/block', 'Api\ApiV1Controller@accountBlockById');
Route::post('accounts/{id}/unblock', 'Api\ApiV1Controller@accountUnblockById'); Route::post('accounts/{id}/unblock', 'Api\ApiV1Controller@accountUnblockById');
Route::get('statuses/{id}', 'PublicApiController@getStatus'); Route::get('statuses/{id}', 'PublicApiController@getStatus');
@ -236,8 +232,6 @@ Route::domain(config('pixelfed.domain.app'))->middleware(['validemail', 'twofact
Route::get('discover/profiles', 'DiscoverController@profilesDirectoryApi'); Route::get('discover/profiles', 'DiscoverController@profilesDirectoryApi');
Route::get('profile/{username}/status/{postid}', 'PublicApiController@status'); Route::get('profile/{username}/status/{postid}', 'PublicApiController@status');
Route::get('comments/{username}/status/{postId}', 'PublicApiController@statusComments'); Route::get('comments/{username}/status/{postId}', 'PublicApiController@statusComments');
Route::get('likes/profile/{username}/status/{id}', 'PublicApiController@statusLikes');
Route::get('shares/profile/{username}/status/{id}', 'PublicApiController@statusShares');
Route::post('moderator/action', 'InternalApiController@modAction'); Route::post('moderator/action', 'InternalApiController@modAction');
Route::get('discover/categories', 'InternalApiController@discoverCategories'); Route::get('discover/categories', 'InternalApiController@discoverCategories');
Route::get('loops', 'DiscoverController@loopsApi'); Route::get('loops', 'DiscoverController@loopsApi');