diff --git a/app/Http/Controllers/Admin/AdminGroupsController.php b/app/Http/Controllers/Admin/AdminGroupsController.php new file mode 100644 index 000000000..45a4fd266 --- /dev/null +++ b/app/Http/Controllers/Admin/AdminGroupsController.php @@ -0,0 +1,49 @@ +groupAdminStats(); + + return view('admin.groups.home', compact('stats')); + } + + protected function groupAdminStats() + { + return Cache::remember('admin:groups:stats', 3, function () { + $res = [ + 'total' => Group::count(), + 'local' => Group::whereLocal(true)->count(), + ]; + + $res['remote'] = $res['total'] - $res['local']; + $res['categories'] = GroupCategory::count(); + $res['posts'] = GroupPost::count(); + $res['members'] = GroupMember::count(); + $res['interactions'] = GroupInteraction::count(); + $res['reports'] = GroupReport::count(); + + $res['local_30d'] = Cache::remember('admin:groups:stats:local_30d', 43200, function () { + return Group::whereLocal(true)->where('created_at', '>', now()->subMonth())->count(); + }); + + $res['remote_30d'] = Cache::remember('admin:groups:stats:remote_30d', 43200, function () { + return Group::whereLocal(false)->where('created_at', '>', now()->subMonth())->count(); + }); + + return $res; + }); + } +} diff --git a/app/Http/Controllers/GroupController.php b/app/Http/Controllers/GroupController.php new file mode 100644 index 000000000..881d31f01 --- /dev/null +++ b/app/Http/Controllers/GroupController.php @@ -0,0 +1,671 @@ +middleware('auth'); + abort_unless(config('groups.enabled'), 404); + } + + public function index(Request $request) + { + abort_if(! $request->user(), 404); + + return view('layouts.spa'); + } + + public function home(Request $request) + { + abort_if(! $request->user(), 404); + + return view('layouts.spa'); + } + + public function show(Request $request, $id, $path = false) + { + $group = Group::find($id); + + if (! $group || $group->status) { + return response()->view('groups.unavailable')->setStatusCode(404); + } + + if ($request->wantsJson()) { + return $this->showGroupObject($group); + } + + return view('layouts.spa', compact('id', 'path')); + } + + public function showStatus(Request $request, $gid, $sid) + { + $group = Group::find($gid); + $pid = optional($request->user())->profile_id ?? false; + + if (! $group || $group->status) { + return response()->view('groups.unavailable')->setStatusCode(404); + } + + if ($group->is_private) { + abort_if(! $request->user(), 404); + abort_if(! $group->isMember($pid), 404); + } + + $gp = GroupPost::whereGroupId($gid) + ->findOrFail($sid); + + return view('layouts.spa', compact('group', 'gp')); + } + + public function getGroup(Request $request, $id) + { + $group = Group::whereNull('status')->findOrFail($id); + $pid = optional($request->user())->profile_id ?? false; + + $group = $this->toJson($group, $pid); + + return response()->json($group, 200, [], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + } + + public function showStatusLikes(Request $request, $id, $sid) + { + $group = Group::findOrFail($id); + $user = $request->user(); + $pid = $user->profile_id; + abort_if(! $group->isMember($pid), 404); + $status = GroupPost::whereGroupId($id)->findOrFail($sid); + $likes = GroupLike::whereStatusId($sid) + ->cursorPaginate(10) + ->map(function ($l) use ($group) { + $account = AccountService::get($l->profile_id); + $account['url'] = "/groups/{$group->id}/user/{$account['id']}"; + + return $account; + }) + ->filter(function ($l) { + return $l && isset($l['id']); + }) + ->values(); + + return $likes; + } + + public function groupSettings(Request $request, $id) + { + abort_if(! $request->user(), 404); + $group = Group::findOrFail($id); + $pid = $request->user()->profile_id; + abort_if(! $group->isMember($pid), 404); + abort_if(! in_array($group->selfRole($pid), ['founder', 'admin']), 404); + + return view('groups.settings', compact('group')); + } + + public function joinGroup(Request $request, $id) + { + $group = Group::findOrFail($id); + $pid = $request->user()->profile_id; + abort_if($group->isMember($pid), 404); + + if (! $request->user()->is_admin) { + abort_if(GroupService::getRejoinTimeout($group->id, $pid), 422, 'Cannot re-join this group for 24 hours after leaving or cancelling a request to join'); + } + + $member = new GroupMember; + $member->group_id = $group->id; + $member->profile_id = $pid; + $member->role = 'member'; + $member->local_group = true; + $member->local_profile = true; + $member->join_request = $group->is_private; + $member->save(); + + GroupService::delSelf($group->id, $pid); + GroupService::log( + $group->id, + $pid, + 'group:joined', + null, + GroupMember::class, + $member->id + ); + + $group = $this->toJson($group, $pid); + + return $group; + } + + public function updateGroup(Request $request, $id) + { + $this->validate($request, [ + 'description' => 'nullable|max:500', + 'membership' => 'required|in:all,local,private', + 'avatar' => 'nullable', + 'header' => 'nullable', + 'discoverable' => 'required', + 'activitypub' => 'required', + 'is_nsfw' => 'required', + 'category' => 'required|string|in:'.implode(',', GroupService::categories()), + ]); + + $pid = $request->user()->profile_id; + $group = Group::whereProfileId($pid)->findOrFail($id); + $member = GroupMember::whereGroupId($group->id)->whereProfileId($pid)->firstOrFail(); + + abort_if($member->role != 'founder', 403, 'Invalid group permission'); + + $metadata = $group->metadata; + $len = $group->is_private ? 12 : 4; + + if ($request->hasFile('avatar')) { + $avatar = $request->file('avatar'); + + if ($avatar) { + if (isset($metadata['avatar']) && + isset($metadata['avatar']['path']) && + Storage::exists($metadata['avatar']['path']) + ) { + Storage::delete($metadata['avatar']['path']); + } + + $fileName = 'avatar_'.strtolower(str_random($len)).'.'.$avatar->extension(); + $path = $avatar->storePubliclyAs('public/g/'.$group->id.'/meta', $fileName); + $url = url(Storage::url($path)); + $metadata['avatar'] = [ + 'path' => $path, + 'url' => $url, + 'updated_at' => now(), + ]; + } + } + + if ($request->hasFile('header')) { + $header = $request->file('header'); + + if ($header) { + if (isset($metadata['header']) && + isset($metadata['header']['path']) && + Storage::exists($metadata['header']['path']) + ) { + Storage::delete($metadata['header']['path']); + } + + $fileName = 'header_'.strtolower(str_random($len)).'.'.$header->extension(); + $path = $header->storePubliclyAs('public/g/'.$group->id.'/meta', $fileName); + $url = url(Storage::url($path)); + $metadata['header'] = [ + 'path' => $path, + 'url' => $url, + 'updated_at' => now(), + ]; + } + } + + $cat = GroupService::categoryById($group->category_id); + if ($request->category !== $cat['name']) { + $group->category_id = GroupCategory::whereName($request->category)->first()->id; + } + + $changes = null; + $group->description = e($request->input('description', null)); + $group->is_private = $request->input('membership') == 'private'; + $group->local_only = $request->input('membership') == 'local'; + $group->activitypub = $request->input('activitypub') == 'true'; + $group->discoverable = $request->input('discoverable') == 'true'; + $group->is_nsfw = $request->input('is_nsfw') == 'true'; + $group->metadata = $metadata; + if ($group->isDirty()) { + $changes = $group->getDirty(); + } + $group->save(); + + GroupService::log( + $group->id, + $pid, + 'group:settings:updated', + $changes + ); + + GroupService::del($group->id); + + $res = $this->toJson($group, $pid); + + return $res; + } + + protected function toJson($group, $pid = false) + { + return GroupService::get($group->id, $pid); + } + + public function groupLeave(Request $request, $id) + { + abort_if(! $request->user(), 404); + + $pid = $request->user()->profile_id; + $group = Group::findOrFail($id); + + abort_if($pid == $group->profile_id, 422, 'Cannot leave a group you created'); + + abort_if(! $group->isMember($pid), 403, 'Not a member of group.'); + + GroupMember::whereGroupId($group->id)->whereProfileId($pid)->delete(); + GroupService::del($group->id); + GroupService::delSelf($group->id, $pid); + GroupService::setRejoinTimeout($group->id, $pid); + + return [200]; + } + + public function cancelJoinRequest(Request $request, $id) + { + abort_if(! $request->user(), 404); + + $pid = $request->user()->profile_id; + $group = Group::findOrFail($id); + + abort_if($pid == $group->profile_id, 422, 'Cannot leave a group you created'); + abort_if($group->isMember($pid), 422, 'Cannot cancel approved join request, please leave group instead.'); + + GroupMember::whereGroupId($group->id)->whereProfileId($pid)->delete(); + GroupService::del($group->id); + GroupService::delSelf($group->id, $pid); + GroupService::setRejoinTimeout($group->id, $pid); + + return [200]; + } + + public function metaBlockSearch(Request $request, $id) + { + abort_if(! $request->user(), 404); + $group = Group::findOrFail($id); + $pid = $request->user()->profile_id; + abort_if(! $group->isMember($pid), 404); + abort_if(! in_array($group->selfRole($pid), ['founder', 'admin']), 404); + + $type = $request->input('type'); + $item = $request->input('item'); + + switch ($type) { + case 'instance': + $res = Instance::whereDomain($item)->first(); + if ($res) { + abort_if(GroupBlock::whereGroupId($group->id)->whereInstanceId($res->id)->exists(), 400); + } + break; + + case 'user': + $res = Profile::whereUsername($item)->first(); + if ($res) { + abort_if(GroupBlock::whereGroupId($group->id)->whereProfileId($res->id)->exists(), 400); + } + if ($res->user_id != null) { + abort_if(User::whereIsAdmin(true)->whereId($res->user_id)->exists(), 400); + } + break; + } + + return response()->json((bool) $res, ($res ? 200 : 404)); + } + + public function reportCreate(Request $request, $id) + { + abort_if(! $request->user(), 404); + $group = Group::findOrFail($id); + $pid = $request->user()->profile_id; + abort_if(! $group->isMember($pid), 404); + + $id = $request->input('id'); + $type = $request->input('type'); + $types = [ + // original 3 + 'spam', + 'sensitive', + 'abusive', + + // new + 'underage', + 'violence', + 'copyright', + 'impersonation', + 'scam', + 'terrorism', + ]; + + $gp = GroupPost::whereGroupId($group->id)->find($id); + abort_if(! $gp, 422, 'Cannot report an invalid or deleted post'); + abort_if(! in_array($type, $types), 422, 'Invalid report type'); + abort_if($gp->profile_id === $pid, 422, 'Cannot report your own post'); + abort_if( + GroupReport::whereGroupId($group->id) + ->whereProfileId($pid) + ->whereItemType(GroupPost::class) + ->whereItemId($id) + ->exists(), + 422, + 'You already reported this' + ); + + $report = new GroupReport(); + $report->group_id = $group->id; + $report->profile_id = $pid; + $report->type = $type; + $report->item_type = GroupPost::class; + $report->item_id = $id; + $report->open = true; + $report->save(); + + GroupService::log( + $group->id, + $pid, + 'group:report:create', + [ + 'type' => $type, + 'report_id' => $report->id, + 'status_id' => $gp->status_id, + 'profile_id' => $gp->profile_id, + 'username' => optional(AccountService::get($gp->profile_id))['acct'], + 'gpid' => $gp->id, + 'url' => $gp->url(), + ], + GroupReport::class, + $report->id + ); + + return response([200]); + } + + public function reportAction(Request $request, $id) + { + abort_if(! $request->user(), 404); + $group = Group::findOrFail($id); + $pid = $request->user()->profile_id; + abort_if(! $group->isMember($pid), 404); + abort_if(! in_array($group->selfRole($pid), ['founder', 'admin']), 404); + + $this->validate($request, [ + 'action' => 'required|in:cw,delete,ignore', + 'id' => 'required|string', + ]); + + $action = $request->input('action'); + $id = $request->input('id'); + + $report = GroupReport::whereGroupId($group->id) + ->findOrFail($id); + $status = Status::findOrFail($report->item_id); + $gp = GroupPost::whereGroupId($group->id) + ->whereStatusId($status->id) + ->firstOrFail(); + + switch ($action) { + case 'cw': + $status->is_nsfw = true; + $status->save(); + StatusService::del($status->id); + + GroupReport::whereGroupId($group->id) + ->whereItemType($report->item_type) + ->whereItemId($report->item_id) + ->update(['open' => false]); + + GroupService::log( + $group->id, + $pid, + 'group:moderation:action', + [ + 'type' => 'cw', + 'report_id' => $report->id, + 'status_id' => $status->id, + 'profile_id' => $status->profile_id, + 'status_url' => $gp->url(), + ], + GroupReport::class, + $report->id + ); + + return response()->json([200]); + break; + + case 'ignore': + GroupReport::whereGroupId($group->id) + ->whereItemType($report->item_type) + ->whereItemId($report->item_id) + ->update(['open' => false]); + + GroupService::log( + $group->id, + $pid, + 'group:moderation:action', + [ + 'type' => 'ignore', + 'report_id' => $report->id, + 'status_id' => $status->id, + 'profile_id' => $status->profile_id, + 'status_url' => $gp->url(), + ], + GroupReport::class, + $report->id + ); + + return response()->json([200]); + break; + } + } + + public function getMemberInteractionLimits(Request $request, $id) + { + abort_if(! $request->user(), 404); + $group = Group::findOrFail($id); + $pid = $request->user()->profile_id; + abort_if(! $group->isMember($pid), 404); + abort_if(! in_array($group->selfRole($pid), ['founder', 'admin']), 404); + + $profile_id = $request->input('profile_id'); + abort_if(! $group->isMember($profile_id), 404); + $limits = GroupService::getInteractionLimits($group->id, $profile_id); + + return response()->json($limits); + } + + public function updateMemberInteractionLimits(Request $request, $id) + { + abort_if(! $request->user(), 404); + $group = Group::findOrFail($id); + $pid = $request->user()->profile_id; + abort_if(! $group->isMember($pid), 404); + abort_if(! in_array($group->selfRole($pid), ['founder', 'admin']), 404); + + $this->validate($request, [ + 'profile_id' => 'required|exists:profiles,id', + 'can_post' => 'required', + 'can_comment' => 'required', + 'can_like' => 'required', + ]); + + $member = $request->input('profile_id'); + $can_post = $request->input('can_post'); + $can_comment = $request->input('can_comment'); + $can_like = $request->input('can_like'); + $account = AccountService::get($member); + + abort_if(! $account, 422, 'Invalid profile'); + abort_if(! $group->isMember($member), 422, 'Invalid profile'); + + $limit = GroupLimit::firstOrCreate([ + 'profile_id' => $member, + 'group_id' => $group->id, + ]); + + if ($limit->wasRecentlyCreated) { + abort_if(GroupLimit::whereGroupId($group->id)->count() >= 25, 422, 'limit_reached'); + } + + $previousLimits = $limit->limits; + + $limit->limits = [ + 'can_post' => $can_post, + 'can_comment' => $can_comment, + 'can_like' => $can_like, + ]; + $limit->save(); + + GroupService::clearInteractionLimits($group->id, $member); + + GroupService::log( + $group->id, + $pid, + 'group:member-limits:updated', + [ + 'profile_id' => $account['id'], + 'username' => $account['username'], + 'previousLimits' => $previousLimits, + 'newLimits' => $limit->limits, + ], + GroupLimit::class, + $limit->id + ); + + return $request->all(); + } + + public function showProfile(Request $request, $id, $pid) + { + $group = Group::find($id); + + if (! $group || $group->status) { + return response()->view('groups.unavailable')->setStatusCode(404); + } + + return view('layouts.spa'); + } + + public function showProfileByUsername(Request $request, $id, $pid) + { + abort_if(! $request->user(), 404); + if (! $request->user()) { + return redirect("/{$pid}"); + } + + $group = Group::find($id); + $cid = $request->user()->profile_id; + + if (! $group || $group->status) { + return response()->view('groups.unavailable')->setStatusCode(404); + } + + if (! $group->isMember($cid)) { + return redirect("/{$pid}"); + } + + $profile = Profile::whereUsername($pid)->first(); + + if (! $group->isMember($profile->id)) { + return redirect("/{$pid}"); + } + + if ($profile) { + $url = url("/groups/{$id}/user/{$profile->id}"); + + return redirect($url); + } + + abort(404, 'Invalid username'); + } + + public function groupInviteLanding(Request $request, $id) + { + abort(404, 'Not yet implemented'); + $group = Group::findOrFail($id); + + return view('groups.invite', compact('group')); + } + + public function groupShortLinkRedirect(Request $request, $hid) + { + $gid = HashidService::decode($hid); + $group = Group::findOrFail($gid); + + return redirect($group->url()); + } + + public function groupInviteClaim(Request $request, $id) + { + $group = GroupService::get($id); + abort_if(! $group || empty($group), 404); + + return view('groups.invite-claim', compact('group')); + } + + public function groupMemberInviteCheck(Request $request, $id) + { + abort_if(! $request->user(), 404); + $pid = $request->user()->profile_id; + $group = Group::findOrFail($id); + abort_if($group->isMember($pid), 422, 'Already a member'); + + $exists = GroupInvitation::whereGroupId($id)->whereToProfileId($pid)->exists(); + + return response()->json([ + 'gid' => $id, + 'can_join' => (bool) $exists, + ]); + } + + public function groupMemberInviteAccept(Request $request, $id) + { + abort_if(! $request->user(), 404); + $pid = $request->user()->profile_id; + $group = Group::findOrFail($id); + abort_if($group->isMember($pid), 422, 'Already a member'); + + abort_if(! GroupInvitation::whereGroupId($id)->whereToProfileId($pid)->exists(), 422); + + $gm = new GroupMember; + $gm->group_id = $id; + $gm->profile_id = $pid; + $gm->role = 'member'; + $gm->local_group = $group->local; + $gm->local_profile = true; + $gm->join_request = false; + $gm->save(); + + GroupInvitation::whereGroupId($id)->whereToProfileId($pid)->delete(); + GroupService::del($id); + GroupService::delSelf($id, $pid); + + return ['next_url' => $group->url()]; + } + + public function groupMemberInviteDecline(Request $request, $id) + { + abort_if(! $request->user(), 404); + $pid = $request->user()->profile_id; + $group = Group::findOrFail($id); + abort_if($group->isMember($pid), 422, 'Already a member'); + + return ['next_url' => '/']; + } +} diff --git a/app/Http/Controllers/GroupFederationController.php b/app/Http/Controllers/GroupFederationController.php new file mode 100644 index 000000000..7f45f74a4 --- /dev/null +++ b/app/Http/Controllers/GroupFederationController.php @@ -0,0 +1,103 @@ +whereActivitypub(true)->findOrFail($id); + $res = $this->showGroupObject($group); + return response()->json($res, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES); + } + + public function showGroupObject($group) + { + return Cache::remember('ap:groups:object:' . $group->id, 3600, function() use($group) { + return [ + '@context' => 'https://www.w3.org/ns/activitystreams', + 'id' => $group->url(), + 'inbox' => $group->permalink('/inbox'), + 'name' => $group->name, + 'outbox' => $group->permalink('/outbox'), + 'summary' => $group->description, + 'type' => 'Group', + 'attributedTo' => [ + 'type' => 'Person', + 'id' => $group->admin->permalink() + ], + // 'endpoints' => [ + // 'sharedInbox' => config('app.url') . '/f/inbox' + // ], + 'preferredUsername' => 'gid_' . $group->id, + 'publicKey' => [ + 'id' => $group->permalink('#main-key'), + 'owner' => $group->permalink(), + 'publicKeyPem' => InstanceActor::first()->public_key, + ], + 'url' => $group->permalink() + ]; + + if($group->metadata && isset($group->metadata['avatar'])) { + $res['icon'] = [ + 'type' => 'Image', + 'url' => $group->metadata['avatar']['url'] + ]; + } + + if($group->metadata && isset($group->metadata['header'])) { + $res['image'] = [ + 'type' => 'Image', + 'url' => $group->metadata['header']['url'] + ]; + } + ksort($res); + return $res; + }); + } + + public function getStatusObject(Request $request, $gid, $sid) + { + $group = Group::whereLocal(true)->whereActivitypub(true)->findOrFail($gid); + $gp = GroupPost::whereGroupId($gid)->findOrFail($sid); + $status = Status::findOrFail($gp->status_id); + // permission check + + $res = [ + '@context' => 'https://www.w3.org/ns/activitystreams', + 'id' => $gp->url(), + + 'type' => 'Note', + + 'summary' => null, + 'content' => $status->rendered ?? $status->caption, + 'inReplyTo' => null, + + 'published' => $status->created_at->toAtomString(), + 'url' => $gp->url(), + 'attributedTo' => $status->profile->permalink(), + 'to' => [ + 'https://www.w3.org/ns/activitystreams#Public', + $group->permalink('/followers'), + ], + 'cc' => [], + 'sensitive' => (bool) $status->is_nsfw, + 'attachment' => MediaService::activitypub($status->id), + 'target' => [ + 'type' => 'Collection', + 'id' => $group->permalink('/wall'), + 'attributedTo' => $group->permalink() + ] + ]; + // ksort($res); + return response()->json($res, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES); + } +} diff --git a/app/Http/Controllers/GroupPostController.php b/app/Http/Controllers/GroupPostController.php new file mode 100644 index 000000000..909037a00 --- /dev/null +++ b/app/Http/Controllers/GroupPostController.php @@ -0,0 +1,10 @@ +middleware('auth'); + } + + public function checkCreatePermission(Request $request) + { + abort_if(!$request->user(), 404); + $pid = $request->user()->profile_id; + $config = GroupService::config(); + if($request->user()->is_admin) { + $allowed = true; + } else { + $max = $config['limits']['user']['create']['max']; + $allowed = Group::whereProfileId($pid)->count() <= $max; + } + + return ['permission' => (bool) $allowed]; + } + + public function storeGroup(Request $request) + { + abort_if(!$request->user(), 404); + + $this->validate($request, [ + 'name' => 'required', + 'description' => 'nullable|max:500', + 'membership' => 'required|in:public,private,local' + ]); + + $pid = $request->user()->profile_id; + + $config = GroupService::config(); + abort_if($config['limits']['user']['create']['new'] == false && $request->user()->is_admin == false, 422, 'Invalid operation'); + $max = $config['limits']['user']['create']['max']; + // abort_if(Group::whereProfileId($pid)->count() <= $max, 422, 'Group limit reached'); + + $group = new Group; + $group->profile_id = $pid; + $group->name = $request->input('name'); + $group->description = $request->input('description', null); + $group->is_private = $request->input('membership') == 'private'; + $group->local_only = $request->input('membership') == 'local'; + $group->metadata = $request->input('configuration'); + $group->save(); + + GroupService::log($group->id, $pid, 'group:created'); + + $member = new GroupMember; + $member->group_id = $group->id; + $member->profile_id = $pid; + $member->role = 'founder'; + $member->local_group = true; + $member->local_profile = true; + $member->save(); + + GroupService::log( + $group->id, + $pid, + 'group:joined', + null, + GroupMember::class, + $member->id + ); + + return [ + 'id' => $group->id, + 'url' => $group->url() + ]; + } +} diff --git a/app/Http/Controllers/Groups/GroupsAdminController.php b/app/Http/Controllers/Groups/GroupsAdminController.php new file mode 100644 index 000000000..4bdf0f504 --- /dev/null +++ b/app/Http/Controllers/Groups/GroupsAdminController.php @@ -0,0 +1,353 @@ +middleware('auth'); + } + + public function getAdminTabs(Request $request, $id) + { + abort_if(!$request->user(), 404); + $group = Group::findOrFail($id); + $pid = $request->user()->profile_id; + abort_if(!$group->isMember($pid), 404); + abort_if(!in_array($group->selfRole($pid), ['founder', 'admin']), 404); + abort_if($pid !== $group->profile_id, 404); + + $reqs = GroupMember::whereGroupId($group->id)->whereJoinRequest(true)->count(); + $mods = GroupReport::whereGroupId($group->id)->whereOpen(true)->count(); + $tabs = [ + 'moderation_count' => $mods > 99 ? '99+' : $mods, + 'request_count' => $reqs > 99 ? '99+' : $reqs + ]; + + return response()->json($tabs); + } + + public function getInteractionLogs(Request $request, $id) + { + abort_if(!$request->user(), 404); + $group = Group::findOrFail($id); + $pid = $request->user()->profile_id; + abort_if(!$group->isMember($pid), 404); + abort_if(!in_array($group->selfRole($pid), ['founder', 'admin']), 404); + + $logs = GroupInteraction::whereGroupId($id) + ->latest() + ->paginate(10) + ->map(function($log) use($group) { + return [ + 'id' => $log->id, + 'profile' => GroupAccountService::get($group->id, $log->profile_id), + 'type' => $log->type, + 'metadata' => $log->metadata, + 'created_at' => $log->created_at->format('c') + ]; + }); + + return response()->json($logs, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES); + } + + public function getBlocks(Request $request, $id) + { + abort_if(!$request->user(), 404); + $group = Group::findOrFail($id); + $pid = $request->user()->profile_id; + abort_if(!$group->isMember($pid), 404); + abort_if(!in_array($group->selfRole($pid), ['founder', 'admin']), 404); + + $blocks = [ + 'instances' => GroupBlock::whereGroupId($group->id)->whereNotNull('instance_id')->whereModerated(false)->latest()->take(3)->pluck('name'), + 'users' => GroupBlock::whereGroupId($group->id)->whereNotNull('profile_id')->whereIsUser(true)->latest()->take(3)->pluck('name'), + 'moderated' => GroupBlock::whereGroupId($group->id)->whereNotNull('instance_id')->whereModerated(true)->latest()->take(3)->pluck('name') + ]; + + return response()->json($blocks, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES); + } + + public function exportBlocks(Request $request, $id) + { + abort_if(!$request->user(), 404); + $group = Group::findOrFail($id); + $pid = $request->user()->profile_id; + abort_if(!$group->isMember($pid), 404); + abort_if(!in_array($group->selfRole($pid), ['founder', 'admin']), 404); + + $blocks = [ + 'instances' => GroupBlock::whereGroupId($group->id)->whereNotNull('instance_id')->whereModerated(false)->latest()->pluck('name'), + 'users' => GroupBlock::whereGroupId($group->id)->whereNotNull('profile_id')->whereIsUser(true)->latest()->pluck('name'), + 'moderated' => GroupBlock::whereGroupId($group->id)->whereNotNull('instance_id')->whereModerated(true)->latest()->pluck('name') + ]; + + $blocks['_created_at'] = now()->format('c'); + $blocks['_version'] = '1.0.0'; + ksort($blocks); + + return response()->streamDownload(function() use($blocks) { + echo json_encode($blocks, JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES); + }); + } + + public function addBlock(Request $request, $id) + { + abort_if(!$request->user(), 404); + $group = Group::findOrFail($id); + $pid = $request->user()->profile_id; + abort_if(!$group->isMember($pid), 404); + abort_if(!in_array($group->selfRole($pid), ['founder', 'admin']), 404); + + $this->validate($request, [ + 'item' => 'required', + 'type' => 'required|in:instance,user,moderate' + ]); + + $item = $request->input('item'); + $type = $request->input('type'); + + switch($type) { + case 'instance': + $instance = Instance::whereDomain($item)->first(); + abort_if(!$instance, 422, 'This domain either isn\'nt known or is invalid'); + $gb = new GroupBlock; + $gb->group_id = $group->id; + $gb->admin_id = $pid; + $gb->instance_id = $instance->id; + $gb->name = $instance->domain; + $gb->is_user = false; + $gb->moderated = false; + $gb->save(); + + GroupService::log( + $group->id, + $pid, + 'group:admin:block:instance', + [ + 'domain' => $instance->domain + ], + GroupBlock::class, + $gb->id + ); + + return [200]; + break; + + case 'user': + $profile = Profile::whereUsername($item)->first(); + abort_if(!$profile, 422, 'This user either isn\'nt known or is invalid'); + $gb = new GroupBlock; + $gb->group_id = $group->id; + $gb->admin_id = $pid; + $gb->profile_id = $profile->id; + $gb->name = $profile->username; + $gb->is_user = true; + $gb->moderated = false; + $gb->save(); + + GroupService::log( + $group->id, + $pid, + 'group:admin:block:user', + [ + 'username' => $profile->username, + 'domain' => $profile->domain + ], + GroupBlock::class, + $gb->id + ); + + return [200]; + break; + + case 'moderate': + $instance = Instance::whereDomain($item)->first(); + abort_if(!$instance, 422, 'This domain either isn\'nt known or is invalid'); + $gb = new GroupBlock; + $gb->group_id = $group->id; + $gb->admin_id = $pid; + $gb->instance_id = $instance->id; + $gb->name = $instance->domain; + $gb->is_user = false; + $gb->moderated = true; + $gb->save(); + + GroupService::log( + $group->id, + $pid, + 'group:admin:moderate:instance', + [ + 'domain' => $instance->domain + ], + GroupBlock::class, + $gb->id + ); + + return [200]; + break; + + default: + return response()->json([], 422, []); + break; + } + } + + public function undoBlock(Request $request, $id) + { + abort_if(!$request->user(), 404); + $group = Group::findOrFail($id); + $pid = $request->user()->profile_id; + abort_if(!$group->isMember($pid), 404); + abort_if(!in_array($group->selfRole($pid), ['founder', 'admin']), 404); + + $this->validate($request, [ + 'item' => 'required', + 'type' => 'required|in:instance,user,moderate' + ]); + + $item = $request->input('item'); + $type = $request->input('type'); + + switch($type) { + case 'instance': + $instance = Instance::whereDomain($item)->first(); + abort_if(!$instance, 422, 'This domain either isn\'nt known or is invalid'); + + $gb = GroupBlock::whereGroupId($group->id) + ->whereInstanceId($instance->id) + ->whereModerated(false) + ->first(); + + abort_if(!$gb, 422, 'Invalid group block'); + + GroupService::log( + $group->id, + $pid, + 'group:admin:unblock:instance', + [ + 'domain' => $instance->domain + ], + GroupBlock::class, + $gb->id + ); + + $gb->delete(); + + return [200]; + break; + + case 'user': + $profile = Profile::whereUsername($item)->first(); + abort_if(!$profile, 422, 'This user either isn\'nt known or is invalid'); + + $gb = GroupBlock::whereGroupId($group->id) + ->whereProfileId($profile->id) + ->whereIsUser(true) + ->first(); + + abort_if(!$gb, 422, 'Invalid group block'); + + GroupService::log( + $group->id, + $pid, + 'group:admin:unblock:user', + [ + 'username' => $profile->username, + 'domain' => $profile->domain + ], + GroupBlock::class, + $gb->id + ); + + $gb->delete(); + + return [200]; + break; + + case 'moderate': + $instance = Instance::whereDomain($item)->first(); + abort_if(!$instance, 422, 'This domain either isn\'nt known or is invalid'); + + $gb = GroupBlock::whereGroupId($group->id) + ->whereInstanceId($instance->id) + ->whereModerated(true) + ->first(); + + abort_if(!$gb, 422, 'Invalid group block'); + + GroupService::log( + $group->id, + $pid, + 'group:admin:moderate:instance', + [ + 'domain' => $instance->domain + ], + GroupBlock::class, + $gb->id + ); + + $gb->delete(); + + return [200]; + break; + + default: + return response()->json([], 422, []); + break; + } + } + + public function getReportList(Request $request, $id) + { + abort_if(!$request->user(), 404); + $group = Group::findOrFail($id); + $pid = $request->user()->profile_id; + abort_if(!$group->isMember($pid), 404); + abort_if(!in_array($group->selfRole($pid), ['founder', 'admin']), 404); + + $scope = $request->input('scope', 'open'); + + $list = GroupReport::selectRaw('id, profile_id, item_type, item_id, type, created_at, count(*) as total') + ->whereGroupId($group->id) + ->groupBy('item_id') + ->when($scope == 'open', function($query, $scope) { + return $query->whereOpen(true); + }) + ->latest() + ->simplePaginate(10) + ->map(function($report) use($group) { + $res = [ + 'id' => (string) $report->id, + 'profile' => GroupAccountService::get($group->id, $report->profile_id), + 'type' => $report->type, + 'created_at' => $report->created_at->format('c'), + 'total_count' => $report->total + ]; + + if($report->item_type === GroupPost::class) { + $res['status'] = GroupPostService::get($group->id, $report->item_id); + } + + return $res; + }); + return response()->json($list, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES); + } + +} diff --git a/app/Http/Controllers/Groups/GroupsApiController.php b/app/Http/Controllers/Groups/GroupsApiController.php new file mode 100644 index 000000000..13bbca640 --- /dev/null +++ b/app/Http/Controllers/Groups/GroupsApiController.php @@ -0,0 +1,84 @@ +middleware('auth'); + } + + protected function toJson($group, $pid = false) + { + return GroupService::get($group->id, $pid); + } + + public function getConfig(Request $request) + { + return GroupService::config(); + } + + public function getGroupAccount(Request $request, $gid, $pid) + { + $res = GroupAccountService::get($gid, $pid); + + return response()->json($res); + } + + public function getGroupCategories(Request $request) + { + $res = GroupService::categories(); + return response()->json($res, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES); + } + + public function getGroupsByCategory(Request $request) + { + $name = $request->input('name'); + $category = GroupCategory::whereName($name)->firstOrFail(); + $groups = Group::whereCategoryId($category->id) + ->simplePaginate(6) + ->map(function($group) { + return GroupService::get($group->id); + }) + ->filter(function($group) { + return $group; + }) + ->values(); + return $groups; + } + + public function getRecommendedGroups(Request $request) + { + return []; + } + + public function getSelfGroups(Request $request) + { + $selfOnly = $request->input('self') == true; + $memberOnly = $request->input('member') == true; + $pid = $request->user()->profile_id; + $res = GroupMember::whereProfileId($request->user()->profile_id) + ->when($selfOnly, function($q, $selfOnly) { + return $q->whereRole('founder'); + }) + ->when($memberOnly, function($q, $memberOnly) { + return $q->whereRole('member'); + }) + ->simplePaginate(4) + ->map(function($member) use($pid) { + $group = $member->group; + return $this->toJson($group, $pid); + }); + + return response()->json($res); + } +} diff --git a/app/Http/Controllers/Groups/GroupsCommentController.php b/app/Http/Controllers/Groups/GroupsCommentController.php new file mode 100644 index 000000000..435ed0d78 --- /dev/null +++ b/app/Http/Controllers/Groups/GroupsCommentController.php @@ -0,0 +1,361 @@ +validate($request, [ + 'gid' => 'required', + 'sid' => 'required', + 'cid' => 'sometimes', + 'limit' => 'nullable|integer|min:3|max:10' + ]); + + $pid = optional($request->user())->profile_id; + $gid = $request->input('gid'); + $sid = $request->input('sid'); + $cid = $request->has('cid') && $request->input('cid') == 1; + $limit = $request->input('limit', 3); + $maxId = $request->input('max_id', 0); + + $group = Group::findOrFail($gid); + + abort_if($group->is_private && !$group->isMember($pid), 403, 'Not a member of group.'); + + $status = $cid ? GroupComment::findOrFail($sid) : GroupPost::findOrFail($sid); + + abort_if($status->group_id != $group->id, 400, 'Invalid group'); + + $replies = GroupComment::whereGroupId($group->id) + ->whereStatusId($status->id) + ->orderByDesc('id') + ->when($maxId, function($query, $maxId) { + return $query->where('id', '<', $maxId); + }) + ->take($limit) + ->get() + ->map(function($gp) use($pid) { + $status = GroupCommentService::get($gp['group_id'], $gp['id']); + $status['reply_count'] = $gp['reply_count']; + $status['url'] = $gp->url(); + $status['favourited'] = (bool) GroupsLikeService::liked($pid, $gp['id']); + $status['account']['url'] = url("/groups/{$gp['group_id']}/user/{$gp['profile_id']}"); + return $status; + }); + + return $replies->toArray(); + } + + public function storeComment(Request $request) + { + $this->validate($request, [ + 'gid' => 'required|exists:groups,id', + 'sid' => 'required|exists:group_posts,id', + 'cid' => 'sometimes', + 'content' => 'required|string|min:1|max:1500' + ]); + + $pid = $request->user()->profile_id; + $gid = $request->input('gid'); + $sid = $request->input('sid'); + $cid = $request->input('cid'); + $limit = $request->input('limit', 3); + $caption = e($request->input('content')); + + $group = Group::findOrFail($gid); + + abort_if(!$group->isMember($pid), 403, 'Not a member of group.'); + abort_if(!GroupService::canComment($gid, $pid), 422, 'You cannot interact with this content at this time'); + + + $parent = $cid == 1 ? + GroupComment::findOrFail($sid) : + GroupPost::whereGroupId($gid)->findOrFail($sid); + // $autolink = Purify::clean(Autolink::create()->autolink($caption)); + // $autolink = str_replace('/discover/tags/', '/groups/' . $gid . '/topics/', $autolink); + + $status = new GroupComment; + $status->group_id = $group->id; + $status->profile_id = $pid; + $status->status_id = $parent->id; + $status->caption = Purify::clean($caption); + $status->visibility = 'public'; + $status->is_nsfw = false; + $status->local = true; + $status->save(); + + NewCommentPipeline::dispatch($parent, $status)->onQueue('groups'); + // todo: perform in job + $parent->reply_count = $parent->reply_count ? $parent->reply_count + $parent->reply_count : 1; + $parent->save(); + GroupPostService::del($parent->group_id, $parent->id); + + GroupService::log( + $group->id, + $pid, + 'group:comment:created', + [ + 'type' => 'group:post:comment', + 'status_id' => $status->id + ], + GroupPost::class, + $status->id + ); + + //GroupCommentPipeline::dispatch($parent, $status, $gp); + //NewStatusPipeline::dispatch($status, $gp); + //GroupPostService::del($group->id, GroupService::sidToGid($group->id, $parent->id)); + + // todo: perform in job + $s = GroupCommentService::get($status->group_id, $status->id); + + $s['pf_type'] = 'text'; + $s['visibility'] = 'public'; + $s['url'] = $status->url(); + + return $s; + } + + public function storeCommentPhoto(Request $request) + { + $this->validate($request, [ + 'gid' => 'required|exists:groups,id', + 'sid' => 'required|exists:group_posts,id', + 'photo' => 'required|image' + ]); + + $pid = $request->user()->profile_id; + $gid = $request->input('gid'); + $sid = $request->input('sid'); + $limit = $request->input('limit', 3); + $caption = $request->input('content'); + + $group = Group::findOrFail($gid); + + abort_if(!$group->isMember($pid), 403, 'Not a member of group.'); + abort_if(!GroupService::canComment($gid, $pid), 422, 'You cannot interact with this content at this time'); + $parent = GroupPost::whereGroupId($gid)->findOrFail($sid); + + $status = new GroupComment; + $status->status_id = $parent->id; + $status->group_id = $group->id; + $status->profile_id = $pid; + $status->caption = Purify::clean($caption); + $status->visibility = 'draft'; + $status->is_nsfw = false; + $status->save(); + + $photo = $request->file('photo'); + $storagePath = GroupMediaService::path($group->id, $pid, $status->id); + $storagePath = 'public/g/' . $group->id . '/p/' . $parent->id; + $path = $photo->storePublicly($storagePath); + + $media = new GroupMedia(); + $media->group_id = $group->id; + $media->status_id = $status->id; + $media->profile_id = $request->user()->profile_id; + $media->media_path = $path; + $media->size = $photo->getSize(); + $media->mime = $photo->getMimeType(); + $media->save(); + + ImageResizePipeline::dispatchSync($media); + ImageS3UploadPipeline::dispatchSync($media); + + // $gp = new GroupPost; + // $gp->group_id = $group->id; + // $gp->profile_id = $pid; + // $gp->type = 'reply:photo'; + // $gp->status_id = $status->id; + // $gp->in_reply_to_id = $parent->id; + // $gp->save(); + + // GroupService::log( + // $group->id, + // $pid, + // 'group:comment:created', + // [ + // 'type' => $gp->type, + // 'status_id' => $status->id + // ], + // GroupPost::class, + // $gp->id + // ); + + // todo: perform in job + // $parent->reply_count = Status::whereInReplyToId($parent->id)->count(); + // $parent->save(); + // StatusService::del($parent->id); + // GroupPostService::del($group->id, GroupService::sidToGid($group->id, $parent->id)); + + // delay response while background job optimizes media + // sleep(5); + + // todo: perform in job + $s = GroupCommentService::get($status->group_id, $status->id); + + // $s['pf_type'] = 'text'; + // $s['visibility'] = 'public'; + // $s['url'] = $gp->url(); + + return $s; + } + + public function deleteComment(Request $request) + { + abort_if(!$request->user(), 403); + + $this->validate($request, [ + 'id' => 'required|integer|min:1', + 'gid' => 'required|integer|min:1' + ]); + + $pid = $request->user()->profile_id; + $gid = $request->input('gid'); + $group = Group::findOrFail($gid); + abort_if(!$group->isMember($pid), 403, 'Not a member of group.'); + + $gp = GroupComment::whereGroupId($group->id)->findOrFail($request->input('id')); + abort_if($gp->profile_id != $pid && $group->profile_id != $pid, 403); + + $parent = GroupPost::find($gp->status_id); + abort_if(!$parent, 422, 'Invalid parent'); + + DeleteCommentPipeline::dispatch($parent, $gp)->onQueue('groups'); + GroupService::log( + $group->id, + $pid, + 'group:status:deleted', + [ + 'type' => $gp->type, + 'status_id' => $gp->id, + ], + GroupComment::class, + $gp->id + ); + $gp->delete(); + + if($request->wantsJson()) { + return response()->json(['Status successfully deleted.']); + } else { + return redirect('/groups/feed'); + } + } + + public function likePost(Request $request) + { + $this->validate($request, [ + 'gid' => 'required', + 'sid' => 'required' + ]); + + $pid = $request->user()->profile_id; + $gid = $request->input('gid'); + $sid = $request->input('sid'); + + $group = GroupService::get($gid); + abort_if(!$group || $gid != $group['id'], 422, 'Invalid group'); + abort_if(!GroupService::canLike($gid, $pid), 422, 'You cannot interact with this content at this time'); + abort_if(!GroupService::isMember($gid, $pid), 403, 'Not a member of group'); + $gp = GroupCommentService::get($gid, $sid); + abort_if(!$gp, 422, 'Invalid status'); + $count = $gp['favourites_count'] ?? 0; + + $like = GroupLike::firstOrCreate([ + 'group_id' => $gid, + 'profile_id' => $pid, + 'comment_id' => $sid, + ]); + + if($like->wasRecentlyCreated) { + // update parent post like count + $parent = GroupComment::find($sid); + abort_if(!$parent || $parent->group_id != $gid, 422, 'Invalid status'); + $parent->likes_count = $parent->likes_count + 1; + $parent->save(); + GroupsLikeService::add($pid, $sid); + // invalidate cache + GroupCommentService::del($gid, $sid); + $count++; + GroupService::log( + $gid, + $pid, + 'group:like', + null, + GroupLike::class, + $like->id + ); + } + + $response = ['code' => 200, 'msg' => 'Like saved', 'count' => $count]; + + return $response; + } + + public function unlikePost(Request $request) + { + $this->validate($request, [ + 'gid' => 'required', + 'sid' => 'required' + ]); + + $pid = $request->user()->profile_id; + $gid = $request->input('gid'); + $sid = $request->input('sid'); + + $group = GroupService::get($gid); + abort_if(!$group || $gid != $group['id'], 422, 'Invalid group'); + abort_if(!GroupService::canLike($gid, $pid), 422, 'You cannot interact with this content at this time'); + abort_if(!GroupService::isMember($gid, $pid), 403, 'Not a member of group'); + $gp = GroupCommentService::get($gid, $sid); + abort_if(!$gp, 422, 'Invalid status'); + $count = $gp['favourites_count'] ?? 0; + + $like = GroupLike::where([ + 'group_id' => $gid, + 'profile_id' => $pid, + 'comment_id' => $sid, + ])->first(); + + if($like) { + $like->delete(); + $parent = GroupComment::find($sid); + abort_if(!$parent || $parent->group_id != $gid, 422, 'Invalid status'); + $parent->likes_count = $parent->likes_count - 1; + $parent->save(); + GroupsLikeService::remove($pid, $sid); + // invalidate cache + GroupCommentService::del($gid, $sid); + $count--; + } + + $response = ['code' => 200, 'msg' => 'Unliked post', 'count' => $count]; + + return $response; + } +} diff --git a/app/Http/Controllers/Groups/GroupsDiscoverController.php b/app/Http/Controllers/Groups/GroupsDiscoverController.php new file mode 100644 index 000000000..2194807de --- /dev/null +++ b/app/Http/Controllers/Groups/GroupsDiscoverController.php @@ -0,0 +1,57 @@ +middleware('auth'); + } + + public function getDiscoverPopular(Request $request) + { + abort_if(!$request->user(), 404); + $groups = Group::orderByDesc('member_count') + ->take(12) + ->pluck('id') + ->map(function($id) { + return GroupService::get($id); + }) + ->filter(function($id) { + return $id; + }) + ->take(6) + ->values(); + return $groups; + } + + public function getDiscoverNew(Request $request) + { + abort_if(!$request->user(), 404); + $groups = Group::latest() + ->take(12) + ->pluck('id') + ->map(function($id) { + return GroupService::get($id); + }) + ->filter(function($id) { + return $id; + }) + ->take(6) + ->values(); + return $groups; + } +} diff --git a/app/Http/Controllers/Groups/GroupsFeedController.php b/app/Http/Controllers/Groups/GroupsFeedController.php new file mode 100644 index 000000000..bb04e2487 --- /dev/null +++ b/app/Http/Controllers/Groups/GroupsFeedController.php @@ -0,0 +1,188 @@ +middleware('auth'); + } + + public function getSelfFeed(Request $request) + { + abort_if(!$request->user(), 404); + $pid = $request->user()->profile_id; + $limit = $request->input('limit', 5); + $page = $request->input('page'); + $initial = $request->has('initial'); + + if($initial) { + $res = Cache::remember('groups:self:feed:' . $pid, 900, function() use($pid) { + return $this->getSelfFeedV0($pid, 5, null); + }); + } else { + abort_if($page && $page > 5, 422); + $res = $this->getSelfFeedV0($pid, $limit, $page); + } + + return response()->json($res, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES); + } + + protected function getSelfFeedV0($pid, $limit, $page) + { + return GroupPost::join('group_members', 'group_posts.group_id', 'group_members.group_id') + ->select('group_posts.*', 'group_members.group_id', 'group_members.profile_id') + ->where('group_members.profile_id', $pid) + ->whereIn('group_posts.type', ['text', 'photo', 'video']) + ->orderByDesc('group_posts.id') + ->limit($limit) + // ->pluck('group_posts.status_id') + ->simplePaginate($limit) + ->map(function($gp) use($pid) { + $status = GroupPostService::get($gp['group_id'], $gp['id']); + + if(!$status) { + return false; + } + + $status['favourited'] = (bool) GroupsLikeService::liked($pid, $gp['id']); + $status['favourites_count'] = GroupsLikeService::count($gp['id']); + $status['pf_type'] = $gp['type']; + $status['visibility'] = 'public'; + $status['url'] = url("/groups/{$gp['group_id']}/p/{$gp['id']}"); + $status['group'] = GroupService::get($gp['group_id']); + $status['account']['url'] = url("/groups/{$gp['group_id']}/user/{$status['account']['id']}"); + + return $status; + }); + } + + public function getGroupProfileFeed(Request $request, $id, $pid) + { + abort_if(!$request->user(), 404); + $cid = $request->user()->profile_id; + + $group = Group::findOrFail($id); + abort_if(!$group->isMember($pid), 404); + + $feed = GroupPost::whereGroupId($id) + ->whereProfileId($pid) + ->latest() + ->paginate(3) + ->map(function($gp) use($pid) { + $status = GroupPostService::get($gp['group_id'], $gp['id']); + if(!$status) { + return false; + } + $status['favourited'] = (bool) GroupsLikeService::liked($pid, $gp['id']); + $status['favourites_count'] = GroupsLikeService::count($gp['id']); + $status['pf_type'] = $gp['type']; + $status['visibility'] = 'public'; + $status['url'] = $gp->url(); + + // if($gp['type'] == 'poll') { + // $status['poll'] = PollService::get($status['id']); + // } + + $status['account']['url'] = "/groups/{$gp['group_id']}/user/{$status['account']['id']}"; + + return $status; + }) + ->filter(function($status) { + return $status; + }); + + return $feed; + } + + public function getGroupFeed(Request $request, $id) + { + $group = Group::findOrFail($id); + $user = $request->user(); + $pid = optional($user)->profile_id ?? false; + abort_if(!$group->isMember($pid), 404); + $max = $request->input('max_id'); + $limit = $request->limit ?? 3; + $filtered = $user ? UserFilterService::filters($user->profile_id) : []; + + // $posts = GroupPost::whereGroupId($group->id) + // ->when($maxId, function($q, $maxId) { + // return $q->where('status_id', '<', $maxId); + // }) + // ->whereNull('in_reply_to_id') + // ->orderByDesc('status_id') + // ->simplePaginate($limit) + // ->map(function($gp) use($pid) { + // $status = StatusService::get($gp['status_id'], false); + // if(!$status) { + // return false; + // } + // $status['favourited'] = (bool) LikeService::liked($pid, $gp['status_id']); + // $status['favourites_count'] = LikeService::count($gp['status_id']); + // $status['pf_type'] = $gp['type']; + // $status['visibility'] = 'public'; + // $status['url'] = $gp->url(); + + // if($gp['type'] == 'poll') { + // $status['poll'] = PollService::get($status['id']); + // } + + // $status['account']['url'] = url("/groups/{$gp['group_id']}/user/{$status['account']['id']}"); + + // return $status; + // })->filter(function($status) { + // return $status; + // }); + // return $posts; + + Cache::remember('api:v1:timelines:public:cache_check', 10368000, function() use($id) { + if(GroupFeedService::count($id) == 0) { + GroupFeedService::warmCache($id, true, 400); + } + }); + + if ($max) { + $feed = GroupFeedService::getRankedMaxId($id, $max, $limit); + } else { + $feed = GroupFeedService::get($id, 0, $limit); + } + + $res = collect($feed) + ->map(function($k) use($user, $id) { + $status = GroupPostService::get($id, $k); + if($status && $user) { + $pid = $user->profile_id; + $sid = $status['account']['id']; + $status['favourited'] = (bool) GroupsLikeService::liked($pid, $status['id']); + $status['favourites_count'] = GroupsLikeService::count($status['id']); + $status['relationship'] = $pid == $sid ? [] : RelationshipService::get($pid, $sid); + } + return $status; + }) + ->filter(function($s) use($filtered) { + return $s && in_array($s['account']['id'], $filtered) == false; + }) + ->values() + ->toArray(); + + return $res; + } +} diff --git a/app/Http/Controllers/Groups/GroupsMemberController.php b/app/Http/Controllers/Groups/GroupsMemberController.php new file mode 100644 index 000000000..3bfe086a2 --- /dev/null +++ b/app/Http/Controllers/Groups/GroupsMemberController.php @@ -0,0 +1,214 @@ +validate($request, [ + 'gid' => 'required', + 'limit' => 'nullable|integer|min:3|max:10' + ]); + + abort_if(!$request->user(), 404); + + $pid = $request->user()->profile_id; + $gid = $request->input('gid'); + $group = Group::findOrFail($gid); + + abort_if(!$group->isMember($pid), 403, 'Not a member of group.'); + + $members = GroupMember::whereGroupId($gid) + ->whereJoinRequest(false) + ->simplePaginate(10) + ->map(function($member) use($pid) { + $account = AccountService::get($member['profile_id']); + $account['role'] = $member['role']; + $account['joined'] = $member['created_at']; + $account['following'] = $pid != $member['profile_id'] ? + FollowerService::follows($pid, $member['profile_id']) : + null; + $account['url'] = url("/groups/{$member->group_id}/user/{$member['profile_id']}"); + return $account; + }); + + return response()->json($members->toArray(), 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES); + } + + public function getGroupMemberJoinRequests(Request $request) + { + abort_if(!$request->user(), 404); + $id = $request->input('gid'); + $group = Group::findOrFail($id); + $pid = $request->user()->profile_id; + abort_if(!$group->isMember($pid), 404); + abort_if(!in_array($group->selfRole($pid), ['founder', 'admin']), 404); + + return GroupMember::whereGroupId($group->id) + ->whereJoinRequest(true) + ->whereNull('rejected_at') + ->paginate(10) + ->map(function($member) { + return AccountService::get($member->profile_id); + }); + } + + public function handleGroupMemberJoinRequest(Request $request) + { + abort_if(!$request->user(), 404); + $id = $request->input('gid'); + $group = Group::findOrFail($id); + $pid = $request->user()->profile_id; + abort_if(!$group->isMember($pid), 404); + abort_if(!in_array($group->selfRole($pid), ['founder', 'admin']), 404); + $mid = $request->input('pid'); + abort_if($group->isMember($mid), 404); + + $this->validate($request, [ + 'gid' => 'required', + 'pid' => 'required', + 'action' => 'required|in:approve,reject' + ]); + + $action = $request->input('action'); + + $member = GroupMember::whereGroupId($group->id) + ->whereProfileId($mid) + ->firstOrFail(); + + if($action == 'approve') { + MemberJoinApprovedPipeline::dispatch($member)->onQueue('groups'); + } else if ($action == 'reject') { + MemberJoinRejectedPipeline::dispatch($member)->onQueue('groups'); + } + + return $request->all(); + } + + public function getGroupMember(Request $request) + { + $this->validate($request, [ + 'gid' => 'required', + 'pid' => 'required' + ]); + + abort_if(!$request->user(), 404); + $gid = $request->input('gid'); + $group = Group::findOrFail($gid); + $pid = $request->user()->profile_id; + abort_if(!$group->isMember($pid), 404); + abort_if(!in_array($group->selfRole($pid), ['founder', 'admin']), 404); + + $member_id = $request->input('pid'); + $member = GroupMember::whereGroupId($gid) + ->whereProfileId($member_id) + ->firstOrFail(); + + $account = GroupAccountService::get($group->id, $member['profile_id']); + $account['role'] = $member['role']; + $account['joined'] = $member['created_at']; + $account['following'] = $pid != $member['profile_id'] ? + FollowerService::follows($pid, $member['profile_id']) : + null; + $account['url'] = url("/groups/{$gid}/user/{$member_id}"); + + return response()->json($account, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES); + } + + public function getGroupMemberCommonIntersections(Request $request) + { + abort_if(!$request->user(), 404); + $cid = $request->user()->profile_id; + + // $this->validate($request, [ + // 'gid' => 'required', + // 'pid' => 'required' + // ]); + + $gid = $request->input('gid'); + $pid = $request->input('pid'); + + if($pid === $cid) { + return []; + } + + $group = Group::findOrFail($gid); + abort_if(!$group->isMember($cid), 404); + abort_if(!$group->isMember($pid), 404); + + $self = GroupPostHashtag::selectRaw('group_post_hashtags.*, count(*) as countr') + ->whereProfileId($cid) + ->groupBy('hashtag_id') + ->orderByDesc('countr') + ->take(20) + ->pluck('hashtag_id'); + $user = GroupPostHashtag::selectRaw('group_post_hashtags.*, count(*) as countr') + ->whereProfileId($pid) + ->groupBy('hashtag_id') + ->orderByDesc('countr') + ->take(20) + ->pluck('hashtag_id'); + + $topics = $self->intersect($user) + ->values() + ->shuffle() + ->take(3) + ->map(function($id) use($group) { + $tag = GroupHashtagService::get($id); + $tag['url'] = url("/groups/{$group->id}/topics/{$tag['slug']}?src=upt"); + return $tag; + }); + + // $friends = DB::table('followers as u') + // ->join('followers as s', 'u.following_id', '=', 's.following_id') + // ->where('s.profile_id', $cid) + // ->where('u.profile_id', $pid) + // ->inRandomOrder() + // ->take(10) + // ->pluck('s.following_id') + // ->map(function($id) use($gid) { + // $res = AccountService::get($id); + // $res['url'] = url("/groups/{$gid}/user/{$id}"); + // return $res; + // }); + $mutualGroups = GroupService::mutualGroups($cid, $pid, [$gid]); + + $mutualFriends = collect(FollowerService::mutualIds($cid, $pid)) + ->map(function($id) use($gid) { + $res = AccountService::get($id); + if(GroupService::isMember($gid, $id)) { + $res['url'] = url("/groups/{$gid}/user/{$id}"); + } else if(!$res['local']) { + $res['url'] = url("/i/web/profile/_/{$id}"); + } + return $res; + }); + $mutualFriendsCount = FollowerService::mutualCount($cid, $pid); + + $res = [ + 'groups_count' => $mutualGroups['count'], + 'groups' => $mutualGroups['groups'], + 'topics' => $topics, + 'friends_count' => $mutualFriendsCount, + 'friends' => $mutualFriends, + ]; + + return response()->json($res, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES); + } +} diff --git a/app/Http/Controllers/Groups/GroupsMetaController.php b/app/Http/Controllers/Groups/GroupsMetaController.php new file mode 100644 index 000000000..bc1e58b33 --- /dev/null +++ b/app/Http/Controllers/Groups/GroupsMetaController.php @@ -0,0 +1,31 @@ +middleware('auth'); + } + + public function deleteGroup(Request $request) + { + abort_if(!$request->user(), 404); + $id = $request->input('gid'); + $group = Group::findOrFail($id); + $pid = $request->user()->profile_id; + abort_if(!$group->isMember($pid), 404); + abort_if(!in_array($group->selfRole($pid), ['founder', 'admin']), 404); + + $group->status = "delete"; + $group->save(); + GroupService::del($group->id); + return [200]; + } +} diff --git a/app/Http/Controllers/Groups/GroupsNotificationsController.php b/app/Http/Controllers/Groups/GroupsNotificationsController.php new file mode 100644 index 000000000..dafc6c821 --- /dev/null +++ b/app/Http/Controllers/Groups/GroupsNotificationsController.php @@ -0,0 +1,55 @@ +middleware('auth'); + } + + public function selfGlobalNotifications(Request $request) + { + abort_if(!$request->user(), 404); + $pid = $request->user()->profile_id; + + $res = Notification::whereProfileId($pid) + ->where('action', 'like', 'group%') + ->latest() + ->paginate(10) + ->map(function($n) { + $res = [ + 'id' => $n->id, + 'type' => $n->action, + 'account' => AccountService::get($n->actor_id), + 'object' => [ + 'id' => $n->item_id, + 'type' => last(explode('\\', $n->item_type)), + ], + 'created_at' => $n->created_at->format('c') + ]; + + if($res['object']['type'] == 'Status' || in_array($n->action, ['group:comment'])) { + $res['status'] = StatusService::get($n->item_id, false); + $res['group'] = GroupService::get($res['status']['gid']); + } + + if($res['object']['type'] == 'Group') { + $res['group'] = GroupService::get($n->item_id); + } + + return $res; + }); + + return response()->json($res, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES); + } +} diff --git a/app/Http/Controllers/Groups/GroupsPostController.php b/app/Http/Controllers/Groups/GroupsPostController.php new file mode 100644 index 000000000..11b4799fe --- /dev/null +++ b/app/Http/Controllers/Groups/GroupsPostController.php @@ -0,0 +1,420 @@ +middleware('auth'); + } + + public function storePost(Request $request) + { + $this->validate($request, [ + 'group_id' => 'required|exists:groups,id', + 'caption' => 'sometimes|string|max:'.config_cache('pixelfed.max_caption_length', 500), + 'pollOptions' => 'sometimes|array|min:1|max:4' + ]); + + $group = Group::findOrFail($request->input('group_id')); + $pid = $request->user()->profile_id; + $caption = $request->input('caption'); + $type = $request->input('type', 'text'); + + abort_if(!GroupService::canPost($group->id, $pid), 422, 'You cannot create new posts at this time'); + + if($type == 'text') { + abort_if(strlen(e($caption)) == 0, 403); + } + + $gp = new GroupPost; + $gp->group_id = $group->id; + $gp->profile_id = $pid; + $gp->caption = e($caption); + $gp->type = $type; + $gp->visibility = 'draft'; + $gp->save(); + + $status = $gp; + + NewPostPipeline::dispatchSync($gp); + + // NewStatusPipeline::dispatch($status, $gp); + + if($type == 'poll') { + // Polls not supported yet + // $poll = new Poll; + // $poll->status_id = $status->id; + // $poll->profile_id = $status->profile_id; + // $poll->poll_options = $request->input('pollOptions'); + // $poll->expires_at = now()->addMinutes($request->input('expiry')); + // $poll->cached_tallies = collect($poll->poll_options)->map(function($o) { + // return 0; + // })->toArray(); + // $poll->save(); + // sleep(5); + } + if($type == 'photo') { + $photo = $request->file('photo'); + $storagePath = GroupMediaService::path($group->id, $pid, $status->id); + // $storagePath = 'public/g/' . $group->id . '/p/' . $status->id; + $path = $photo->storePublicly($storagePath); + // $hash = \hash_file('sha256', $photo); + + $media = new GroupMedia(); + $media->group_id = $group->id; + $media->status_id = $status->id; + $media->profile_id = $request->user()->profile_id; + $media->media_path = $path; + $media->size = $photo->getSize(); + $media->mime = $photo->getMimeType(); + $media->save(); + + // Bus::chain([ + // new ImageResizePipeline($media), + // new ImageS3UploadPipeline($media), + // ])->dispatch($media); + + ImageResizePipeline::dispatchSync($media); + ImageS3UploadPipeline::dispatchSync($media); + // ImageOptimize::dispatch($media); + // delay response while background job optimizes media + // sleep(5); + } + if($type == 'video') { + $video = $request->file('video'); + $storagePath = 'public/g/' . $group->id . '/p/' . $status->id; + $path = $video->storePublicly($storagePath); + $hash = \hash_file('sha256', $video); + + $media = new Media(); + $media->status_id = $status->id; + $media->profile_id = $request->user()->profile_id; + $media->user_id = $request->user()->id; + $media->media_path = $path; + $media->original_sha256 = $hash; + $media->size = $video->getSize(); + $media->mime = $video->getMimeType(); + $media->save(); + + VideoThumbnail::dispatch($media); + sleep(15); + } + + GroupService::log( + $group->id, + $pid, + 'group:status:created', + [ + 'type' => $gp->type, + 'status_id' => $status->id + ], + GroupPost::class, + $gp->id + ); + + $s = GroupPostService::get($status->group_id, $status->id); + GroupFeedService::add($group->id, $gp->id); + Cache::forget('groups:self:feed:' . $pid); + + $s['pf_type'] = $type; + $s['visibility'] = 'public'; + $s['url'] = $gp->url(); + + if($type == 'poll') { + $s['poll'] = PollService::get($status->id); + } + + $group->last_active_at = now(); + $group->save(); + + return $s; + } + + public function deletePost(Request $request) + { + abort_if(!$request->user(), 403); + + $this->validate($request, [ + 'id' => 'required|integer|min:1', + 'gid' => 'required|integer|min:1' + ]); + + $pid = $request->user()->profile_id; + $gid = $request->input('gid'); + $group = Group::findOrFail($gid); + abort_if(!$group->isMember($pid), 403, 'Not a member of group.'); + + $gp = GroupPost::whereGroupId($status->group_id)->findOrFail($request->input('id')); + abort_if($gp->profile_id != $pid && $group->profile_id != $pid, 403); + $cached = GroupPostService::get($status->group_id, $status->id); + + if($cached) { + $cached = collect($cached)->filter(function($r, $k) { + return in_array($k, [ + 'id', + 'sensitive', + 'pf_type', + 'media_attachments', + 'content_text', + 'created_at' + ]); + }); + } + + GroupService::log( + $status->group_id, + $request->user()->profile_id, + 'group:status:deleted', + [ + 'type' => $gp->type, + 'status_id' => $status->id, + 'original' => $cached + ], + GroupPost::class, + $gp->id + ); + + $user = $request->user(); + + // if($status->profile_id != $user->profile->id && + // $user->is_admin == true && + // $status->uri == null + // ) { + // $media = $status->media; + + // $ai = new AccountInterstitial; + // $ai->user_id = $status->profile->user_id; + // $ai->type = 'post.removed'; + // $ai->view = 'account.moderation.post.removed'; + // $ai->item_type = 'App\Status'; + // $ai->item_id = $status->id; + // $ai->has_media = (bool) $media->count(); + // $ai->blurhash = $media->count() ? $media->first()->blurhash : null; + // $ai->meta = json_encode([ + // 'caption' => $status->caption, + // 'created_at' => $status->created_at, + // 'type' => $status->type, + // 'url' => $status->url(), + // 'is_nsfw' => $status->is_nsfw, + // 'scope' => $status->scope, + // 'reblog' => $status->reblog_of_id, + // 'likes_count' => $status->likes_count, + // 'reblogs_count' => $status->reblogs_count, + // ]); + // $ai->save(); + + // $u = $status->profile->user; + // $u->has_interstitial = true; + // $u->save(); + // } + + if($status->in_reply_to_id) { + $parent = GroupPost::find($status->in_reply_to_id); + if($parent) { + $parent->reply_count = GroupPost::whereInReplyToId($parent->id)->count(); + $parent->save(); + GroupPostService::del($group->id, GroupService::sidToGid($group->id, $parent->id)); + } + } + + GroupPostService::del($group->id, $gp->id); + GroupFeedService::del($group->id, $gp->id); + if ($status->profile_id == $user->profile->id || $user->is_admin == true) { + // Cache::forget('profile:status_count:'.$status->profile_id); + StatusDelete::dispatch($status); + } + + if($request->wantsJson()) { + return response()->json(['Status successfully deleted.']); + } else { + return redirect($user->url()); + } + } + + public function likePost(Request $request) + { + $this->validate($request, [ + 'gid' => 'required', + 'sid' => 'required' + ]); + + $pid = $request->user()->profile_id; + $gid = $request->input('gid'); + $sid = $request->input('sid'); + + $group = GroupService::get($gid); + abort_if(!$group, 422, 'Invalid group'); + abort_if(!GroupService::canLike($gid, $pid), 422, 'You cannot interact with this content at this time'); + abort_if(!GroupService::isMember($gid, $pid), 403, 'Not a member of group'); + $gp = GroupPostService::get($gid, $sid); + abort_if(!$gp, 422, 'Invalid status'); + $count = $gp['favourites_count'] ?? 0; + + $like = GroupLike::firstOrCreate([ + 'group_id' => $gid, + 'profile_id' => $pid, + 'status_id' => $sid, + ]); + + if($like->wasRecentlyCreated) { + // update parent post like count + $parent = GroupPost::whereGroupId($gid)->find($sid); + abort_if(!$parent, 422, 'Invalid status'); + $parent->likes_count = $parent->likes_count + 1; + $parent->save(); + GroupsLikeService::add($pid, $sid); + // invalidate cache + GroupPostService::del($gid, $sid); + $count++; + GroupService::log( + $gid, + $pid, + 'group:like', + null, + GroupLike::class, + $like->id + ); + } + // if (GroupLike::whereGroupId($gid)->whereStatusId($sid)->whereProfileId($pid)->exists()) { + // $like = GroupLike::whereProfileId($pid)->whereStatusId($sid)->firstOrFail(); + // // UnlikePipeline::dispatch($like); + // $count = $gp->likes_count - 1; + // $action = 'group:unlike'; + // } else { + // $count = $gp->likes_count; + // $like = GroupLike::firstOrCreate([ + // 'group_id' => $gid, + // 'profile_id' => $pid, + // 'status_id' => $sid + // ]); + // if($like->wasRecentlyCreated == true) { + // $count++; + // $gp->likes_count = $count; + // $like->save(); + // $gp->save(); + // // LikePipeline::dispatch($like); + // $action = 'group:like'; + // } + // } + + + // Cache::forget('status:'.$status->id.':likedby:userid:'.$request->user()->id); + // StatusService::del($status->id); + + $response = ['code' => 200, 'msg' => 'Like saved', 'count' => $count]; + + return $response; + } + + public function unlikePost(Request $request) + { + $this->validate($request, [ + 'gid' => 'required', + 'sid' => 'required' + ]); + + $pid = $request->user()->profile_id; + $gid = $request->input('gid'); + $sid = $request->input('sid'); + + $group = GroupService::get($gid); + abort_if(!$group, 422, 'Invalid group'); + abort_if(!GroupService::canLike($gid, $pid), 422, 'You cannot interact with this content at this time'); + abort_if(!GroupService::isMember($gid, $pid), 403, 'Not a member of group'); + $gp = GroupPostService::get($gid, $sid); + abort_if(!$gp, 422, 'Invalid status'); + $count = $gp['favourites_count'] ?? 0; + + $like = GroupLike::where([ + 'group_id' => $gid, + 'profile_id' => $pid, + 'status_id' => $sid, + ])->first(); + + if($like) { + $like->delete(); + $parent = GroupPost::whereGroupId($gid)->find($sid); + abort_if(!$parent, 422, 'Invalid status'); + $parent->likes_count = $parent->likes_count - 1; + $parent->save(); + GroupsLikeService::remove($pid, $sid); + // invalidate cache + GroupPostService::del($gid, $sid); + $count--; + } + + $response = ['code' => 200, 'msg' => 'Unliked post', 'count' => $count]; + + return $response; + } + + public function getGroupMedia(Request $request) + { + $this->validate($request, [ + 'gid' => 'required', + 'type' => 'required|in:photo,video' + ]); + + abort_if(!$request->user(), 404); + + $pid = $request->user()->profile_id; + $gid = $request->input('gid'); + $type = $request->input('type'); + $group = Group::findOrFail($gid); + + abort_if(!$group->isMember($pid), 403, 'Not a member of group.'); + + $media = GroupPost::whereGroupId($gid) + ->whereType($type) + ->latest() + ->simplePaginate(20) + ->map(function($gp) use($pid) { + $status = GroupPostService::get($gp['group_id'], $gp['id']); + if(!$status) { + return false; + } + $status['favourited'] = (bool) GroupsLikeService::liked($pid, $gp['id']); + $status['favourites_count'] = GroupsLikeService::count($gp['id']); + $status['pf_type'] = $gp['type']; + $status['visibility'] = 'public'; + $status['url'] = $gp->url(); + + // if($gp['type'] == 'poll') { + // $status['poll'] = PollService::get($status['id']); + // } + + return $status; + })->filter(function($status) { + return $status; + }); + + return response()->json($media->toArray(), 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES); + } +} diff --git a/app/Http/Controllers/Groups/GroupsSearchController.php b/app/Http/Controllers/Groups/GroupsSearchController.php new file mode 100644 index 000000000..560436f46 --- /dev/null +++ b/app/Http/Controllers/Groups/GroupsSearchController.php @@ -0,0 +1,221 @@ +middleware('auth'); + } + + public function inviteFriendsToGroup(Request $request) + { + abort_if(!$request->user(), 404); + $this->validate($request, [ + 'uids' => 'required', + 'g' => 'required', + ]); + $uid = $request->input('uids'); + $gid = $request->input('g'); + $pid = $request->user()->profile_id; + $group = Group::findOrFail($gid); + abort_if(!$group->isMember($pid), 404); + abort_if( + GroupInvitation::whereGroupId($group->id) + ->whereFromProfileId($pid) + ->count() >= 20, + 422, + 'Invite limit reached' + ); + + $profiles = collect($uid) + ->map(function($u) { + return Profile::find($u); + }) + ->filter(function($u) use($pid) { + return $u && + $u->id != $pid && + isset($u->id) && + Follower::whereFollowingId($pid) + ->whereProfileId($u->id) + ->exists(); + }) + ->filter(function($u) use($group, $pid) { + return GroupInvitation::whereGroupId($group->id) + ->whereFromProfileId($pid) + ->whereToProfileId($u->id) + ->exists() == false; + }) + ->each(function($u) use($gid, $pid) { + $gi = new GroupInvitation; + $gi->group_id = $gid; + $gi->from_profile_id = $pid; + $gi->to_profile_id = $u->id; + $gi->to_local = true; + $gi->from_local = $u->domain == null; + $gi->save(); + // GroupMemberInvite::dispatch($gi); + }); + return [200]; + } + + public function searchFriendsToInvite(Request $request) + { + abort_if(!$request->user(), 404); + $this->validate($request, [ + 'q' => 'required|min:2|max:40', + 'g' => 'required', + 'v' => 'required|in:0.2' + ]); + $q = $request->input('q'); + $gid = $request->input('g'); + $pid = $request->user()->profile_id; + $group = Group::findOrFail($gid); + abort_if(!$group->isMember($pid), 404); + + $res = Profile::where('username', 'like', "%{$q}%") + ->whereNull('profiles.domain') + ->join('followers', 'profiles.id', '=', 'followers.profile_id') + ->where('followers.following_id', $pid) + ->take(10) + ->get() + ->filter(function($p) use($group) { + return $group->isMember($p->profile_id) == false; + }) + ->filter(function($p) use($group, $pid) { + return GroupInvitation::whereGroupId($group->id) + ->whereFromProfileId($pid) + ->whereToProfileId($p->profile_id) + ->exists() == false; + }) + ->map(function($gm) use ($gid) { + $a = AccountService::get($gm->profile_id); + return [ + 'id' => (string) $gm->profile_id, + 'username' => $a['acct'], + 'url' => url("/groups/{$gid}/user/{$a['id']}?rf=group_search") + ]; + }) + ->values(); + + return $res; + } + + public function searchGlobalResults(Request $request) + { + abort_if(!$request->user(), 404); + $this->validate($request, [ + 'q' => 'required|min:2|max:140', + 'v' => 'required|in:0.2' + ]); + $q = $request->input('q'); + + if(str_starts_with($q, 'https://')) { + $res = Helpers::getSignedFetch($q); + if($res && $res = json_decode($res, true)) { + + } + if($res && isset($res['type']) && in_array($res['type'], ['Group', 'Note', 'Page'])) { + if($res['type'] === 'Group') { + return GroupActivityPubService::fetchGroup($q, true); + } + $resp = GroupActivityPubService::fetchGroupPost($q, true); + $resp['name'] = 'Group Post'; + $resp['url'] = '/groups/' . $resp['group_id'] . '/p/' . $resp['id']; + return [$resp]; + } + } + return Group::whereNull('status') + ->where('name', 'like', '%' . $q . '%') + ->orderBy('id') + ->take(10) + ->pluck('id') + ->map(function($group) { + return GroupService::get($group); + }); + } + + public function searchLocalAutocomplete(Request $request) + { + abort_if(!$request->user(), 404); + $this->validate($request, [ + 'q' => 'required|min:2|max:40', + 'g' => 'required', + 'v' => 'required|in:0.2' + ]); + $q = $request->input('q'); + $gid = $request->input('g'); + $pid = $request->user()->profile_id; + $group = Group::findOrFail($gid); + abort_if(!$group->isMember($pid), 404); + + $res = GroupMember::whereGroupId($gid) + ->join('profiles', 'group_members.profile_id', '=', 'profiles.id') + ->where('profiles.username', 'like', "%{$q}%") + ->take(10) + ->get() + ->map(function($gm) use ($gid) { + $a = AccountService::get($gm->profile_id); + return [ + 'username' => $a['username'], + 'url' => url("/groups/{$gid}/user/{$a['id']}?rf=group_search") + ]; + }); + return $res; + } + + public function searchAddRecent(Request $request) + { + $this->validate($request, [ + 'q' => 'required|min:2|max:40', + 'g' => 'required', + ]); + $q = $request->input('q'); + $gid = $request->input('g'); + $pid = $request->user()->profile_id; + $group = Group::findOrFail($gid); + abort_if(!$group->isMember($pid), 404); + + $key = 'groups:search:recent:'.$gid.':pid:'.$pid; + $ttl = now()->addDays(14); + $res = Cache::get($key); + if(!$res) { + $val = json_encode([$q]); + } else { + $ex = collect(json_decode($res)) + ->prepend($q) + ->unique('value') + ->slice(0, 3) + ->values() + ->all(); + $val = json_encode($ex); + } + Cache::put($key, $val, $ttl); + return 200; + } + + public function searchGetRecent(Request $request) + { + $gid = $request->input('g'); + $pid = $request->user()->profile_id; + $group = Group::findOrFail($gid); + abort_if(!$group->isMember($pid), 404); + $key = 'groups:search:recent:'.$gid.':pid:'.$pid; + return Cache::get($key); + } +} diff --git a/app/Http/Controllers/Groups/GroupsTopicController.php b/app/Http/Controllers/Groups/GroupsTopicController.php new file mode 100644 index 000000000..c3d8ecda7 --- /dev/null +++ b/app/Http/Controllers/Groups/GroupsTopicController.php @@ -0,0 +1,133 @@ +middleware('auth'); + } + + public function groupTopics(Request $request) + { + $this->validate($request, [ + 'gid' => 'required', + ]); + + abort_if(!$request->user(), 404); + + $pid = $request->user()->profile_id; + $gid = $request->input('gid'); + $group = Group::findOrFail($gid); + + abort_if(!$group->isMember($pid), 403, 'Not a member of group.'); + + $posts = GroupPostHashtag::join('group_hashtags', 'group_hashtags.id', '=', 'group_post_hashtags.hashtag_id') + ->selectRaw('group_hashtags.*, group_post_hashtags.*, count(group_post_hashtags.hashtag_id) as ht_count') + ->where('group_post_hashtags.group_id', $gid) + ->orderByDesc('ht_count') + ->limit(10) + ->pluck('group_post_hashtags.hashtag_id', 'ht_count') + ->map(function($id, $key) use ($gid) { + $tag = GroupHashtag::find($id); + return [ + 'hid' => $id, + 'name' => $tag->name, + 'url' => url("/groups/{$gid}/topics/{$tag->slug}"), + 'count' => $key + ]; + })->values(); + + return response()->json($posts, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES); + } + + public function groupTopicTag(Request $request) + { + $this->validate($request, [ + 'gid' => 'required', + 'name' => 'required' + ]); + + abort_if(!$request->user(), 404); + + $pid = $request->user()->profile_id; + $gid = $request->input('gid'); + $limit = $request->input('limit', 3); + $group = Group::findOrFail($gid); + + abort_if(!$group->isMember($pid), 403, 'Not a member of group.'); + + $name = $request->input('name'); + $hashtag = GroupHashtag::whereName($name)->first(); + + if(!$hashtag) { + return []; + } + + // $posts = GroupPost::whereGroupId($gid) + // ->select('status_hashtags.*', 'group_posts.*') + // ->where('status_hashtags.hashtag_id', $hashtag->id) + // ->join('status_hashtags', 'group_posts.status_id', '=', 'status_hashtags.status_id') + // ->orderByDesc('group_posts.status_id') + // ->simplePaginate($limit) + // ->map(function($gp) use($pid) { + // $status = StatusService::get($gp['status_id'], false); + // if(!$status) { + // return false; + // } + // $status['favourited'] = (bool) LikeService::liked($pid, $gp['status_id']); + // $status['favourites_count'] = LikeService::count($gp['status_id']); + // $status['pf_type'] = $gp['type']; + // $status['visibility'] = 'public'; + // $status['url'] = $gp->url(); + // return $status; + // }); + + $posts = GroupPostHashtag::whereGroupId($gid) + ->whereHashtagId($hashtag->id) + ->orderByDesc('id') + ->simplePaginate($limit) + ->map(function($gp) use($pid) { + $status = GroupPostService::get($gp['group_id'], $gp['status_id']); + if(!$status) { + return false; + } + $status['favourited'] = (bool) GroupsLikeService::liked($pid, $gp['status_id']); + $status['favourites_count'] = GroupsLikeService::count($gp['status_id']); + $status['pf_type'] = $status['pf_type']; + $status['visibility'] = 'public'; + return $status; + }); + + return response()->json($posts, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES); + } + + public function showTopicFeed(Request $request, $gid, $tag) + { + abort_if(!$request->user(), 404); + + $pid = $request->user()->profile_id; + $group = Group::findOrFail($gid); + $gid = $group->id; + abort_if(!$group->isMember($pid), 403, 'Not a member of group.'); + return view('groups.topic-feed', compact('gid', 'tag')); + } +} diff --git a/app/Jobs/GroupPipeline/GroupCommentPipeline.php b/app/Jobs/GroupPipeline/GroupCommentPipeline.php new file mode 100644 index 000000000..cdae65d10 --- /dev/null +++ b/app/Jobs/GroupPipeline/GroupCommentPipeline.php @@ -0,0 +1,99 @@ +status = $status; + $this->comment = $comment; + $this->groupPost = $groupPost; + } + + public function handle() + { + if($this->status->group_id == null || $this->comment->group_id == null) { + return; + } + + $this->updateParentReplyCount(); + $this->generateNotification(); + + if($this->groupPost) { + $this->updateChildReplyCount(); + } + } + + protected function updateParentReplyCount() + { + $parent = $this->status; + $parent->reply_count = Status::whereInReplyToId($parent->id)->count(); + $parent->save(); + StatusService::del($parent->id); + } + + protected function updateChildReplyCount() + { + $gp = $this->groupPost; + if($gp->reply_child_id) { + $parent = GroupPost::whereStatusId($gp->reply_child_id)->first(); + if($parent) { + $parent->reply_count++; + $parent->save(); + } + } + } + + protected function generateNotification() + { + $status = $this->status; + $comment = $this->comment; + + $target = $status->profile; + $actor = $comment->profile; + + if ($actor->id == $target->id || $status->comments_disabled == true) { + return; + } + + $notification = DB::transaction(function() use($target, $actor, $comment) { + $actorName = $actor->username; + $actorUrl = $actor->url(); + $text = "{$actorName} commented on your group post."; + $html = "{$actorName} commented on your group post."; + $notification = new Notification(); + $notification->profile_id = $target->id; + $notification->actor_id = $actor->id; + $notification->action = 'group:comment'; + $notification->item_id = $comment->id; + $notification->item_type = "App\Status"; + $notification->save(); + return $notification; + }); + + NotificationService::setNotification($notification); + NotificationService::set($notification->profile_id, $notification->id); + } +} diff --git a/app/Jobs/GroupPipeline/GroupMediaPipeline.php b/app/Jobs/GroupPipeline/GroupMediaPipeline.php new file mode 100644 index 000000000..1155e5465 --- /dev/null +++ b/app/Jobs/GroupPipeline/GroupMediaPipeline.php @@ -0,0 +1,57 @@ +media = $media; + } + + public function handle() + { + MediaStorageService::store($this->media); + } + + protected function localToCloud($media) + { + $path = storage_path('app/'.$media->media_path); + $thumb = storage_path('app/'.$media->thumbnail_path); + + $p = explode('/', $media->media_path); + $name = array_pop($p); + $pt = explode('/', $media->thumbnail_path); + $thumbname = array_pop($pt); + $storagePath = implode('/', $p); + + $disk = Storage::disk(config('filesystems.cloud')); + $file = $disk->putFileAs($storagePath, new File($path), $name, 'public'); + $url = $disk->url($file); + $thumbFile = $disk->putFileAs($storagePath, new File($thumb), $thumbname, 'public'); + $thumbUrl = $disk->url($thumbFile); + $media->thumbnail_url = $thumbUrl; + $media->cdn_url = $url; + $media->optimized_url = $url; + $media->replicated_at = now(); + $media->save(); + if($media->status_id) { + Cache::forget('status:transformer:media:attachments:' . $media->status_id); + } + } + +} diff --git a/app/Jobs/GroupPipeline/GroupMemberInvite.php b/app/Jobs/GroupPipeline/GroupMemberInvite.php new file mode 100644 index 000000000..d2c2bf8ef --- /dev/null +++ b/app/Jobs/GroupPipeline/GroupMemberInvite.php @@ -0,0 +1,54 @@ +invite = $invite; + } + + /** + * Execute the job. + * + * @return void + */ + public function handle() + { + $invite = $this->invite; + $actor = Profile::find($invite->from_profile_id); + $target = Profile::find($invite->to_profile_id); + + if(!$actor || !$target) { + return; + } + + $notification = new Notification; + $notification->profile_id = $target->id; + $notification->actor_id = $actor->id; + $notification->action = 'group:invite'; + $notification->item_id = $invite->group_id; + $notification->item_type = 'App\Models\Group'; + $notification->save(); + } +} diff --git a/app/Jobs/GroupPipeline/JoinApproved.php b/app/Jobs/GroupPipeline/JoinApproved.php new file mode 100644 index 000000000..f41c8f698 --- /dev/null +++ b/app/Jobs/GroupPipeline/JoinApproved.php @@ -0,0 +1,54 @@ +member = $member; + } + + /** + * Execute the job. + * + * @return void + */ + public function handle() + { + $member = $this->member; + $member->approved_at = now(); + $member->join_request = false; + $member->role = 'member'; + $member->save(); + + $n = new Notification; + $n->profile_id = $member->profile_id; + $n->actor_id = $member->profile_id; + $n->item_id = $member->group_id; + $n->item_type = 'App\Models\Group'; + $n->save(); + + GroupService::del($member->group_id); + GroupService::delSelf($member->group_id, $member->profile_id); + } +} diff --git a/app/Jobs/GroupPipeline/JoinRejected.php b/app/Jobs/GroupPipeline/JoinRejected.php new file mode 100644 index 000000000..71e1e30c8 --- /dev/null +++ b/app/Jobs/GroupPipeline/JoinRejected.php @@ -0,0 +1,50 @@ +member = $member; + } + + /** + * Execute the job. + * + * @return void + */ + public function handle() + { + $member = $this->member; + $member->rejected_at = now(); + $member->save(); + + $n = new Notification; + $n->profile_id = $member->profile_id; + $n->actor_id = $member->profile_id; + $n->item_id = $member->group_id; + $n->item_type = 'App\Models\Group'; + $n->action = 'group.join.rejected'; + $n->save(); + } +} diff --git a/app/Jobs/GroupPipeline/LikePipeline.php b/app/Jobs/GroupPipeline/LikePipeline.php new file mode 100644 index 000000000..bd3e668f7 --- /dev/null +++ b/app/Jobs/GroupPipeline/LikePipeline.php @@ -0,0 +1,107 @@ +like = $like; + } + + /** + * Execute the job. + * + * @return void + */ + public function handle() + { + $like = $this->like; + + $status = $this->like->status; + $actor = $this->like->actor; + + if (!$status) { + // Ignore notifications to deleted statuses + return; + } + + StatusService::refresh($status->id); + + if($status->url && $actor->domain == null) { + return $this->remoteLikeDeliver(); + } + + $exists = Notification::whereProfileId($status->profile_id) + ->whereActorId($actor->id) + ->whereAction('group:like') + ->whereItemId($status->id) + ->whereItemType('App\Status') + ->count(); + + if ($actor->id === $status->profile_id || $exists !== 0) { + return true; + } + + try { + $notification = new Notification(); + $notification->profile_id = $status->profile_id; + $notification->actor_id = $actor->id; + $notification->action = 'group:like'; + $notification->item_id = $status->id; + $notification->item_type = "App\Status"; + $notification->save(); + + } catch (Exception $e) { + } + } + + public function remoteLikeDeliver() + { + $like = $this->like; + $status = $this->like->status; + $actor = $this->like->actor; + + $fractal = new Fractal\Manager(); + $fractal->setSerializer(new ArraySerializer()); + $resource = new Fractal\Resource\Item($like, new LikeTransformer()); + $activity = $fractal->createData($resource)->toArray(); + + $url = $status->profile->sharedInbox ?? $status->profile->inbox_url; + + Helpers::sendSignedObject($actor, $url, $activity); + } +} diff --git a/app/Jobs/GroupPipeline/NewStatusPipeline.php b/app/Jobs/GroupPipeline/NewStatusPipeline.php new file mode 100644 index 000000000..4d8eeca5c --- /dev/null +++ b/app/Jobs/GroupPipeline/NewStatusPipeline.php @@ -0,0 +1,130 @@ +status = $status; + $this->gp = $gp; + } + + public function handle() + { + $status = $this->status; + + $autolink = Autolink::create() + ->setAutolinkActiveUsersOnly(true) + ->setBaseHashPath("/groups/{$status->group_id}/topics/") + ->setBaseUserPath("/groups/{$status->group_id}/username/") + ->autolink($status->caption); + + $entities = Extractor::create()->extract($status->caption); + + $autolink = str_replace('/discover/tags/', '/groups/' . $status->group_id . '/topics/', $autolink); + + $status->rendered = nl2br($autolink); + $status->entities = null; + $status->save(); + + $this->tags = array_unique($entities['hashtags']); + $this->mentions = array_unique($entities['mentions']); + + if(count($this->tags)) { + $this->storeHashtags(); + } + + if(count($this->mentions)) { + $this->storeMentions($this->mentions); + } + } + + protected function storeHashtags() + { + $tags = $this->tags; + $status = $this->status; + $gp = $this->gp; + + foreach ($tags as $tag) { + if(mb_strlen($tag) > 124) { + continue; + } + + DB::transaction(function () use ($status, $tag, $gp) { + $slug = str_slug($tag, '-', false); + $hashtag = Hashtag::firstOrCreate( + ['name' => $tag, 'slug' => $slug] + ); + GroupPostHashtag::firstOrCreate( + [ + 'group_id' => $status->group_id, + 'group_post_id' => $gp->id, + 'status_id' => $status->id, + 'hashtag_id' => $hashtag->id, + 'profile_id' => $status->profile_id, + ] + ); + + }); + } + + if(count($this->mentions)) { + $this->storeMentions(); + } + StatusService::del($status->id); + } + + protected function storeMentions() + { + $mentions = $this->mentions; + $status = $this->status; + + foreach ($mentions as $mention) { + $mentioned = Profile::whereUsername($mention)->first(); + + if (empty($mentioned) || !isset($mentioned->id)) { + continue; + } + + DB::transaction(function () use ($status, $mentioned) { + $m = new Mention(); + $m->status_id = $status->id; + $m->profile_id = $mentioned->id; + $m->save(); + + MentionPipeline::dispatch($status, $m); + }); + } + StatusService::del($status->id); + } +} diff --git a/app/Jobs/GroupPipeline/UnlikePipeline.php b/app/Jobs/GroupPipeline/UnlikePipeline.php new file mode 100644 index 000000000..b322d6853 --- /dev/null +++ b/app/Jobs/GroupPipeline/UnlikePipeline.php @@ -0,0 +1,109 @@ +like = $like; + } + + /** + * Execute the job. + * + * @return void + */ + public function handle() + { + $like = $this->like; + + $status = $this->like->status; + $actor = $this->like->actor; + + if (!$status) { + // Ignore notifications to deleted statuses + return; + } + + $count = $status->likes_count > 1 ? $status->likes_count : $status->likes()->count(); + $status->likes_count = $count - 1; + $status->save(); + + StatusService::del($status->id); + + if($actor->id !== $status->profile_id && $status->url && $actor->domain == null) { + $this->remoteLikeDeliver(); + } + + $exists = Notification::whereProfileId($status->profile_id) + ->whereActorId($actor->id) + ->whereAction('group:like') + ->whereItemId($status->id) + ->whereItemType('App\Status') + ->first(); + + if($exists) { + $exists->delete(); + } + + $like = Like::whereProfileId($actor->id)->whereStatusId($status->id)->first(); + + if(!$like) { + return; + } + + $like->forceDelete(); + + return; + } + + public function remoteLikeDeliver() + { + $like = $this->like; + $status = $this->like->status; + $actor = $this->like->actor; + + $fractal = new Fractal\Manager(); + $fractal->setSerializer(new ArraySerializer()); + $resource = new Fractal\Resource\Item($like, new LikeTransformer()); + $activity = $fractal->createData($resource)->toArray(); + + $url = $status->profile->sharedInbox ?? $status->profile->inbox_url; + + Helpers::sendSignedObject($actor, $url, $activity); + } +} diff --git a/app/Jobs/GroupsPipeline/DeleteCommentPipeline.php b/app/Jobs/GroupsPipeline/DeleteCommentPipeline.php new file mode 100644 index 000000000..e1d94c5de --- /dev/null +++ b/app/Jobs/GroupsPipeline/DeleteCommentPipeline.php @@ -0,0 +1,58 @@ +parent = $parent; + $this->status = $status; + } + + /** + * Execute the job. + * + * @return void + */ + public function handle() + { + $parent = $this->parent; + $parent->reply_count = GroupComment::whereStatusId($parent->id)->count(); + $parent->save(); + + return; + } +} diff --git a/app/Jobs/GroupsPipeline/ImageResizePipeline.php b/app/Jobs/GroupsPipeline/ImageResizePipeline.php new file mode 100644 index 000000000..fa649efea --- /dev/null +++ b/app/Jobs/GroupsPipeline/ImageResizePipeline.php @@ -0,0 +1,89 @@ +media = $media; + } + + /** + * Execute the job. + * + * @return void + */ + public function handle() + { + $media = $this->media; + + if(!$media) { + return; + } + + if (!Storage::exists($media->media_path) || $media->skip_optimize) { + return; + } + + $path = $media->media_path; + $file = storage_path('app/' . $path); + $quality = config_cache('pixelfed.image_quality'); + + $orientations = [ + 'square' => [ + 'width' => 1080, + 'height' => 1080, + ], + 'landscape' => [ + 'width' => 1920, + 'height' => 1080, + ], + 'portrait' => [ + 'width' => 1080, + 'height' => 1350, + ], + ]; + + try { + $img = Intervention::make($file); + $img->orientate(); + $width = $img->width(); + $height = $img->height(); + $aspect = $width / $height; + $orientation = $aspect === 1 ? 'square' : ($aspect > 1 ? 'landscape' : 'portrait'); + $ratio = $orientations[$orientation]; + $img->resize($ratio['width'], $ratio['height']); + $img->save($file, $quality); + } catch (Exception $e) { + Log::error($e); + } + } +} diff --git a/app/Jobs/GroupsPipeline/ImageS3DeletePipeline.php b/app/Jobs/GroupsPipeline/ImageS3DeletePipeline.php new file mode 100644 index 000000000..d59c6d086 --- /dev/null +++ b/app/Jobs/GroupsPipeline/ImageS3DeletePipeline.php @@ -0,0 +1,67 @@ +media = $media; + } + + /** + * Execute the job. + * + * @return void + */ + public function handle() + { + $media = $this->media; + + if(!$media || (bool) config_cache('pixelfed.cloud_storage') === false) { + return; + } + + $fs = Storage::disk(config('filesystems.cloud')); + + if(!$fs) { + return; + } + + if($fs->exists($media->media_path)) { + $fs->delete($media->media_path); + } + } +} diff --git a/app/Jobs/GroupsPipeline/ImageS3UploadPipeline.php b/app/Jobs/GroupsPipeline/ImageS3UploadPipeline.php new file mode 100644 index 000000000..169c11073 --- /dev/null +++ b/app/Jobs/GroupsPipeline/ImageS3UploadPipeline.php @@ -0,0 +1,107 @@ +media = $media; + } + + /** + * Execute the job. + * + * @return void + */ + public function handle() + { + $media = $this->media; + + if(!$media || (bool) config_cache('pixelfed.cloud_storage') === false) { + return; + } + + $path = storage_path('app/' . $media->media_path); + + $p = explode('/', $media->media_path); + $name = array_pop($p); + $storagePath = implode('/', $p); + + $url = (bool) config_cache('pixelfed.cloud_storage') && (bool) config('media.storage.remote.resilient_mode') ? + self::handleResilientStore($storagePath, $path, $name) : + self::handleStore($storagePath, $path, $name); + + if($url && strlen($url) && str_starts_with($url, 'https://')) { + $media->cdn_url = $url; + $media->processed_at = now(); + $media->version = 11; + $media->save(); + Storage::disk('local')->delete($media->media_path); + } + } + + protected function handleStore($storagePath, $path, $name) + { + return retry(3, function() use($storagePath, $path, $name) { + $baseDisk = (bool) config_cache('pixelfed.cloud_storage') ? config('filesystems.cloud') : 'local'; + $disk = Storage::disk($baseDisk); + $file = $disk->putFileAs($storagePath, new File($path), $name, 'public'); + return $disk->url($file); + }, random_int(100, 500)); + } + + protected function handleResilientStore($storagePath, $path, $name) + { + $attempts = 0; + return retry(4, function() use($storagePath, $path, $name, $attempts) { + self::$attempts++; + usleep(100000); + $baseDisk = self::$attempts > 1 ? $this->getAltDriver() : config('filesystems.cloud'); + try { + $disk = Storage::disk($baseDisk); + $file = $disk->putFileAs($storagePath, new File($path), $name, 'public'); + } catch (S3Exception | ClientException | ConnectException | UnableToWriteFile | Exception $e) {} + return $disk->url($file); + }, function (int $attempt, Exception $exception) { + return $attempt * 200; + }); + } + + protected function getAltDriver() + { + return config('filesystems.cloud'); + } +} diff --git a/app/Jobs/GroupsPipeline/MemberJoinApprovedPipeline.php b/app/Jobs/GroupsPipeline/MemberJoinApprovedPipeline.php new file mode 100644 index 000000000..a3ec21982 --- /dev/null +++ b/app/Jobs/GroupsPipeline/MemberJoinApprovedPipeline.php @@ -0,0 +1,47 @@ +member = $member; + } + + /** + * Execute the job. + * + * @return void + */ + public function handle() + { + $member = $this->member; + $member->approved_at = now(); + $member->join_request = false; + $member->role = 'member'; + $member->save(); + + GroupService::del($member->group_id); + GroupService::delSelf($member->group_id, $member->profile_id); + } +} diff --git a/app/Jobs/GroupsPipeline/MemberJoinRejectedPipeline.php b/app/Jobs/GroupsPipeline/MemberJoinRejectedPipeline.php new file mode 100644 index 000000000..5e8226de0 --- /dev/null +++ b/app/Jobs/GroupsPipeline/MemberJoinRejectedPipeline.php @@ -0,0 +1,42 @@ +member = $member; + } + + /** + * Execute the job. + * + * @return void + */ + public function handle() + { + $member = $this->member; + $member->rejected_at = now(); + $member->save(); + } +} diff --git a/app/Jobs/GroupsPipeline/NewCommentPipeline.php b/app/Jobs/GroupsPipeline/NewCommentPipeline.php new file mode 100644 index 000000000..fb618a14d --- /dev/null +++ b/app/Jobs/GroupsPipeline/NewCommentPipeline.php @@ -0,0 +1,115 @@ +parent = $parent; + $this->status = $status; + } + + /** + * Execute the job. + * + * @return void + */ + public function handle() + { + $profile = $this->status->profile; + $status = $this->status; + + $parent = $this->parent; + $parent->reply_count = GroupComment::whereStatusId($parent->id)->count(); + $parent->save(); + + if ($profile->no_autolink == false) { + $this->parseEntities(); + } + } + + public function parseEntities() + { + $this->extractEntities(); + } + + public function extractEntities() + { + $this->entities = Extractor::create()->extract($this->status->caption); + $this->autolinkStatus(); + } + + public function autolinkStatus() + { + $this->autolink = Autolink::create()->autolink($this->status->caption); + $this->storeHashtags(); + } + + public function storeHashtags() + { + $tags = array_unique($this->entities['hashtags']); + $status = $this->status; + + foreach ($tags as $tag) { + if (mb_strlen($tag) > 124) { + continue; + } + DB::transaction(function () use ($status, $tag) { + $hashtag = GroupHashtag::firstOrCreate([ + 'name' => $tag, + ]); + + GroupPostHashtag::firstOrCreate( + [ + 'status_id' => $status->id, + 'group_id' => $status->group_id, + 'hashtag_id' => $hashtag->id, + 'profile_id' => $status->profile_id, + 'status_visibility' => $status->visibility, + ] + ); + }); + } + $this->storeMentions(); + } + + public function storeMentions() + { + // todo + } +} diff --git a/app/Jobs/GroupsPipeline/NewPostPipeline.php b/app/Jobs/GroupsPipeline/NewPostPipeline.php new file mode 100644 index 000000000..1302a0233 --- /dev/null +++ b/app/Jobs/GroupsPipeline/NewPostPipeline.php @@ -0,0 +1,108 @@ +status = $status; + } + + /** + * Execute the job. + * + * @return void + */ + public function handle() + { + $profile = $this->status->profile; + $status = $this->status; + + if ($profile->no_autolink == false) { + $this->parseEntities(); + } + } + + public function parseEntities() + { + $this->extractEntities(); + } + + public function extractEntities() + { + $this->entities = Extractor::create()->extract($this->status->caption); + $this->autolinkStatus(); + } + + public function autolinkStatus() + { + $this->autolink = Autolink::create()->autolink($this->status->caption); + $this->storeHashtags(); + } + + public function storeHashtags() + { + $tags = array_unique($this->entities['hashtags']); + $status = $this->status; + + foreach ($tags as $tag) { + if (mb_strlen($tag) > 124) { + continue; + } + DB::transaction(function () use ($status, $tag) { + $hashtag = GroupHashtag::firstOrCreate([ + 'name' => $tag, + ]); + + GroupPostHashtag::firstOrCreate( + [ + 'status_id' => $status->id, + 'group_id' => $status->group_id, + 'hashtag_id' => $hashtag->id, + 'profile_id' => $status->profile_id, + 'status_visibility' => $status->visibility, + ] + ); + }); + } + $this->storeMentions(); + } + + public function storeMentions() + { + // todo + } +} diff --git a/app/Models/Group.php b/app/Models/Group.php new file mode 100644 index 000000000..508ed98c0 --- /dev/null +++ b/app/Models/Group.php @@ -0,0 +1,67 @@ + 'json' + ]; + + public function url() + { + return url("/groups/{$this->id}"); + } + + public function permalink($suffix = null) + { + if(!$this->local) { + return $this->remote_url; + } + return $this->url() . $suffix; + } + + public function members() + { + return $this->hasMany(GroupMember::class); + } + + public function admin() + { + return $this->belongsTo(Profile::class, 'profile_id'); + } + + public function isMember($id = false) + { + $id = $id ?? request()->user()->profile_id; + // return $this->members()->whereProfileId($id)->whereJoinRequest(false)->exists(); + return GroupService::isMember($this->id, $id); + } + + public function getMembershipType() + { + return $this->is_private ? 'private' : ($this->is_local ? 'local' : 'all'); + } + + public function selfRole($id = false) + { + $id = $id ?? request()->user()->profile_id; + return optional($this->members()->whereProfileId($id)->first())->role ?? null; + } +} diff --git a/app/Models/GroupActivityGraph.php b/app/Models/GroupActivityGraph.php new file mode 100644 index 000000000..55981d20a --- /dev/null +++ b/app/Models/GroupActivityGraph.php @@ -0,0 +1,11 @@ +belongsTo(Profile::class); + } + + public function url() + { + return '/group/' . $this->group_id . '/c/' . $this->id; + } +} diff --git a/app/Models/GroupEvent.php b/app/Models/GroupEvent.php new file mode 100644 index 000000000..ddcd074cc --- /dev/null +++ b/app/Models/GroupEvent.php @@ -0,0 +1,11 @@ + 'array' + ]; +} diff --git a/app/Models/GroupInvitation.php b/app/Models/GroupInvitation.php new file mode 100644 index 000000000..adcd38ea4 --- /dev/null +++ b/app/Models/GroupInvitation.php @@ -0,0 +1,11 @@ + 'json', + 'metadata' => 'json' + ]; + + protected $fillable = [ + 'profile_id', + 'group_id' + ]; +} diff --git a/app/Models/GroupMedia.php b/app/Models/GroupMedia.php new file mode 100644 index 000000000..12f424151 --- /dev/null +++ b/app/Models/GroupMedia.php @@ -0,0 +1,39 @@ + + */ + protected function casts(): array + { + return [ + 'metadata' => 'json', + 'processed_at' => 'datetime', + 'thumbnail_generated' => 'datetime' + ]; + } + + public function url() + { + if($this->cdn_url) { + return $this->cdn_url; + } + return Storage::url($this->media_path); + } + + public function thumbnailUrl() + { + return $this->thumbnail_url; + } +} diff --git a/app/Models/GroupMember.php b/app/Models/GroupMember.php new file mode 100644 index 000000000..4f15e0d3e --- /dev/null +++ b/app/Models/GroupMember.php @@ -0,0 +1,16 @@ +belongsTo(Group::class); + } +} diff --git a/app/Models/GroupPost.php b/app/Models/GroupPost.php new file mode 100644 index 000000000..59693ec6b --- /dev/null +++ b/app/Models/GroupPost.php @@ -0,0 +1,57 @@ +group_id . '/' . $this->id; + } + + public function group() + { + return $this->belongsTo(Group::class); + } + + public function status() + { + return $this->belongsTo(Status::class); + } + + public function profile() + { + return $this->belongsTo(Profile::class); + } + + public function url() + { + return '/groups/' . $this->group_id . '/p/' . $this->id; + } +} diff --git a/app/Models/GroupPostHashtag.php b/app/Models/GroupPostHashtag.php new file mode 100644 index 000000000..46165dd7c --- /dev/null +++ b/app/Models/GroupPostHashtag.php @@ -0,0 +1,22 @@ + 100) { + $stop = 100; + } + + return Redis::zrevrange(self::CACHE_KEY.$gid, $start, $stop); + } + + public static function getRankedMaxId($gid, $start = null, $limit = 10) + { + if (! $start) { + return []; + } + + return array_keys(Redis::zrevrangebyscore(self::CACHE_KEY.$gid, $start, '-inf', [ + 'withscores' => true, + 'limit' => [1, $limit], + ])); + } + + public static function getRankedMinId($gid, $end = null, $limit = 10) + { + if (! $end) { + return []; + } + + return array_keys(Redis::zrevrangebyscore(self::CACHE_KEY.$gid, '+inf', $end, [ + 'withscores' => true, + 'limit' => [0, $limit], + ])); + } + + public static function add($gid, $val) + { + if (self::count($gid) > self::FEED_LIMIT) { + if (config('database.redis.client') === 'phpredis') { + Redis::zpopmin(self::CACHE_KEY.$gid); + } + } + + return Redis::zadd(self::CACHE_KEY.$gid, $val, $val); + } + + public static function rem($gid, $val) + { + return Redis::zrem(self::CACHE_KEY.$gid, $val); + } + + public static function del($gid, $val) + { + return self::rem($gid, $val); + } + + public static function count($gid) + { + return Redis::zcard(self::CACHE_KEY.$gid); + } + + public static function warmCache($gid, $force = false, $limit = 100) + { + if (self::count($gid) == 0 || $force == true) { + Redis::del(self::CACHE_KEY.$gid); + $ids = GroupPost::whereGroupId($gid) + ->orderByDesc('id') + ->limit($limit) + ->pluck('id'); + foreach ($ids as $id) { + self::add($gid, $id); + } + + return 1; + } + } +} diff --git a/app/Services/GroupPostService.php b/app/Services/GroupPostService.php new file mode 100644 index 000000000..7295bda40 --- /dev/null +++ b/app/Services/GroupPostService.php @@ -0,0 +1,49 @@ +find($pid); + + if (! $gp) { + return null; + } + + $fractal = new Fractal\Manager(); + $fractal->setSerializer(new ArraySerializer()); + $resource = new Fractal\Resource\Item($gp, new GroupPostTransformer()); + $res = $fractal->createData($resource)->toArray(); + + $res['pf_type'] = $gp['type']; + $res['url'] = $gp->url(); + + // if($gp['type'] == 'poll') { + // $status['poll'] = PollService::get($status['id']); + // } + //$status['account']['url'] = url("/groups/{$gp['group_id']}/user/{$status['account']['id']}"); + return $res; + }); + } + + public static function del($gid, $pid) + { + return Cache::forget(self::key($gid, $pid)); + } +} diff --git a/app/Services/GroupService.php b/app/Services/GroupService.php new file mode 100644 index 000000000..ac1a1a1c6 --- /dev/null +++ b/app/Services/GroupService.php @@ -0,0 +1,366 @@ +withoutRelations()->whereNull('status')->find($id); + + if(!$group) { + return null; + } + + $admin = $group->profile_id ? AccountService::get($group->profile_id) : null; + + return [ + 'id' => (string) $group->id, + 'name' => $group->name, + 'description' => $group->description, + 'short_description' => str_limit(strip_tags($group->description), 120), + 'category' => self::categoryById($group->category_id), + 'local' => (bool) $group->local, + 'url' => $group->url(), + 'shorturl' => url('/g/'.HashidService::encode($group->id)), + 'membership' => $group->getMembershipType(), + 'member_count' => $group->members()->whereJoinRequest(false)->count(), + 'verified' => false, + 'self' => null, + 'admin' => $admin, + 'config' => [ + 'recommended' => (bool) $group->recommended, + 'discoverable' => (bool) $group->discoverable, + 'activitypub' => (bool) $group->activitypub, + 'is_nsfw' => (bool) $group->is_nsfw, + 'dms' => (bool) $group->dms + ], + 'metadata' => $group->metadata, + 'created_at' => $group->created_at->toAtomString(), + ]; + } + ); + + if($pid) { + $res['self'] = self::getSelf($id, $pid); + } + + return $res; + } + + public static function del($id) + { + Cache::forget('ap:groups:object:' . $id); + return Cache::forget(self::key($id)); + } + + public static function getSelf($gid, $pid) + { + return Cache::remember( + self::key('self:gid-' . $gid . ':pid-' . $pid), + 3600, + function() use($gid, $pid) { + $group = Group::find($gid); + + if(!$gid || !$pid) { + return [ + 'is_member' => false, + 'role' => null, + 'is_requested' => null + ]; + } + + return [ + 'is_member' => $group->isMember($pid), + 'role' => $group->selfRole($pid), + 'is_requested' => optional($group->members()->whereProfileId($pid)->first())->join_request ?? false + ]; + } + ); + } + + public static function delSelf($gid, $pid) + { + Cache::forget(self::key("is_member:{$gid}:{$pid}")); + return Cache::forget(self::key('self:gid-' . $gid . ':pid-' . $pid)); + } + + public static function sidToGid($gid, $pid) + { + return Cache::remember(self::key('s2gid:' . $gid . ':' . $pid), 3600, function() use($gid, $pid) { + return optional(GroupPost::whereGroupId($gid)->whereStatusId($pid)->first())->id; + }); + } + + public static function membershipsByPid($pid) + { + return Cache::remember(self::key("mbpid:{$pid}"), 3600, function() use($pid) { + return GroupMember::whereProfileId($pid)->pluck('group_id'); + }); + } + + public static function config() + { + return [ + 'enabled' => config('exp.gps') ?? false, + 'limits' => [ + 'group' => [ + 'max' => 999, + 'federation' => false, + ], + + 'user' => [ + 'create' => [ + 'new' => true, + 'max' => 10 + ], + 'join' => [ + 'max' => 10 + ], + 'invite' => [ + 'max' => 20 + ] + ] + ], + 'guest' => [ + 'public' => false + ] + ]; + } + + public static function fetchRemote($url) + { + // todo: refactor this demo + $res = Helpers::fetchFromUrl($url); + + if(!$res || !isset($res['type']) || $res['type'] != 'Group') { + return false; + } + + $group = Group::whereRemoteUrl($url)->first(); + + if($group) { + return $group; + } + + $group = new Group; + $group->remote_url = $res['url']; + $group->name = $res['name']; + $group->inbox_url = $res['inbox']; + $group->metadata = [ + 'header' => [ + 'url' => $res['icon']['image']['url'] + ] + ]; + $group->description = Purify::clean($res['summary']); + $group->local = false; + $group->save(); + + return $group->url(); + } + + public static function log( + string $groupId, + string $profileId, + string $type = null, + array $meta = null, + string $itemType = null, + string $itemId = null + ) + { + // todo: truncate (some) metadata after XX days in cron/queue + $log = new GroupInteraction; + $log->group_id = $groupId; + $log->profile_id = $profileId; + $log->type = $type; + $log->item_type = $itemType; + $log->item_id = $itemId; + $log->metadata = $meta; + $log->save(); + } + + public static function getRejoinTimeout($gid, $pid) + { + $key = self::key('rejoin-timeout:gid-' . $gid . ':pid-' . $pid); + return Cache::has($key); + } + + public static function setRejoinTimeout($gid, $pid) + { + // todo: allow group admins to manually remove timeout + $key = self::key('rejoin-timeout:gid-' . $gid . ':pid-' . $pid); + return Cache::put($key, 1, 86400); + } + + public static function getMemberInboxes($id) + { + // todo: cache this, maybe add join/leave methods to this service to handle cache invalidation + $group = (new Group)->withoutRelations()->findOrFail($id); + if(!$group->local) { + return []; + } + $members = GroupMember::whereGroupId($id)->whereLocalProfile(false)->pluck('profile_id'); + return Profile::find($members)->map(function($u) { + return $u->sharedInbox ?? $u->inbox_url; + })->toArray(); + } + + public static function getInteractionLimits($gid, $pid) + { + return Cache::remember(self::key(":il:{$gid}:{$pid}"), 3600, function() use($gid, $pid) { + $limit = GroupLimit::whereGroupId($gid)->whereProfileId($pid)->first(); + if(!$limit) { + return [ + 'limits' => [ + 'can_post' => true, + 'can_comment' => true, + 'can_like' => true + ], + 'updated_at' => null + ]; + } + + return [ + 'limits' => $limit->limits, + 'updated_at' => $limit->updated_at->format('c') + ]; + }); + } + + public static function clearInteractionLimits($gid, $pid) + { + return Cache::forget(self::key(":il:{$gid}:{$pid}")); + } + + public static function canPost($gid, $pid) + { + $limits = self::getInteractionLimits($gid, $pid); + if($limits) { + return (bool) $limits['limits']['can_post']; + } else { + return true; + } + } + + public static function canComment($gid, $pid) + { + $limits = self::getInteractionLimits($gid, $pid); + if($limits) { + return (bool) $limits['limits']['can_comment']; + } else { + return true; + } + } + + public static function canLike($gid, $pid) + { + $limits = self::getInteractionLimits($gid, $pid); + if($limits) { + return (bool) $limits['limits']['can_like']; + } else { + return true; + } + } + + public static function categories($onlyActive = true) + { + return Cache::remember(self::key(':categories'), 2678400, function() use($onlyActive) { + return GroupCategory::when($onlyActive, function($q, $onlyActive) { + return $q->whereActive(true); + }) + ->orderBy('order') + ->pluck('name') + ->toArray(); + }); + } + + public static function categoryById($id) + { + return Cache::remember(self::key(':categorybyid:'.$id), 2678400, function() use($id) { + $category = GroupCategory::find($id); + if($category) { + return [ + 'name' => $category->name, + 'url' => url("/groups/explore/category/{$category->slug}") + ]; + } + return false; + }); + } + + public static function isMember($gid = false, $pid = false) + { + if(!$gid || !$pid) { + return false; + } + + $key = self::key("is_member:{$gid}:{$pid}"); + return Cache::remember($key, 3600, function() use($gid, $pid) { + return GroupMember::whereGroupId($gid) + ->whereProfileId($pid) + ->whereJoinRequest(false) + ->exists(); + }); + } + + public static function mutualGroups($cid = false, $pid = false, $exclude = []) + { + if(!$cid || !$pid) { + return [ + 'count' => 0, + 'groups' => [] + ]; + } + + $self = self::membershipsByPid($cid); + $user = self::membershipsByPid($pid); + + if(!$self->count() || !$user->count()) { + return [ + 'count' => 0, + 'groups' => [] + ]; + } + + $intersect = $self->intersect($user); + $count = $intersect->count(); + $groups = $intersect + ->values() + ->filter(function($id) use($exclude) { + return !in_array($id, $exclude); + }) + ->shuffle() + ->take(1) + ->map(function($id) { + return self::get($id); + }); + + return [ + 'count' => $count, + 'groups' => $groups + ]; + } +} diff --git a/app/Services/Groups/GroupAccountService.php b/app/Services/Groups/GroupAccountService.php new file mode 100644 index 000000000..2d86e4f43 --- /dev/null +++ b/app/Services/Groups/GroupAccountService.php @@ -0,0 +1,51 @@ +whereProfileId($pid)->first(); + if(!$membership) { + return []; + } + + return [ + 'joined' => $membership->created_at->format('c'), + 'role' => $membership->role, + 'local_group' => (bool) $membership->local_group, + 'local_profile' => (bool) $membership->local_profile, + ]; + }); + return $account; + } + + public static function del($gid, $pid) + { + $key = self::CACHE_KEY . $gid . ':' . $pid; + return Cache::forget($key); + } +} diff --git a/app/Services/Groups/GroupActivityPubService.php b/app/Services/Groups/GroupActivityPubService.php new file mode 100644 index 000000000..4c48b22c4 --- /dev/null +++ b/app/Services/Groups/GroupActivityPubService.php @@ -0,0 +1,311 @@ +first(); + if($group) { + return $group; + } + + $res = ActivityPubFetchService::get($url); + if(!$res) { + return $res; + } + $json = json_decode($res, true); + $group = self::validateGroup($json); + if(!$group) { + return false; + } + if($saveOnFetch) { + return self::storeGroup($group); + } + return $group; + } + + public static function fetchGroupPost($url, $saveOnFetch = true) + { + $group = GroupPost::where('remote_url', $url)->first(); + + if($group) { + return $group; + } + + $res = ActivityPubFetchService::get($url); + if(!$res) { + return 'invalid res'; + } + $json = json_decode($res, true); + if(!$json) { + return 'invalid json'; + } + if(isset($json['inReplyTo'])) { + $comment = self::validateGroupComment($json); + return self::storeGroupComment($comment); + } + + $group = self::validateGroupPost($json); + if($saveOnFetch) { + return self::storeGroupPost($group); + } + return $group; + } + + public static function validateGroup($obj) + { + $validator = Validator::make($obj, [ + '@context' => 'required', + 'id' => ['required', 'url', new ValidUrl], + 'type' => 'required|in:Group', + 'preferredUsername' => 'required', + 'name' => 'required', + 'url' => ['sometimes', 'url', new ValidUrl], + 'inbox' => ['required', 'url', new ValidUrl], + 'outbox' => ['required', 'url', new ValidUrl], + 'followers' => ['required', 'url', new ValidUrl], + 'attributedTo' => 'required', + 'summary' => 'sometimes', + 'publicKey' => 'required', + 'publicKey.id' => 'required', + 'publicKey.owner' => ['required', 'url', 'same:id', new ValidUrl], + 'publicKey.publicKeyPem' => 'required', + ]); + + if($validator->fails()) { + return false; + } + + return $validator->validated(); + } + + public static function validateGroupPost($obj) + { + $validator = Validator::make($obj, [ + '@context' => 'required', + 'id' => ['required', 'url', new ValidUrl], + 'type' => 'required|in:Page,Note', + 'to' => 'required|array', + 'to.*' => ['required', 'url', new ValidUrl], + 'cc' => 'sometimes|array', + 'cc.*' => ['sometimes', 'url', new ValidUrl], + 'url' => ['sometimes', 'url', new ValidUrl], + 'attributedTo' => 'required', + 'name' => 'sometimes', + 'target' => 'sometimes', + 'audience' => 'sometimes', + 'inReplyTo' => 'sometimes', + 'content' => 'sometimes', + 'mediaType' => 'sometimes', + 'sensitive' => 'sometimes', + 'attachment' => 'sometimes', + 'published' => 'required', + ]); + + if($validator->fails()) { + return false; + } + + return $validator->validated(); + } + + public static function validateGroupComment($obj) + { + $validator = Validator::make($obj, [ + '@context' => 'required', + 'id' => ['required', 'url', new ValidUrl], + 'type' => 'required|in:Note', + 'to' => 'required|array', + 'to.*' => ['required', 'url', new ValidUrl], + 'cc' => 'sometimes|array', + 'cc.*' => ['sometimes', 'url', new ValidUrl], + 'url' => ['sometimes', 'url', new ValidUrl], + 'attributedTo' => 'required', + 'name' => 'sometimes', + 'target' => 'sometimes', + 'audience' => 'sometimes', + 'inReplyTo' => 'sometimes', + 'content' => 'sometimes', + 'mediaType' => 'sometimes', + 'sensitive' => 'sometimes', + 'published' => 'required', + ]); + + if($validator->fails()) { + return $validator->errors(); + return false; + } + + return $validator->validated(); + } + + public static function getGroupFromPostActivity($groupPost) + { + if(isset($groupPost['audience']) && is_string($groupPost['audience'])) { + return $groupPost['audience']; + } + + if( + isset( + $groupPost['target'], + $groupPost['target']['type'], + $groupPost['target']['attributedTo'] + ) && $groupPost['target']['type'] == 'Collection' + ) { + return $groupPost['target']['attributedTo']; + } + + return false; + } + + public static function getActorFromPostActivity($groupPost) + { + if(!isset($groupPost['attributedTo'])) { + return false; + } + + $field = $groupPost['attributedTo']; + + if(is_string($field)) { + return $field; + } + + if(is_array($field) && count($field) === 1) { + if( + isset( + $field[0]['id'], + $field[0]['type'] + ) && + $field[0]['type'] === 'Person' && + is_string($field[0]['id']) + ) { + return $field[0]['id']; + } + } + + return false; + } + + public static function getCaptionFromPostActivity($groupPost) + { + if(!isset($groupPost['name']) && isset($groupPost['content'])) { + return Purify::clean(strip_tags($groupPost['content'])); + } + + if(isset($groupPost['name'], $groupPost['content'])) { + return Purify::clean(strip_tags($groupPost['name'])) . Purify::clean(strip_tags($groupPost['content'])); + } + } + + public static function getSensitiveFromPostActivity($groupPost) + { + if(!isset($groupPost['sensitive'])) { + return true; + } + + if(isset($groupPost['sensitive']) && !is_bool($groupPost['sensitive'])) { + return true; + } + + return boolval($groupPost['sensitive']); + } + + public static function storeGroup($activity) + { + $group = new Group; + $group->profile_id = null; + $group->category_id = 1; + $group->name = $activity['name'] ?? 'Untitled Group'; + $group->description = isset($activity['summary']) ? Purify::clean($activity['summary']) : null; + $group->is_private = false; + $group->local_only = false; + $group->metadata = []; + $group->local = false; + $group->remote_url = $activity['id']; + $group->inbox_url = $activity['inbox']; + $group->activitypub = true; + $group->save(); + + return $group; + } + + public static function storeGroupPost($groupPost) + { + $groupUrl = self::getGroupFromPostActivity($groupPost); + if(!$groupUrl) { + return; + } + $group = self::fetchGroup($groupUrl, true); + if(!$group) { + return; + } + $actorUrl = self::getActorFromPostActivity($groupPost); + $actor = Helpers::profileFetch($actorUrl); + $caption = self::getCaptionFromPostActivity($groupPost); + $sensitive = self::getSensitiveFromPostActivity($groupPost); + $model = GroupPost::firstOrCreate( + [ + 'remote_url' => $groupPost['id'], + ], [ + 'group_id' => $group->id, + 'profile_id' => $actor->id, + 'type' => 'text', + 'caption' => $caption, + 'visibility' => 'public', + 'is_nsfw' => $sensitive, + ] + ); + return $model; + } + + public static function storeGroupComment($groupPost) + { + $groupUrl = self::getGroupFromPostActivity($groupPost); + if(!$groupUrl) { + return; + } + $group = self::fetchGroup($groupUrl, true); + if(!$group) { + return; + } + $actorUrl = self::getActorFromPostActivity($groupPost); + $actor = Helpers::profileFetch($actorUrl); + $caption = self::getCaptionFromPostActivity($groupPost); + $sensitive = self::getSensitiveFromPostActivity($groupPost); + $parentPost = self::fetchGroupPost($groupPost['inReplyTo']); + $model = GroupComment::firstOrCreate( + [ + 'remote_url' => $groupPost['id'], + ], [ + 'group_id' => $group->id, + 'profile_id' => $actor->id, + 'status_id' => $parentPost->id, + 'type' => 'text', + 'caption' => $caption, + 'visibility' => 'public', + 'is_nsfw' => $sensitive, + 'local' => $actor->private_key != null + ] + ); + return $model; + } +} diff --git a/app/Services/Groups/GroupCommentService.php b/app/Services/Groups/GroupCommentService.php new file mode 100644 index 000000000..52eeee533 --- /dev/null +++ b/app/Services/Groups/GroupCommentService.php @@ -0,0 +1,50 @@ +find($pid); + + if(!$gp) { + return null; + } + + $fractal = new Fractal\Manager(); + $fractal->setSerializer(new ArraySerializer()); + $resource = new Fractal\Resource\Item($gp, new GroupPostTransformer()); + $res = $fractal->createData($resource)->toArray(); + + $res['pf_type'] = 'group:post:comment'; + $res['url'] = $gp->url(); + // if($gp['type'] == 'poll') { + // $status['poll'] = PollService::get($status['id']); + // } + //$status['account']['url'] = url("/groups/{$gp['group_id']}/user/{$status['account']['id']}"); + return $res; + }); + } + + public static function del($gid, $pid) + { + return Cache::forget(self::key($gid, $pid)); + } +} diff --git a/app/Services/Groups/GroupFeedService.php b/app/Services/Groups/GroupFeedService.php new file mode 100644 index 000000000..a2a87be1d --- /dev/null +++ b/app/Services/Groups/GroupFeedService.php @@ -0,0 +1,95 @@ + 100) { + $stop = 100; + } + + return Redis::zrevrange(self::CACHE_KEY . $gid, $start, $stop); + } + + public static function getRankedMaxId($gid, $start = null, $limit = 10) + { + if(!$start) { + return []; + } + + return array_keys(Redis::zrevrangebyscore(self::CACHE_KEY . $gid, $start, '-inf', [ + 'withscores' => true, + 'limit' => [1, $limit] + ])); + } + + public static function getRankedMinId($gid, $end = null, $limit = 10) + { + if(!$end) { + return []; + } + + return array_keys(Redis::zrevrangebyscore(self::CACHE_KEY . $gid, '+inf', $end, [ + 'withscores' => true, + 'limit' => [0, $limit] + ])); + } + + public static function add($gid, $val) + { + if(self::count($gid) > self::FEED_LIMIT) { + if(config('database.redis.client') === 'phpredis') { + Redis::zpopmin(self::CACHE_KEY . $gid); + } + } + + return Redis::zadd(self::CACHE_KEY . $gid, $val, $val); + } + + public static function rem($gid, $val) + { + return Redis::zrem(self::CACHE_KEY . $gid, $val); + } + + public static function del($gid, $val) + { + return self::rem($gid, $val); + } + + public static function count($gid) + { + return Redis::zcard(self::CACHE_KEY . $gid); + } + + public static function warmCache($gid, $force = false, $limit = 100) + { + if(self::count($gid) == 0 || $force == true) { + Redis::del(self::CACHE_KEY . $gid); + $ids = GroupPost::whereGroupId($gid) + ->orderByDesc('id') + ->limit($limit) + ->pluck('id'); + foreach($ids as $id) { + self::add($gid, $id); + } + return 1; + } + } +} diff --git a/app/Services/Groups/GroupHashtagService.php b/app/Services/Groups/GroupHashtagService.php new file mode 100644 index 000000000..6553850f0 --- /dev/null +++ b/app/Services/Groups/GroupHashtagService.php @@ -0,0 +1,28 @@ + $tag->name, + 'slug' => Str::slug($tag->name), + ]; + }); + } +} diff --git a/app/Services/Groups/GroupMediaService.php b/app/Services/Groups/GroupMediaService.php new file mode 100644 index 000000000..0200e3a56 --- /dev/null +++ b/app/Services/Groups/GroupMediaService.php @@ -0,0 +1,114 @@ +orderBy('order')->get(); + if(!$media) { + return []; + } + $medias = $media->map(function($media) { + return [ + 'id' => (string) $media->id, + 'type' => 'Document', + 'url' => $media->url(), + 'preview_url' => $media->url(), + 'remote_url' => $media->url, + 'description' => $media->cw_summary, + 'blurhash' => $media->blurhash ?? 'U4Rfzst8?bt7ogayj[j[~pfQ9Goe%Mj[WBay' + ]; + }); + return $medias->toArray(); + }); + } + + public static function getMastodon($id) + { + $media = self::get($id); + if(!$media) { + return []; + } + $medias = collect($media) + ->map(function($media) { + $mime = $media['mime'] ? explode('/', $media['mime']) : false; + unset( + $media['optimized_url'], + $media['license'], + $media['is_nsfw'], + $media['orientation'], + $media['filter_name'], + $media['filter_class'], + $media['mime'], + $media['hls_manifest'] + ); + + $media['type'] = $mime ? strtolower($mime[0]) : 'unknown'; + return $media; + }) + ->filter(function($m) { + return $m && isset($m['url']); + }) + ->values(); + + return $medias->toArray(); + } + + public static function del($statusId) + { + return Cache::forget(self::CACHE_KEY . $statusId); + } + + public static function activitypub($statusId) + { + $status = self::get($statusId); + if(!$status) { + return []; + } + + return collect($status)->map(function($s) { + $license = isset($s['license']) && $s['license']['title'] ? $s['license']['title'] : null; + return [ + 'type' => 'Document', + 'mediaType' => $s['mime'], + 'url' => $s['url'], + 'name' => $s['description'], + 'summary' => $s['description'], + 'blurhash' => $s['blurhash'], + 'license' => $license + ]; + }); + } +} diff --git a/app/Services/Groups/GroupPostService.php b/app/Services/Groups/GroupPostService.php new file mode 100644 index 000000000..a043be134 --- /dev/null +++ b/app/Services/Groups/GroupPostService.php @@ -0,0 +1,83 @@ +find($pid); + + if(!$gp) { + return null; + } + + $fractal = new Fractal\Manager(); + $fractal->setSerializer(new ArraySerializer()); + $resource = new Fractal\Resource\Item($gp, new GroupPostTransformer()); + $res = $fractal->createData($resource)->toArray(); + + $res['pf_type'] = $gp['type']; + $res['url'] = $gp->url(); + // if($gp['type'] == 'poll') { + // $status['poll'] = PollService::get($status['id']); + // } + //$status['account']['url'] = url("/groups/{$gp['group_id']}/user/{$status['account']['id']}"); + return $res; + }); + } + + public static function del($gid, $pid) + { + return Cache::forget(self::key($gid, $pid)); + } + + public function getStatus(Request $request) + { + $gid = $request->input('gid'); + $sid = $request->input('sid'); + $pid = optional($request->user())->profile_id ?? false; + + $group = Group::findOrFail($gid); + + if($group->is_private) { + abort_if(!$group->isMember($pid), 404); + } + + $gp = GroupPost::whereGroupId($group->id)->whereId($sid)->firstOrFail(); + + $status = GroupPostService::get($gp['group_id'], $gp['id']); + if(!$status) { + return false; + } + $status['reply_count'] = $gp['reply_count']; + $status['favourited'] = (bool) GroupsLikeService::liked($pid, $gp['id']); + $status['favourites_count'] = GroupsLikeService::count($gp['id']); + $status['pf_type'] = $gp['type']; + $status['visibility'] = 'public'; + $status['url'] = $gp->url(); + $status['account']['url'] = url("/groups/{$gp->group_id}/user/{$gp->profile_id}"); + + // if($gp['type'] == 'poll') { + // $status['poll'] = PollService::get($status['id']); + // } + + return $status; + } +} diff --git a/app/Services/Groups/GroupsLikeService.php b/app/Services/Groups/GroupsLikeService.php new file mode 100644 index 000000000..e2daa1e71 --- /dev/null +++ b/app/Services/Groups/GroupsLikeService.php @@ -0,0 +1,85 @@ + 400) { + Redis::zpopmin(self::CACHE_SET_KEY . $profileId); + } + + return Redis::zadd(self::CACHE_SET_KEY . $profileId, $statusId, $statusId); + } + + public static function setCount($id) + { + return Redis::zcard(self::CACHE_SET_KEY . $id); + } + + public static function setRem($profileId, $val) + { + return Redis::zrem(self::CACHE_SET_KEY . $profileId, $val); + } + + public static function get($profileId, $start = 0, $stop = 10) + { + if($stop > 100) { + $stop = 100; + } + + return Redis::zrevrange(self::CACHE_SET_KEY . $profileId, $start, $stop); + } + + public static function remove($profileId, $statusId) + { + $key = self::CACHE_KEY . $profileId . ':' . $statusId; + Cache::decrement(self::CACHE_POST_KEY . $statusId); + //Cache::forget('pf:services:likes:liked_by:'.$statusId); + self::setRem($profileId, $statusId); + return Cache::put($key, false, 86400); + } + + public static function liked($profileId, $statusId) + { + $key = self::CACHE_KEY . $profileId . ':' . $statusId; + return Cache::remember($key, 900, function() use($profileId, $statusId) { + return GroupLike::whereProfileId($profileId)->whereStatusId($statusId)->exists(); + }); + } + + public static function likedBy($status) + { + $empty = [ + 'username' => null, + 'others' => false + ]; + + return $empty; + } + + public static function count($id) + { + return Cache::get(self::CACHE_POST_KEY . $id, 0); + } + +} diff --git a/app/Transformer/Api/GroupPostTransformer.php b/app/Transformer/Api/GroupPostTransformer.php new file mode 100644 index 000000000..0999b3fa4 --- /dev/null +++ b/app/Transformer/Api/GroupPostTransformer.php @@ -0,0 +1,59 @@ + (string) $status->id, + 'gid' => $status->group_id ? (string) $status->group_id : null, + 'url' => '/groups/' . $status->group_id . '/p/' . $status->id, + 'content' => $status->caption, + 'content_text' => $status->caption, + 'created_at' => str_replace('+00:00', 'Z', $status->created_at->format(DATE_RFC3339_EXTENDED)), + 'reblogs_count' => $status->reblogs_count ?? 0, + 'favourites_count' => $status->likes_count ?? 0, + 'reblogged' => null, + 'favourited' => null, + 'muted' => null, + 'sensitive' => (bool) $status->is_nsfw, + 'spoiler_text' => $status->cw_summary ?? '', + 'visibility' => $status->visibility, + 'application' => [ + 'name' => 'web', + 'website' => null + ], + 'language' => null, + 'pf_type' => $status->type, + 'reply_count' => (int) $status->reply_count ?? 0, + 'comments_disabled' => (bool) $status->comments_disabled, + 'thread' => false, + 'media_attachments' => GroupMediaService::get($status->id), + 'replies' => [], + 'parent' => [], + 'place' => null, + 'local' => (bool) !$status->remote_url, + 'account' => AccountService::get($status->profile_id, true), + 'poll' => [], + ]; + } +} diff --git a/app/Util/ActivityPub/Helpers.php b/app/Util/ActivityPub/Helpers.php index 2002a8967..9e03beef1 100644 --- a/app/Util/ActivityPub/Helpers.php +++ b/app/Util/ActivityPub/Helpers.php @@ -858,6 +858,11 @@ class Helpers return self::profileFirstOrNew($url); } + public static function getSignedFetch($url) + { + return ActivityPubFetchService::get($url); + } + public static function sendSignedObject($profile, $url, $body) { if (app()->environment() !== 'production') { diff --git a/config/groups.php b/config/groups.php new file mode 100644 index 000000000..24513e502 --- /dev/null +++ b/config/groups.php @@ -0,0 +1,13 @@ + env('GROUPS_ENABLED', false), + 'federation' => env('GROUPS_FEDERATION', true), + + 'acl' => [ + 'create_group' => [ + 'admins' => env('GROUPS_ACL_CREATE_ADMINS', true), + 'users' => env('GROUPS_ACL_CREATE_USERS', true), + ] + ] +]; diff --git a/database/migrations/2021_08_04_100435_create_group_roles_table.php b/database/migrations/2021_08_04_100435_create_group_roles_table.php new file mode 100644 index 000000000..c2b0d0ff4 --- /dev/null +++ b/database/migrations/2021_08_04_100435_create_group_roles_table.php @@ -0,0 +1,36 @@ +id(); + $table->bigInteger('group_id')->unsigned()->index(); + $table->string('name'); + $table->string('slug')->nullable(); + $table->text('abilities')->nullable(); + $table->unique(['group_id', 'slug']); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('group_roles'); + } +} diff --git a/database/migrations/2021_08_16_100034_create_group_interactions_table.php b/database/migrations/2021_08_16_100034_create_group_interactions_table.php new file mode 100644 index 000000000..adc32d1d1 --- /dev/null +++ b/database/migrations/2021_08_16_100034_create_group_interactions_table.php @@ -0,0 +1,37 @@ +bigIncrements('id'); + $table->bigInteger('group_id')->unsigned()->index(); + $table->bigInteger('profile_id')->unsigned()->index(); + $table->string('type')->nullable()->index(); + $table->string('item_type')->nullable()->index(); + $table->string('item_id')->nullable()->index(); + $table->json('metadata')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('group_interactions'); + } +} diff --git a/database/migrations/2021_08_17_073839_create_group_reports_table.php b/database/migrations/2021_08_17_073839_create_group_reports_table.php new file mode 100644 index 000000000..93ed00d63 --- /dev/null +++ b/database/migrations/2021_08_17_073839_create_group_reports_table.php @@ -0,0 +1,39 @@ +id(); + $table->bigInteger('group_id')->unsigned()->index(); + $table->bigInteger('profile_id')->unsigned()->index(); + $table->string('type')->nullable()->index(); + $table->string('item_type')->nullable()->index(); + $table->string('item_id')->nullable()->index(); + $table->json('metadata')->nullable(); + $table->boolean('open')->default(true)->index(); + $table->unique(['group_id', 'profile_id', 'item_type', 'item_id']); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('group_reports'); + } +} diff --git a/database/migrations/2021_09_26_112423_create_group_blocks_table.php b/database/migrations/2021_09_26_112423_create_group_blocks_table.php new file mode 100644 index 000000000..320fcf985 --- /dev/null +++ b/database/migrations/2021_09_26_112423_create_group_blocks_table.php @@ -0,0 +1,40 @@ +bigIncrements('id'); + $table->bigInteger('group_id')->unsigned()->index(); + $table->bigInteger('admin_id')->unsigned()->nullable(); + $table->bigInteger('profile_id')->nullable()->unsigned()->index(); + $table->bigInteger('instance_id')->nullable()->unsigned()->index(); + $table->string('name')->nullable()->index(); + $table->string('reason')->nullable(); + $table->boolean('is_user')->index(); + $table->boolean('moderated')->default(false)->index(); + $table->unique(['group_id', 'profile_id', 'instance_id']); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('group_blocks'); + } +} diff --git a/database/migrations/2021_09_29_023230_create_group_limits_table.php b/database/migrations/2021_09_29_023230_create_group_limits_table.php new file mode 100644 index 000000000..67ca7bec8 --- /dev/null +++ b/database/migrations/2021_09_29_023230_create_group_limits_table.php @@ -0,0 +1,36 @@ +id(); + $table->bigInteger('group_id')->unsigned()->index(); + $table->bigInteger('profile_id')->unsigned()->index(); + $table->json('limits')->nullable(); + $table->json('metadata')->nullable(); + $table->unique(['group_id', 'profile_id']); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('group_limits'); + } +} diff --git a/database/migrations/2021_10_01_083917_create_group_categories_table.php b/database/migrations/2021_10_01_083917_create_group_categories_table.php new file mode 100644 index 000000000..481ddf5ef --- /dev/null +++ b/database/migrations/2021_10_01_083917_create_group_categories_table.php @@ -0,0 +1,102 @@ +id(); + $table->string('name')->unique()->index(); + $table->string('slug')->unique()->index(); + $table->boolean('active')->default(true)->index(); + $table->tinyInteger('order')->unsigned()->nullable(); + $table->json('metadata')->nullable(); + $table->timestamps(); + }); + + $default = [ + 'General', + 'Photography', + 'Fediverse', + 'CompSci & Programming', + 'Causes & Movements', + 'Humor', + 'Science & Tech', + 'Travel', + 'Buy & Sell', + 'Business', + 'Style', + 'Animals', + 'Sports & Fitness', + 'Education', + 'Arts', + 'Entertainment', + 'Faith & Spirituality', + 'Relationships & Identity', + 'Parenting', + 'Hobbies & Interests', + 'Food & Drink', + 'Vehicles & Commutes', + 'Civics & Community', + ]; + + for ($i=1; $i <= 23; $i++) { + $cat = new GroupCategory; + $cat->name = $default[$i - 1]; + $cat->slug = str_slug($cat->name); + $cat->active = true; + $cat->order = $i; + $cat->save(); + } + + Schema::table('groups', function (Blueprint $table) { + $table->unsignedInteger('category_id')->default(1)->index()->after('id'); + $table->unsignedInteger('member_count')->nullable(); + $table->boolean('recommended')->default(false)->index(); + $table->boolean('discoverable')->default(false)->index(); + $table->boolean('activitypub')->default(false); + $table->boolean('is_nsfw')->default(false); + $table->boolean('dms')->default(false); + $table->boolean('autospam')->default(false); + $table->boolean('verified')->default(false); + $table->timestamp('last_active_at')->nullable(); + $table->softDeletes(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('group_categories'); + + Schema::table('groups', function (Blueprint $table) { + $table->dropColumn('category_id'); + $table->dropColumn('member_count'); + $table->dropColumn('recommended'); + $table->dropColumn('activitypub'); + $table->dropColumn('is_nsfw'); + $table->dropColumn('discoverable'); + $table->dropColumn('dms'); + $table->dropColumn('autospam'); + $table->dropColumn('verified'); + $table->dropColumn('last_active_at'); + $table->dropColumn('deleted_at'); + }); + } +} diff --git a/database/migrations/2021_10_09_004230_create_group_hashtags_table.php b/database/migrations/2021_10_09_004230_create_group_hashtags_table.php new file mode 100644 index 000000000..1d05dabb9 --- /dev/null +++ b/database/migrations/2021_10_09_004230_create_group_hashtags_table.php @@ -0,0 +1,36 @@ +bigIncrements('id'); + $table->string('name')->unique()->index(); + $table->string('formatted')->nullable(); + $table->boolean('recommended')->default(false); + $table->boolean('sensitive')->default(false); + $table->boolean('banned')->default(false); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('group_hashtags'); + } +} diff --git a/database/migrations/2021_10_09_004436_create_group_post_hashtags_table.php b/database/migrations/2021_10_09_004436_create_group_post_hashtags_table.php new file mode 100644 index 000000000..08014e399 --- /dev/null +++ b/database/migrations/2021_10_09_004436_create_group_post_hashtags_table.php @@ -0,0 +1,41 @@ +bigIncrements('id'); + $table->bigInteger('hashtag_id')->unsigned()->index(); + $table->bigInteger('group_id')->unsigned()->index(); + $table->bigInteger('profile_id')->unsigned(); + $table->bigInteger('status_id')->unsigned()->nullable(); + $table->string('status_visibility')->nullable(); + $table->boolean('nsfw')->default(false); + $table->unique(['hashtag_id', 'group_id', 'profile_id', 'status_id'], 'group_post_hashtags_gda_unique'); + $table->foreign('group_id')->references('id')->on('groups')->onDelete('cascade'); + $table->foreign('profile_id')->references('id')->on('profiles')->onDelete('cascade'); + $table->foreign('hashtag_id')->references('id')->on('group_hashtags')->onDelete('cascade'); + $table->foreign('status_id')->references('id')->on('group_posts')->onDelete('cascade'); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('group_post_hashtags'); + } +} diff --git a/database/migrations/2021_10_13_002033_create_group_stores_table.php b/database/migrations/2021_10_13_002033_create_group_stores_table.php new file mode 100644 index 000000000..efdf0a966 --- /dev/null +++ b/database/migrations/2021_10_13_002033_create_group_stores_table.php @@ -0,0 +1,37 @@ +bigIncrements('id'); + $table->bigInteger('group_id')->unsigned()->nullable()->index(); + $table->string('store_key')->index(); + $table->json('store_value')->nullable(); + $table->json('metadata')->nullable(); + $table->unique(['group_id', 'store_key']); + $table->foreign('group_id')->references('id')->on('groups')->onDelete('cascade'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('group_stores'); + } +} diff --git a/database/migrations/2021_10_13_002041_create_group_events_table.php b/database/migrations/2021_10_13_002041_create_group_events_table.php new file mode 100644 index 000000000..166c35cf0 --- /dev/null +++ b/database/migrations/2021_10_13_002041_create_group_events_table.php @@ -0,0 +1,44 @@ +bigIncrements('id'); + $table->bigInteger('group_id')->unsigned()->nullable()->index(); + $table->bigInteger('profile_id')->unsigned()->nullable()->index(); + $table->string('name')->nullable(); + $table->string('type')->index(); + $table->json('tags')->nullable(); + $table->json('location')->nullable(); + $table->text('description')->nullable(); + $table->json('metadata')->nullable(); + $table->boolean('open')->default(false)->index(); + $table->boolean('comments_open')->default(false); + $table->boolean('show_guest_list')->default(false); + $table->timestamp('start_at')->nullable(); + $table->timestamp('end_at')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('group_events'); + } +} diff --git a/database/migrations/2021_10_13_002124_create_group_activity_graphs_table.php b/database/migrations/2021_10_13_002124_create_group_activity_graphs_table.php new file mode 100644 index 000000000..13fef7240 --- /dev/null +++ b/database/migrations/2021_10_13_002124_create_group_activity_graphs_table.php @@ -0,0 +1,36 @@ +bigIncrements('id'); + $table->bigInteger('instance_id')->nullable()->index(); + $table->bigInteger('actor_id')->nullable()->index(); + $table->string('verb')->nullable()->index(); + $table->string('id_url')->nullable()->unique()->index(); + $table->json('payload')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('group_activity_graphs'); + } +} diff --git a/database/migrations/2024_05_20_062706_update_group_posts_table.php b/database/migrations/2024_05_20_062706_update_group_posts_table.php new file mode 100644 index 000000000..99f272be9 --- /dev/null +++ b/database/migrations/2024_05_20_062706_update_group_posts_table.php @@ -0,0 +1,48 @@ +dropColumn('status_id'); + $table->dropColumn('reply_child_id'); + $table->dropColumn('in_reply_to_id'); + $table->dropColumn('reblog_of_id'); + $table->text('caption')->nullable(); + $table->string('visibility')->nullable(); + $table->boolean('is_nsfw')->default(false); + $table->unsignedInteger('likes_count')->default(0); + $table->text('cw_summary')->nullable(); + $table->json('media_ids')->nullable(); + $table->boolean('comments_disabled')->default(false); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('group_posts', function (Blueprint $table) { + $table->bigInteger('status_id')->unsigned()->unique()->nullable(); + $table->bigInteger('reply_child_id')->unsigned()->nullable(); + $table->bigInteger('in_reply_to_id')->unsigned()->nullable(); + $table->bigInteger('reblog_of_id')->unsigned()->nullable(); + $table->dropColumn('caption'); + $table->dropColumn('is_nsfw'); + $table->dropColumn('visibility'); + $table->dropColumn('likes_count'); + $table->dropColumn('cw_summary'); + $table->dropColumn('media_ids'); + $table->dropColumn('comments_disabled'); + }); + } +}; diff --git a/database/migrations/2024_05_20_063638_create_group_comments_table.php b/database/migrations/2024_05_20_063638_create_group_comments_table.php new file mode 100644 index 000000000..ad49f58c8 --- /dev/null +++ b/database/migrations/2024_05_20_063638_create_group_comments_table.php @@ -0,0 +1,43 @@ +bigIncrements('id'); + $table->unsignedBigInteger('group_id')->index(); + $table->unsignedBigInteger('profile_id')->nullable(); + $table->unsignedBigInteger('status_id')->nullable()->index(); + $table->unsignedBigInteger('in_reply_to_id')->nullable()->index(); + $table->string('remote_url')->nullable()->unique()->index(); + $table->text('caption')->nullable(); + $table->boolean('is_nsfw')->default(false); + $table->string('visibility')->nullable(); + $table->unsignedInteger('likes_count')->default(0); + $table->unsignedInteger('replies_count')->default(0); + $table->text('cw_summary')->nullable(); + $table->json('media_ids')->nullable(); + $table->string('status')->nullable(); + $table->string('type')->default('text')->nullable(); + $table->boolean('local')->default(false); + $table->json('metadata')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('group_comments'); + } +}; diff --git a/database/migrations/2024_05_20_073054_create_group_likes_table.php b/database/migrations/2024_05_20_073054_create_group_likes_table.php new file mode 100644 index 000000000..162ef7458 --- /dev/null +++ b/database/migrations/2024_05_20_073054_create_group_likes_table.php @@ -0,0 +1,33 @@ +bigIncrements('id'); + $table->unsignedBigInteger('group_id'); + $table->unsignedBigInteger('profile_id')->index(); + $table->unsignedBigInteger('status_id')->nullable(); + $table->unsignedBigInteger('comment_id')->nullable(); + $table->boolean('local')->default(true); + $table->unique(['group_id', 'profile_id', 'status_id', 'comment_id'], 'group_likes_gpsc_unique'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('group_likes'); + } +}; diff --git a/database/migrations/2024_05_20_083159_create_group_media_table.php b/database/migrations/2024_05_20_083159_create_group_media_table.php new file mode 100644 index 000000000..732856097 --- /dev/null +++ b/database/migrations/2024_05_20_083159_create_group_media_table.php @@ -0,0 +1,50 @@ +bigIncrements('id'); + $table->unsignedBigInteger('group_id'); + $table->unsignedBigInteger('profile_id'); + $table->unsignedBigInteger('status_id')->nullable()->index(); + $table->string('media_path')->unique(); + $table->text('thumbnail_url')->nullable(); + $table->text('cdn_url')->nullable(); + $table->text('url')->nullable(); + $table->string('mime')->nullable(); + $table->unsignedInteger('size')->nullable(); + $table->text('cw_summary')->nullable(); + $table->string('license')->nullable(); + $table->string('blurhash')->nullable(); + $table->tinyInteger('order')->unsigned()->default(1); + $table->unsignedInteger('width')->nullable(); + $table->unsignedInteger('height')->nullable(); + $table->boolean('local_user')->default(true); + $table->boolean('is_cached')->default(false); + $table->boolean('is_comment')->default(false)->index(); + $table->json('metadata')->nullable(); + $table->string('version')->default(1); + $table->boolean('skip_optimize')->default(false); + $table->timestamp('processed_at')->nullable(); + $table->timestamp('thumbnail_generated')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('group_media'); + } +}; diff --git a/public/js/changelog.bundle.d5810c2672b6abc7.js b/public/js/changelog.bundle.72c44e46a0cc74f9.js similarity index 67% rename from public/js/changelog.bundle.d5810c2672b6abc7.js rename to public/js/changelog.bundle.72c44e46a0cc74f9.js index b0a083d76..143294298 100644 Binary files a/public/js/changelog.bundle.d5810c2672b6abc7.js and b/public/js/changelog.bundle.72c44e46a0cc74f9.js differ diff --git a/public/js/compose.chunk.28539d85e4c112c4.js b/public/js/compose.chunk.28539d85e4c112c4.js new file mode 100644 index 000000000..bd647b57f Binary files /dev/null and b/public/js/compose.chunk.28539d85e4c112c4.js differ diff --git a/public/js/compose.chunk.47ba00abaa827b26.js b/public/js/compose.chunk.47ba00abaa827b26.js deleted file mode 100644 index 0ef32d80d..000000000 Binary files a/public/js/compose.chunk.47ba00abaa827b26.js and /dev/null differ diff --git a/public/js/daci.chunk.e49239579f174211.js b/public/js/daci.chunk.8a381f0a227e6ed5.js similarity index 75% rename from public/js/daci.chunk.e49239579f174211.js rename to public/js/daci.chunk.8a381f0a227e6ed5.js index da02a4351..b85761a86 100644 Binary files a/public/js/daci.chunk.e49239579f174211.js and b/public/js/daci.chunk.8a381f0a227e6ed5.js differ diff --git a/public/js/discover.chunk.1404d3172761023b.js b/public/js/discover.chunk.fbb3e76aabb32be2.js similarity index 51% rename from public/js/discover.chunk.1404d3172761023b.js rename to public/js/discover.chunk.fbb3e76aabb32be2.js index 03313d4ee..b6ad4beed 100644 Binary files a/public/js/discover.chunk.1404d3172761023b.js and b/public/js/discover.chunk.fbb3e76aabb32be2.js differ diff --git a/public/js/discover~findfriends.chunk.d0e638a697f821b4.js b/public/js/discover~findfriends.chunk.37adc9959baa51ea.js similarity index 75% rename from public/js/discover~findfriends.chunk.d0e638a697f821b4.js rename to public/js/discover~findfriends.chunk.37adc9959baa51ea.js index f56741b41..819c38903 100644 Binary files a/public/js/discover~findfriends.chunk.d0e638a697f821b4.js and b/public/js/discover~findfriends.chunk.37adc9959baa51ea.js differ diff --git a/public/js/discover~hashtag.bundle.1b11b46e0b28aa3f.js b/public/js/discover~hashtag.bundle.909ff7b3dab67bde.js similarity index 53% rename from public/js/discover~hashtag.bundle.1b11b46e0b28aa3f.js rename to public/js/discover~hashtag.bundle.909ff7b3dab67bde.js index f0cca728f..f23188dd6 100644 Binary files a/public/js/discover~hashtag.bundle.1b11b46e0b28aa3f.js and b/public/js/discover~hashtag.bundle.909ff7b3dab67bde.js differ diff --git a/public/js/discover~memories.chunk.315bb6896f3afec2.js b/public/js/discover~memories.chunk.0c1a79e4c57c4ed8.js similarity index 76% rename from public/js/discover~memories.chunk.315bb6896f3afec2.js rename to public/js/discover~memories.chunk.0c1a79e4c57c4ed8.js index f12e1a7c3..f59dcff88 100644 Binary files a/public/js/discover~memories.chunk.315bb6896f3afec2.js and b/public/js/discover~memories.chunk.0c1a79e4c57c4ed8.js differ diff --git a/public/js/discover~myhashtags.chunk.25db2bcadb2836b5.js b/public/js/discover~myhashtags.chunk.4db4fe0961b3c34f.js similarity index 81% rename from public/js/discover~myhashtags.chunk.25db2bcadb2836b5.js rename to public/js/discover~myhashtags.chunk.4db4fe0961b3c34f.js index 3a9ee3b25..102f377f1 100644 Binary files a/public/js/discover~myhashtags.chunk.25db2bcadb2836b5.js and b/public/js/discover~myhashtags.chunk.4db4fe0961b3c34f.js differ diff --git a/public/js/discover~serverfeed.chunk.4eb5e50270522771.js b/public/js/discover~serverfeed.chunk.b6ace26463e28a19.js similarity index 75% rename from public/js/discover~serverfeed.chunk.4eb5e50270522771.js rename to public/js/discover~serverfeed.chunk.b6ace26463e28a19.js index b7e83e7b1..52cb430dc 100644 Binary files a/public/js/discover~serverfeed.chunk.4eb5e50270522771.js and b/public/js/discover~serverfeed.chunk.b6ace26463e28a19.js differ diff --git a/public/js/discover~settings.chunk.3f3acf6b2d7f41a2.js b/public/js/discover~settings.chunk.31f5865465e89899.js similarity index 76% rename from public/js/discover~settings.chunk.3f3acf6b2d7f41a2.js rename to public/js/discover~settings.chunk.31f5865465e89899.js index 43d732777..abb1a57e0 100644 Binary files a/public/js/discover~settings.chunk.3f3acf6b2d7f41a2.js and b/public/js/discover~settings.chunk.31f5865465e89899.js differ diff --git a/public/js/dms.chunk.a42edfd973f6c593.js b/public/js/dms.chunk.8e8464594fef81e5.js similarity index 65% rename from public/js/dms.chunk.a42edfd973f6c593.js rename to public/js/dms.chunk.8e8464594fef81e5.js index d3e03da34..e2886f06f 100644 Binary files a/public/js/dms.chunk.a42edfd973f6c593.js and b/public/js/dms.chunk.8e8464594fef81e5.js differ diff --git a/public/js/dms~message.chunk.6cd795c99fc1a568.js b/public/js/dms~message.chunk.c831de515447ca8a.js similarity index 70% rename from public/js/dms~message.chunk.6cd795c99fc1a568.js rename to public/js/dms~message.chunk.c831de515447ca8a.js index 5e5a4a356..beb2eb2e2 100644 Binary files a/public/js/dms~message.chunk.6cd795c99fc1a568.js and b/public/js/dms~message.chunk.c831de515447ca8a.js differ diff --git a/public/js/group-status.js b/public/js/group-status.js new file mode 100644 index 000000000..3f0e8cb2c Binary files /dev/null and b/public/js/group-status.js differ diff --git a/public/js/group-topic-feed.js b/public/js/group-topic-feed.js new file mode 100644 index 000000000..c4ed457fc Binary files /dev/null and b/public/js/group-topic-feed.js differ diff --git a/public/js/group.create.9836b689acf0fc1b.js b/public/js/group.create.9836b689acf0fc1b.js new file mode 100644 index 000000000..6ee5f1c16 Binary files /dev/null and b/public/js/group.create.9836b689acf0fc1b.js differ diff --git a/public/js/groups-page-about.150f2f899988e65c.js b/public/js/groups-page-about.150f2f899988e65c.js new file mode 100644 index 000000000..8941d3183 Binary files /dev/null and b/public/js/groups-page-about.150f2f899988e65c.js differ diff --git a/public/js/groups-page-media.a57186ce36fd8972.js b/public/js/groups-page-media.a57186ce36fd8972.js new file mode 100644 index 000000000..eab30646b Binary files /dev/null and b/public/js/groups-page-media.a57186ce36fd8972.js differ diff --git a/public/js/groups-page-members.20f9217256d06bf3.js b/public/js/groups-page-members.20f9217256d06bf3.js new file mode 100644 index 000000000..e6be9561b Binary files /dev/null and b/public/js/groups-page-members.20f9217256d06bf3.js differ diff --git a/public/js/groups-page-topics.c856bf15dc42b2fb.js b/public/js/groups-page-topics.c856bf15dc42b2fb.js new file mode 100644 index 000000000..fd2375947 Binary files /dev/null and b/public/js/groups-page-topics.c856bf15dc42b2fb.js differ diff --git a/public/js/groups-page.6c5fbadccf05f783.js b/public/js/groups-page.6c5fbadccf05f783.js new file mode 100644 index 000000000..678d0ad5c Binary files /dev/null and b/public/js/groups-page.6c5fbadccf05f783.js differ diff --git a/public/js/groups-post.c9083f5a20000208.js b/public/js/groups-post.c9083f5a20000208.js new file mode 100644 index 000000000..6db341dc2 Binary files /dev/null and b/public/js/groups-post.c9083f5a20000208.js differ diff --git a/public/js/groups-profile.9049d02d06606680.js b/public/js/groups-profile.9049d02d06606680.js new file mode 100644 index 000000000..d8e8563e7 Binary files /dev/null and b/public/js/groups-profile.9049d02d06606680.js differ diff --git a/public/js/groups.js b/public/js/groups.js new file mode 100644 index 000000000..c2a9a04b9 Binary files /dev/null and b/public/js/groups.js differ diff --git a/public/js/home.chunk.8bbc3c5c38dde66d.js b/public/js/home.chunk.db29292541125db4.js similarity index 76% rename from public/js/home.chunk.8bbc3c5c38dde66d.js rename to public/js/home.chunk.db29292541125db4.js index 7aa8ec580..226236da5 100644 Binary files a/public/js/home.chunk.8bbc3c5c38dde66d.js and b/public/js/home.chunk.db29292541125db4.js differ diff --git a/public/js/home.chunk.8bbc3c5c38dde66d.js.LICENSE.txt b/public/js/home.chunk.db29292541125db4.js.LICENSE.txt similarity index 100% rename from public/js/home.chunk.8bbc3c5c38dde66d.js.LICENSE.txt rename to public/js/home.chunk.db29292541125db4.js.LICENSE.txt diff --git a/public/js/i18n.bundle.28bba3e12cdadf51.js b/public/js/i18n.bundle.11814756f6bbd153.js similarity index 61% rename from public/js/i18n.bundle.28bba3e12cdadf51.js rename to public/js/i18n.bundle.11814756f6bbd153.js index f3028771e..db7ce30e9 100644 Binary files a/public/js/i18n.bundle.28bba3e12cdadf51.js and b/public/js/i18n.bundle.11814756f6bbd153.js differ diff --git a/public/js/landing.js b/public/js/landing.js index 9c93b6e26..55cb59cc8 100644 Binary files a/public/js/landing.js and b/public/js/landing.js differ diff --git a/public/js/manifest.js b/public/js/manifest.js index ae4d1ff8f..2cdec0468 100644 Binary files a/public/js/manifest.js and b/public/js/manifest.js differ diff --git a/public/js/notifications.chunk.1086603ea08d1017.js b/public/js/notifications.chunk.19a0d29f7823c313.js similarity index 59% rename from public/js/notifications.chunk.1086603ea08d1017.js rename to public/js/notifications.chunk.19a0d29f7823c313.js index b5d4bfd12..1ed7ac814 100644 Binary files a/public/js/notifications.chunk.1086603ea08d1017.js and b/public/js/notifications.chunk.19a0d29f7823c313.js differ diff --git a/public/js/post.chunk.803d8c9f68415936.js b/public/js/post.chunk.857e52af9dd166ea.js similarity index 78% rename from public/js/post.chunk.803d8c9f68415936.js rename to public/js/post.chunk.857e52af9dd166ea.js index d024217ac..7d5b651f8 100644 Binary files a/public/js/post.chunk.803d8c9f68415936.js and b/public/js/post.chunk.857e52af9dd166ea.js differ diff --git a/public/js/post.chunk.803d8c9f68415936.js.LICENSE.txt b/public/js/post.chunk.857e52af9dd166ea.js.LICENSE.txt similarity index 100% rename from public/js/post.chunk.803d8c9f68415936.js.LICENSE.txt rename to public/js/post.chunk.857e52af9dd166ea.js.LICENSE.txt diff --git a/public/js/profile.chunk.33a4b9cb10dbbb6c.js b/public/js/profile.chunk.f18d6551c434b139.js similarity index 95% rename from public/js/profile.chunk.33a4b9cb10dbbb6c.js rename to public/js/profile.chunk.f18d6551c434b139.js index 2869c5cbf..6f37de655 100644 Binary files a/public/js/profile.chunk.33a4b9cb10dbbb6c.js and b/public/js/profile.chunk.f18d6551c434b139.js differ diff --git a/public/js/profile.js b/public/js/profile.js index 9f8a3081a..55d85fc51 100644 Binary files a/public/js/profile.js and b/public/js/profile.js differ diff --git a/public/js/search.js b/public/js/search.js index b61629ca2..5e33f2250 100644 Binary files a/public/js/search.js and b/public/js/search.js differ diff --git a/public/js/spa.js b/public/js/spa.js index 74e6e094f..6cd9c7a09 100644 Binary files a/public/js/spa.js and b/public/js/spa.js differ diff --git a/public/js/status.js b/public/js/status.js index fd443ef45..6650c4fb5 100644 Binary files a/public/js/status.js and b/public/js/status.js differ diff --git a/public/js/stories.js b/public/js/stories.js index da19538eb..977a3ab3c 100644 Binary files a/public/js/stories.js and b/public/js/stories.js differ diff --git a/public/js/story-compose.js b/public/js/story-compose.js index 9cdd4e713..2a0d18cb7 100644 Binary files a/public/js/story-compose.js and b/public/js/story-compose.js differ diff --git a/public/js/timeline.js b/public/js/timeline.js index 327acaee6..8a4417069 100644 Binary files a/public/js/timeline.js and b/public/js/timeline.js differ diff --git a/public/js/vendor.js b/public/js/vendor.js index c1ddf78b4..529b96796 100644 Binary files a/public/js/vendor.js and b/public/js/vendor.js differ diff --git a/public/mix-manifest.json b/public/mix-manifest.json index a5fa1da5f..a1e079997 100644 Binary files a/public/mix-manifest.json and b/public/mix-manifest.json differ diff --git a/resources/assets/components/GroupCreate.vue b/resources/assets/components/GroupCreate.vue new file mode 100644 index 000000000..26c48948b --- /dev/null +++ b/resources/assets/components/GroupCreate.vue @@ -0,0 +1,51 @@ + + + + + diff --git a/resources/assets/components/GroupDiscover.vue b/resources/assets/components/GroupDiscover.vue new file mode 100644 index 000000000..bfdc537d3 --- /dev/null +++ b/resources/assets/components/GroupDiscover.vue @@ -0,0 +1,83 @@ + + + + + diff --git a/resources/assets/components/GroupFeed.vue b/resources/assets/components/GroupFeed.vue new file mode 100644 index 000000000..8d752f271 --- /dev/null +++ b/resources/assets/components/GroupFeed.vue @@ -0,0 +1,315 @@ + + + + + diff --git a/resources/assets/components/GroupJoins.vue b/resources/assets/components/GroupJoins.vue new file mode 100644 index 000000000..81295f56d --- /dev/null +++ b/resources/assets/components/GroupJoins.vue @@ -0,0 +1,79 @@ + + + + + diff --git a/resources/assets/components/GroupNotifications.vue b/resources/assets/components/GroupNotifications.vue new file mode 100644 index 000000000..69b33f355 --- /dev/null +++ b/resources/assets/components/GroupNotifications.vue @@ -0,0 +1,57 @@ + + + + + diff --git a/resources/assets/components/GroupPage.vue b/resources/assets/components/GroupPage.vue new file mode 100644 index 000000000..ea9edfdc8 --- /dev/null +++ b/resources/assets/components/GroupPage.vue @@ -0,0 +1,1190 @@ + + + + + diff --git a/resources/assets/components/GroupPost.vue b/resources/assets/components/GroupPost.vue new file mode 100644 index 000000000..8eb5dc9d4 --- /dev/null +++ b/resources/assets/components/GroupPost.vue @@ -0,0 +1,33 @@ + + + diff --git a/resources/assets/components/GroupProfile.vue b/resources/assets/components/GroupProfile.vue new file mode 100644 index 000000000..8affdee26 --- /dev/null +++ b/resources/assets/components/GroupProfile.vue @@ -0,0 +1,443 @@ + + + + + diff --git a/resources/assets/components/GroupSearch.vue b/resources/assets/components/GroupSearch.vue new file mode 100644 index 000000000..e23a75112 --- /dev/null +++ b/resources/assets/components/GroupSearch.vue @@ -0,0 +1,91 @@ + + + + + diff --git a/resources/assets/components/Groups.vue b/resources/assets/components/Groups.vue new file mode 100644 index 000000000..af0dc2e61 --- /dev/null +++ b/resources/assets/components/Groups.vue @@ -0,0 +1,80 @@ + + + + + diff --git a/resources/assets/components/groups/CreateGroup.vue b/resources/assets/components/groups/CreateGroup.vue new file mode 100644 index 000000000..7459275f4 --- /dev/null +++ b/resources/assets/components/groups/CreateGroup.vue @@ -0,0 +1,359 @@ + + + + + diff --git a/resources/assets/components/groups/GroupFeed.vue b/resources/assets/components/groups/GroupFeed.vue new file mode 100644 index 000000000..9a357d4ee --- /dev/null +++ b/resources/assets/components/groups/GroupFeed.vue @@ -0,0 +1,989 @@ + + + + + diff --git a/resources/assets/components/groups/GroupInvite.vue b/resources/assets/components/groups/GroupInvite.vue new file mode 100644 index 000000000..ec11185a5 --- /dev/null +++ b/resources/assets/components/groups/GroupInvite.vue @@ -0,0 +1,217 @@ + + + + + diff --git a/resources/assets/components/groups/GroupProfile.vue b/resources/assets/components/groups/GroupProfile.vue new file mode 100644 index 000000000..67077b84e --- /dev/null +++ b/resources/assets/components/groups/GroupProfile.vue @@ -0,0 +1,379 @@ + + + + + diff --git a/resources/assets/components/groups/GroupSettings.vue b/resources/assets/components/groups/GroupSettings.vue new file mode 100644 index 000000000..099d598f2 --- /dev/null +++ b/resources/assets/components/groups/GroupSettings.vue @@ -0,0 +1,1079 @@ + + + + + diff --git a/resources/assets/components/groups/GroupTopicFeed.vue b/resources/assets/components/groups/GroupTopicFeed.vue new file mode 100644 index 000000000..ee7f57433 --- /dev/null +++ b/resources/assets/components/groups/GroupTopicFeed.vue @@ -0,0 +1,170 @@ + + + diff --git a/resources/assets/components/groups/GroupsHome.vue b/resources/assets/components/groups/GroupsHome.vue new file mode 100644 index 000000000..3a3d6dde8 --- /dev/null +++ b/resources/assets/components/groups/GroupsHome.vue @@ -0,0 +1,473 @@ + + + + + diff --git a/resources/assets/components/groups/Page/GroupAbout.vue b/resources/assets/components/groups/Page/GroupAbout.vue new file mode 100644 index 000000000..8285a3db2 --- /dev/null +++ b/resources/assets/components/groups/Page/GroupAbout.vue @@ -0,0 +1,168 @@ + + + + + diff --git a/resources/assets/components/groups/Page/GroupMedia.vue b/resources/assets/components/groups/Page/GroupMedia.vue new file mode 100644 index 000000000..b2d098ac8 --- /dev/null +++ b/resources/assets/components/groups/Page/GroupMedia.vue @@ -0,0 +1,168 @@ + + + + + diff --git a/resources/assets/components/groups/Page/GroupMembers.vue b/resources/assets/components/groups/Page/GroupMembers.vue new file mode 100644 index 000000000..5b866fc17 --- /dev/null +++ b/resources/assets/components/groups/Page/GroupMembers.vue @@ -0,0 +1,168 @@ + + + + + diff --git a/resources/assets/components/groups/Page/GroupTopics.vue b/resources/assets/components/groups/Page/GroupTopics.vue new file mode 100644 index 000000000..60f0fa496 --- /dev/null +++ b/resources/assets/components/groups/Page/GroupTopics.vue @@ -0,0 +1,168 @@ + + + + + diff --git a/resources/assets/components/groups/partials/CommentDrawer.vue b/resources/assets/components/groups/partials/CommentDrawer.vue new file mode 100644 index 000000000..cd6631df3 --- /dev/null +++ b/resources/assets/components/groups/partials/CommentDrawer.vue @@ -0,0 +1,841 @@ + + + + + diff --git a/resources/assets/components/groups/partials/CommentPost.vue b/resources/assets/components/groups/partials/CommentPost.vue new file mode 100644 index 000000000..4b448f913 --- /dev/null +++ b/resources/assets/components/groups/partials/CommentPost.vue @@ -0,0 +1,405 @@ + + + + + diff --git a/resources/assets/components/groups/partials/ContextMenu.vue b/resources/assets/components/groups/partials/ContextMenu.vue new file mode 100644 index 000000000..52fad0e74 --- /dev/null +++ b/resources/assets/components/groups/partials/ContextMenu.vue @@ -0,0 +1,692 @@ + + + diff --git a/resources/assets/components/groups/partials/CreateForm/CheckboxInput.vue b/resources/assets/components/groups/partials/CreateForm/CheckboxInput.vue new file mode 100644 index 000000000..03fa8727a --- /dev/null +++ b/resources/assets/components/groups/partials/CreateForm/CheckboxInput.vue @@ -0,0 +1,59 @@ + + + diff --git a/resources/assets/components/groups/partials/CreateForm/SelectInput.vue b/resources/assets/components/groups/partials/CreateForm/SelectInput.vue new file mode 100644 index 000000000..304ce0c7d --- /dev/null +++ b/resources/assets/components/groups/partials/CreateForm/SelectInput.vue @@ -0,0 +1,70 @@ + + + diff --git a/resources/assets/components/groups/partials/CreateForm/TextAreaInput.vue b/resources/assets/components/groups/partials/CreateForm/TextAreaInput.vue new file mode 100644 index 000000000..e8977db3f --- /dev/null +++ b/resources/assets/components/groups/partials/CreateForm/TextAreaInput.vue @@ -0,0 +1,86 @@ + + + + + diff --git a/resources/assets/components/groups/partials/GroupEvents.vue b/resources/assets/components/groups/partials/GroupEvents.vue new file mode 100644 index 000000000..e69de29bb diff --git a/resources/assets/components/groups/partials/GroupInfoCard.vue b/resources/assets/components/groups/partials/GroupInfoCard.vue new file mode 100644 index 000000000..455954b8f --- /dev/null +++ b/resources/assets/components/groups/partials/GroupInfoCard.vue @@ -0,0 +1,135 @@ + + + + + diff --git a/resources/assets/components/groups/partials/GroupInsights.vue b/resources/assets/components/groups/partials/GroupInsights.vue new file mode 100644 index 000000000..9909508cb --- /dev/null +++ b/resources/assets/components/groups/partials/GroupInsights.vue @@ -0,0 +1,60 @@ + + + + + diff --git a/resources/assets/components/groups/partials/GroupInviteModal.vue b/resources/assets/components/groups/partials/GroupInviteModal.vue new file mode 100644 index 000000000..75e5f9f68 --- /dev/null +++ b/resources/assets/components/groups/partials/GroupInviteModal.vue @@ -0,0 +1,190 @@ + + + + + diff --git a/resources/assets/components/groups/partials/GroupListCard.vue b/resources/assets/components/groups/partials/GroupListCard.vue new file mode 100644 index 000000000..64300160e --- /dev/null +++ b/resources/assets/components/groups/partials/GroupListCard.vue @@ -0,0 +1,156 @@ + + + + + diff --git a/resources/assets/components/groups/partials/GroupMedia.vue b/resources/assets/components/groups/partials/GroupMedia.vue new file mode 100644 index 000000000..65a96001d --- /dev/null +++ b/resources/assets/components/groups/partials/GroupMedia.vue @@ -0,0 +1,262 @@ + + + + + diff --git a/resources/assets/components/groups/partials/GroupMembers.vue b/resources/assets/components/groups/partials/GroupMembers.vue new file mode 100644 index 000000000..3913aa93d --- /dev/null +++ b/resources/assets/components/groups/partials/GroupMembers.vue @@ -0,0 +1,684 @@ + + + + + diff --git a/resources/assets/components/groups/partials/GroupModeration.vue b/resources/assets/components/groups/partials/GroupModeration.vue new file mode 100644 index 000000000..54d114391 --- /dev/null +++ b/resources/assets/components/groups/partials/GroupModeration.vue @@ -0,0 +1,231 @@ + + + + + diff --git a/resources/assets/components/groups/partials/GroupPolls.vue b/resources/assets/components/groups/partials/GroupPolls.vue new file mode 100644 index 000000000..e69de29bb diff --git a/resources/assets/components/groups/partials/GroupPostModal.vue b/resources/assets/components/groups/partials/GroupPostModal.vue new file mode 100644 index 000000000..094d98a26 --- /dev/null +++ b/resources/assets/components/groups/partials/GroupPostModal.vue @@ -0,0 +1,152 @@ + + + + + diff --git a/resources/assets/components/groups/partials/GroupSearchModal.vue b/resources/assets/components/groups/partials/GroupSearchModal.vue new file mode 100644 index 000000000..8cc70039d --- /dev/null +++ b/resources/assets/components/groups/partials/GroupSearchModal.vue @@ -0,0 +1,199 @@ + + + + + diff --git a/resources/assets/components/groups/partials/GroupStatus.vue b/resources/assets/components/groups/partials/GroupStatus.vue new file mode 100644 index 000000000..439fd03b4 --- /dev/null +++ b/resources/assets/components/groups/partials/GroupStatus.vue @@ -0,0 +1,873 @@ + + + + + diff --git a/resources/assets/components/groups/partials/GroupTopics.vue b/resources/assets/components/groups/partials/GroupTopics.vue new file mode 100644 index 000000000..ed4885b1e --- /dev/null +++ b/resources/assets/components/groups/partials/GroupTopics.vue @@ -0,0 +1,73 @@ + + + + + diff --git a/resources/assets/components/groups/partials/LeaveGroup.vue b/resources/assets/components/groups/partials/LeaveGroup.vue new file mode 100644 index 000000000..417c29347 --- /dev/null +++ b/resources/assets/components/groups/partials/LeaveGroup.vue @@ -0,0 +1,9 @@ + + + diff --git a/resources/assets/components/groups/partials/MemberLimitInteractionsModal.vue b/resources/assets/components/groups/partials/MemberLimitInteractionsModal.vue new file mode 100644 index 000000000..143b47575 --- /dev/null +++ b/resources/assets/components/groups/partials/MemberLimitInteractionsModal.vue @@ -0,0 +1,172 @@ + + + diff --git a/resources/assets/components/groups/partials/Membership/MemberOnlyWarning.vue b/resources/assets/components/groups/partials/Membership/MemberOnlyWarning.vue new file mode 100644 index 000000000..cea224a5f --- /dev/null +++ b/resources/assets/components/groups/partials/Membership/MemberOnlyWarning.vue @@ -0,0 +1,38 @@ + + + diff --git a/resources/assets/components/groups/partials/Page/GroupBanner.vue b/resources/assets/components/groups/partials/Page/GroupBanner.vue new file mode 100644 index 000000000..8038cdce5 --- /dev/null +++ b/resources/assets/components/groups/partials/Page/GroupBanner.vue @@ -0,0 +1,44 @@ + + + + + diff --git a/resources/assets/components/groups/partials/Page/GroupHeaderDetails.vue b/resources/assets/components/groups/partials/Page/GroupHeaderDetails.vue new file mode 100644 index 000000000..baeb2dfd5 --- /dev/null +++ b/resources/assets/components/groups/partials/Page/GroupHeaderDetails.vue @@ -0,0 +1,199 @@ + + + + + diff --git a/resources/assets/components/groups/partials/Page/GroupNavTabs.vue b/resources/assets/components/groups/partials/Page/GroupNavTabs.vue new file mode 100644 index 000000000..c0a8827ea --- /dev/null +++ b/resources/assets/components/groups/partials/Page/GroupNavTabs.vue @@ -0,0 +1,167 @@ + + + + diff --git a/resources/assets/components/groups/partials/ReadMore.vue b/resources/assets/components/groups/partials/ReadMore.vue new file mode 100644 index 000000000..9dabf199d --- /dev/null +++ b/resources/assets/components/groups/partials/ReadMore.vue @@ -0,0 +1,51 @@ + + + diff --git a/resources/assets/components/groups/partials/SelfDiscover.vue b/resources/assets/components/groups/partials/SelfDiscover.vue new file mode 100644 index 000000000..2fb15a39f --- /dev/null +++ b/resources/assets/components/groups/partials/SelfDiscover.vue @@ -0,0 +1,465 @@ + + + + + diff --git a/resources/assets/components/groups/partials/SelfFeed.vue b/resources/assets/components/groups/partials/SelfFeed.vue new file mode 100644 index 000000000..6663b4dfa --- /dev/null +++ b/resources/assets/components/groups/partials/SelfFeed.vue @@ -0,0 +1,146 @@ + + + diff --git a/resources/assets/components/groups/partials/SelfGroups.vue b/resources/assets/components/groups/partials/SelfGroups.vue new file mode 100644 index 000000000..411ec67e4 --- /dev/null +++ b/resources/assets/components/groups/partials/SelfGroups.vue @@ -0,0 +1,171 @@ + + + + + diff --git a/resources/assets/components/groups/partials/SelfInvitations.vue b/resources/assets/components/groups/partials/SelfInvitations.vue new file mode 100644 index 000000000..f9d4f1e64 --- /dev/null +++ b/resources/assets/components/groups/partials/SelfInvitations.vue @@ -0,0 +1,41 @@ + + + diff --git a/resources/assets/components/groups/partials/SelfNotifications.vue b/resources/assets/components/groups/partials/SelfNotifications.vue new file mode 100644 index 000000000..f591cfbd7 --- /dev/null +++ b/resources/assets/components/groups/partials/SelfNotifications.vue @@ -0,0 +1,309 @@ + + + + + diff --git a/resources/assets/components/groups/partials/SelfRemoteSearch.vue b/resources/assets/components/groups/partials/SelfRemoteSearch.vue new file mode 100644 index 000000000..9c3443960 --- /dev/null +++ b/resources/assets/components/groups/partials/SelfRemoteSearch.vue @@ -0,0 +1,47 @@ + + + diff --git a/resources/assets/components/groups/partials/ShareMenu.vue b/resources/assets/components/groups/partials/ShareMenu.vue new file mode 100644 index 000000000..3f4141486 --- /dev/null +++ b/resources/assets/components/groups/partials/ShareMenu.vue @@ -0,0 +1,11 @@ + + + diff --git a/resources/assets/components/groups/partials/Status/GroupHeader.vue b/resources/assets/components/groups/partials/Status/GroupHeader.vue new file mode 100644 index 000000000..1a782302a --- /dev/null +++ b/resources/assets/components/groups/partials/Status/GroupHeader.vue @@ -0,0 +1,304 @@ + + + + + diff --git a/resources/assets/components/groups/partials/Status/ParentUnavailable.vue b/resources/assets/components/groups/partials/Status/ParentUnavailable.vue new file mode 100644 index 000000000..edb0f5062 --- /dev/null +++ b/resources/assets/components/groups/partials/Status/ParentUnavailable.vue @@ -0,0 +1,58 @@ + + + diff --git a/resources/assets/components/groups/sections/Loader.vue b/resources/assets/components/groups/sections/Loader.vue new file mode 100644 index 000000000..e0dc053d0 --- /dev/null +++ b/resources/assets/components/groups/sections/Loader.vue @@ -0,0 +1,23 @@ + + + diff --git a/resources/assets/components/groups/sections/Sidebar.vue b/resources/assets/components/groups/sections/Sidebar.vue new file mode 100644 index 000000000..7b6326b52 --- /dev/null +++ b/resources/assets/components/groups/sections/Sidebar.vue @@ -0,0 +1,307 @@ + + + + + diff --git a/resources/assets/components/partials/sidebar.vue b/resources/assets/components/partials/sidebar.vue index b7b2a061b..7c2891092 100644 --- a/resources/assets/components/partials/sidebar.vue +++ b/resources/assets/components/partials/sidebar.vue @@ -132,12 +132,12 @@ - +