Daniel Supernault 1 anno fa
parent
commit
3811a1cd65
65 ha cambiato i file con 15176 aggiunte e 0 eliminazioni
  1. 51 0
      resources/assets/components/GroupCreate.vue
  2. 83 0
      resources/assets/components/GroupDiscover.vue
  3. 315 0
      resources/assets/components/GroupFeed.vue
  4. 79 0
      resources/assets/components/GroupJoins.vue
  5. 57 0
      resources/assets/components/GroupNotifications.vue
  6. 1190 0
      resources/assets/components/GroupPage.vue
  7. 33 0
      resources/assets/components/GroupPost.vue
  8. 443 0
      resources/assets/components/GroupProfile.vue
  9. 80 0
      resources/assets/components/Groups.vue
  10. 359 0
      resources/assets/components/groups/CreateGroup.vue
  11. 989 0
      resources/assets/components/groups/GroupFeed.vue
  12. 217 0
      resources/assets/components/groups/GroupInvite.vue
  13. 379 0
      resources/assets/components/groups/GroupProfile.vue
  14. 1079 0
      resources/assets/components/groups/GroupSettings.vue
  15. 170 0
      resources/assets/components/groups/GroupTopicFeed.vue
  16. 473 0
      resources/assets/components/groups/GroupsHome.vue
  17. 168 0
      resources/assets/components/groups/Page/GroupAbout.vue
  18. 168 0
      resources/assets/components/groups/Page/GroupMedia.vue
  19. 168 0
      resources/assets/components/groups/Page/GroupMembers.vue
  20. 168 0
      resources/assets/components/groups/Page/GroupTopics.vue
  21. 841 0
      resources/assets/components/groups/partials/CommentDrawer.vue
  22. 405 0
      resources/assets/components/groups/partials/CommentPost.vue
  23. 692 0
      resources/assets/components/groups/partials/ContextMenu.vue
  24. 59 0
      resources/assets/components/groups/partials/CreateForm/CheckboxInput.vue
  25. 70 0
      resources/assets/components/groups/partials/CreateForm/SelectInput.vue
  26. 86 0
      resources/assets/components/groups/partials/CreateForm/TextAreaInput.vue
  27. 78 0
      resources/assets/components/groups/partials/CreateForm/TextInput.vue
  28. 134 0
      resources/assets/components/groups/partials/GroupAbout.vue
  29. 174 0
      resources/assets/components/groups/partials/GroupCard.vue
  30. 345 0
      resources/assets/components/groups/partials/GroupCompose.vue
  31. 0 0
      resources/assets/components/groups/partials/GroupEvents.vue
  32. 135 0
      resources/assets/components/groups/partials/GroupInfoCard.vue
  33. 60 0
      resources/assets/components/groups/partials/GroupInsights.vue
  34. 190 0
      resources/assets/components/groups/partials/GroupInviteModal.vue
  35. 156 0
      resources/assets/components/groups/partials/GroupListCard.vue
  36. 262 0
      resources/assets/components/groups/partials/GroupMedia.vue
  37. 684 0
      resources/assets/components/groups/partials/GroupMembers.vue
  38. 231 0
      resources/assets/components/groups/partials/GroupModeration.vue
  39. 0 0
      resources/assets/components/groups/partials/GroupPolls.vue
  40. 152 0
      resources/assets/components/groups/partials/GroupPostModal.vue
  41. 199 0
      resources/assets/components/groups/partials/GroupSearchModal.vue
  42. 870 0
      resources/assets/components/groups/partials/GroupStatus.vue
  43. 73 0
      resources/assets/components/groups/partials/GroupTopics.vue
  44. 9 0
      resources/assets/components/groups/partials/LeaveGroup.vue
  45. 172 0
      resources/assets/components/groups/partials/MemberLimitInteractionsModal.vue
  46. 38 0
      resources/assets/components/groups/partials/Membership/MemberOnlyWarning.vue
  47. 44 0
      resources/assets/components/groups/partials/Page/GroupBanner.vue
  48. 199 0
      resources/assets/components/groups/partials/Page/GroupHeaderDetails.vue
  49. 167 0
      resources/assets/components/groups/partials/Page/GroupNavTabs.vue
  50. 51 0
      resources/assets/components/groups/partials/ReadMore.vue
  51. 465 0
      resources/assets/components/groups/partials/SelfDiscover.vue
  52. 146 0
      resources/assets/components/groups/partials/SelfFeed.vue
  53. 171 0
      resources/assets/components/groups/partials/SelfGroups.vue
  54. 41 0
      resources/assets/components/groups/partials/SelfInvitations.vue
  55. 309 0
      resources/assets/components/groups/partials/SelfNotifications.vue
  56. 47 0
      resources/assets/components/groups/partials/SelfRemoteSearch.vue
  57. 11 0
      resources/assets/components/groups/partials/ShareMenu.vue
  58. 304 0
      resources/assets/components/groups/partials/Status/GroupHeader.vue
  59. 58 0
      resources/assets/components/groups/partials/Status/ParentUnavailable.vue
  60. 23 0
      resources/assets/components/groups/sections/Loader.vue
  61. 316 0
      resources/assets/components/groups/sections/Sidebar.vue
  62. 4 0
      resources/assets/js/group-status.js
  63. 4 0
      resources/assets/js/group-topic-feed.js
  64. 29 0
      resources/assets/js/groups.js
  65. 3 0
      webpack.mix.js

+ 51 - 0
resources/assets/components/GroupCreate.vue

@@ -0,0 +1,51 @@
+<template>
+    <div class="group-notifications-component">
+        <div class="row border-bottom m-0 p-0">
+            <sidebar />
+
+            <create-group />
+        </div>
+    </div>
+</template>
+
+<script type="text/javascript">
+    import SidebarComponent from '@/groups/sections/Sidebar.vue';
+    import LoaderComponent from '@/groups/sections/Loader.vue';
+    import CreateGroup from '@/groups/CreateGroup.vue';
+
+    export default {
+        components: {
+            "sidebar": SidebarComponent,
+            "loader": LoaderComponent,
+            "create-group": CreateGroup
+        },
+
+        data() {
+            return {
+                loaded: false,
+                loadTimeout: undefined,
+            }
+        },
+
+        created() {
+            this.loadTimeout = setTimeout(() => {
+                this.loaded = true;
+            }, 1000);
+        },
+
+        beforeUnmount() {
+            clearTimeout(this.loadTimeout);
+        }
+    }
+</script>
+
+<style lang="scss" scoped>
+    .group-notifications-component {
+        font-family: var(--font-family-sans-serif);
+
+        .jumbotron {
+            background-color: #fff;
+            border-radius: 0px;
+        }
+    }
+</style>

+ 83 - 0
resources/assets/components/GroupDiscover.vue

@@ -0,0 +1,83 @@
+<template>
+    <div class="group-discover-component">
+        <div class="row border-bottom m-0 p-0">
+            <sidebar />
+
+            <div class="col-12 col-md-9 px-md-0">
+                <loader v-if="!loaded" :loaded="loaded" />
+
+                <template v-else>
+                    <div class="container-fluid">
+                        <div class="py-5">
+                            <h1>Discover</h1>
+                        </div>
+
+                        <div class="popular row">
+                            <group-card
+                                v-for="(popular, idx) in popularGroups"
+                                :key="idx"
+                                :group="popular"
+                            />
+                        </div>
+                    </div>
+                </template>
+            </div>
+        </div>
+    </div>
+</template>
+
+<script type="text/javascript">
+    import SidebarComponent from '@/groups/sections/Sidebar.vue';
+    import LoaderComponent from '@/groups/sections/Loader.vue';
+    import GroupCard from '@/groups/partials/GroupCard.vue';
+
+    export default {
+        components: {
+            "sidebar": SidebarComponent,
+            "loader": LoaderComponent,
+            "group-card": GroupCard
+        },
+
+        data() {
+            return {
+                loaded: false,
+                loadTimeout: undefined,
+                popularGroups: [],
+                newGroups: [],
+            }
+        },
+
+        methods: {
+            fetchPopular() {
+                axios.get('/api/v0/groups/discover/popular')
+                .then(res => this.popularGroups = res.data)
+                .finally(() => this.fetchNewGroups())
+            },
+
+            fetchNewGroups() {
+                axios.get('/api/v0/groups/discover/new')
+                .then(res => this.newGroups = res.data)
+                .finally(() => this.loaded = true)
+            },
+        },
+
+        created() {
+            this.fetchPopular()
+        },
+
+        beforeUnmount() {
+            clearTimeout(this.loadTimeout);
+        }
+    }
+</script>
+
+<style lang="scss" scoped>
+    .group-discover-component {
+        font-family: var(--font-family-sans-serif);
+
+        .jumbotron {
+            background-color: #fff;
+            border-radius: 0px;
+        }
+    }
+</style>

+ 315 - 0
resources/assets/components/GroupFeed.vue

@@ -0,0 +1,315 @@
+<template>
+    <div class="groups-home-component w-100 h-100">
+        <div v-if="initialLoad" class="row border-bottom m-0 p-0">
+            <sidebar />
+
+                    <self-feed :profile="profile" v-on:switchtab="switchTab" />
+                    <!-- <self-discover v-if="tab == 'discover'" :profile="profile" />
+                    <self-notifications v-if="tab == 'notifications'" :profile="profile" />
+                    <self-invitations v-if="tab == 'invitations'" :profile="profile" />
+                    <self-remote-search v-if="tab == 'remotesearch'" :profile="profile" />
+                    <self-groups v-if="tab == 'mygroups'" :profile="profile" />
+                    <create-group v-if="tab == 'creategroup'" :profile="profile" />
+                    <div v-if="tab == 'gsearch'">
+                        <div class="col-12 px-5">
+                            <div class="my-4">
+                                <p class="h1 font-weight-bold mb-1">Group Search</p>
+                                <p class="lead text-muted mb-0">Search and explore groups.</p>
+                            </div>
+                            <div class="media align-items-center text-lighter">
+                                <i class="far fa-chevron-left fa-lg mr-3"></i>
+                                <div class="media-body">
+                                    <p class="lead mb-0">Use the search bar on the side menu</p>
+                                </div>
+                            </div>
+                        </div>
+                    </div> -->
+
+        </div>
+        <div v-else class="row justify-content-center mt-5">
+            <b-spinner />
+        </div>
+    </div>
+</template>
+
+<script type="text/javascript">
+    import GroupStatus from '@/groups/partials/GroupStatus.vue';
+    import SelfFeed from '@/groups/partials/SelfFeed.vue';
+    import SelfDiscover from '@/groups/partials/SelfDiscover.vue';
+    import SelfGroups from '@/groups/partials/SelfGroups.vue';
+    import SelfNotifications from '@/groups/partials/SelfNotifications.vue';
+    import SelfInvitations from '@/groups/partials/SelfInvitations.vue';
+    import SelfRemoteSearch from '@/groups/partials/SelfRemoteSearch.vue';
+    import CreateGroup from '@/groups/CreateGroup.vue';
+    import SidebarComponent from '@/groups/sections/Sidebar.vue';
+    import Autocomplete from '@trevoreyre/autocomplete-vue'
+    import '@trevoreyre/autocomplete-vue/dist/style.css'
+
+    export default {
+        data() {
+            return {
+                initialLoad: false,
+                config: {},
+                groups: [],
+                profile: {},
+                tab: null,
+                searchQuery: undefined,
+            };
+        },
+
+        components: {
+            'autocomplete-input': Autocomplete,
+            'group-status': GroupStatus,
+            'self-discover': SelfDiscover,
+            'self-groups': SelfGroups,
+            'self-feed': SelfFeed,
+            'self-notifications': SelfNotifications,
+            'self-invitations': SelfInvitations,
+            'self-remote-search': SelfRemoteSearch,
+            "create-group": CreateGroup,
+            "sidebar": SidebarComponent
+        },
+
+        mounted() {
+            this.fetchConfig();
+        },
+
+        methods: {
+            init() {
+                document.querySelectorAll("footer").forEach(e => e.parentNode.removeChild(e));
+                document.querySelectorAll(".mobile-footer-spacer").forEach(e => e.parentNode.removeChild(e));
+                document.querySelectorAll(".mobile-footer").forEach(e => e.parentNode.removeChild(e));
+                // let u = new URLSearchParams(window.location.search);
+                // if(u.has('ct')) {
+                //     if(['mygroups', 'notifications', 'discover', 'remotesearch', 'creategroup', 'gsearch'].includes(u.get('ct'))) {
+                //         if(u.get('ct') == 'creategroup' && this.config.limits.user.create.new == false) {
+                //             this.tab = 'feed';
+                //             history.pushState(null, null, '/groups/feed');
+                //         } else {
+                //             this.tab = u.get('ct');
+                //         }
+                //     } else {
+                //         this.tab = 'feed';
+                //         history.pushState(null, null, '/groups/feed');
+                //     }
+                // } else {
+                //     this.tab = 'feed';
+                // }
+                this.initialLoad = true;
+            },
+
+            fetchConfig() {
+                axios.get('/api/v0/groups/config')
+                .then(res => {
+                    this.config = res.data;
+                    this.fetchProfile();
+                });
+            },
+
+            fetchProfile() {
+                axios.get('/api/pixelfed/v1/accounts/verify_credentials')
+                .then(res => {
+                    this.profile = res.data;
+                    this.init();
+                    window._sharedData.curUser = res.data;
+                    window.App.util.navatar();
+                })
+            },
+
+            fetchSelfGroups() {
+                axios.get('/api/v0/groups/self/list')
+                .then(res => {
+                    this.groups = res.data;
+                });
+            },
+
+            switchTab(tab) {
+                event.currentTarget.blur();
+                window.scrollTo(0,0);
+                this.tab = tab;
+
+                if(tab != 'feed') {
+                    history.pushState(null, null, '/groups/home?ct=' + tab);
+                } else {
+                    history.pushState(null, null, '/groups/home');
+                }
+            },
+
+            autocompleteSearch(input) {
+                if (!input || input.length < 2) {
+                    if(this.tab = 'searchresults') {
+                        this.tab = 'feed';
+                    }
+                    return [];
+                };
+
+                this.searchQuery = input;
+                // this.tab = 'searchresults';
+
+                if(input.startsWith('http')) {
+                    let url = new URL(input);
+                    if(url.hostname == location.hostname) {
+                        location.href = input;
+                        return [];
+                    }
+                    return [];
+                }
+
+                if(input.startsWith('#')) {
+                    this.$bvToast.toast(input, {
+                        title: 'Hashtag detected',
+                        variant: 'info',
+                        autoHideDelay: 5000
+                    });
+                    return [];
+                }
+
+                return axios.post('/api/v0/groups/search/global', {
+                    q: input,
+                    v: '0.2'
+                })
+                .then(res => {
+                    this.searchLoading = false;
+                    return res.data;
+                }).catch(err => {
+
+                    if(err.response.status === 422) {
+                        this.$bvToast.toast(err.response.data.error.message, {
+                            title: 'Cannot display search results',
+                            variant: 'danger',
+                            autoHideDelay: 5000
+                        });
+                    }
+
+                    return [];
+                })
+            },
+
+            getSearchResultValue(result) {
+                return result.name;
+            },
+
+            onSearchSubmit(result) {
+                if (result.length < 1) {
+                    return [];
+                }
+
+                location.href = result.url;
+            },
+
+            truncateName(val) {
+                if(val.length < 24) {
+                    return val;
+                }
+
+                return val.substr(0, 23) + '...';
+            }
+        }
+    }
+</script>
+
+<style lang="scss">
+    .groups-home-component {
+        font-family: var(--font-family-sans-serif);
+
+        .group-nav-btn {
+            display: block;
+            width: 100%;
+            padding-left: 0;
+            padding-top: 0.3rem;
+            padding-bottom: 0.3rem;
+            margin-bottom: 0.3rem;
+            border-radius: 1.5rem;
+            text-align: left;
+            color: #6c757d;
+            background-color: transparent;
+            border-color: transparent;
+            justify-content: flex-start;
+
+            &.active {
+                background-color: #EFF6FF !important;
+                border:1px solid #DBEAFE !important;
+                color: #212529;
+
+                .group-nav-btn-icon {
+                    background-color: #2c78bf !important;
+                    color: #fff !important;
+                }
+            }
+
+            &-icon {
+                display: inline-flex;
+                width: 35px;
+                height: 35px;
+                padding: 12px;
+                background-color: #E5E7EB;
+                border-radius: 17px;
+                margin: auto 0.3rem;
+                align-items: center;
+                justify-content: center;
+            }
+
+            &-name {
+                display: inline-block;
+                margin-left: 0.3rem;
+                font-weight: 700;
+            }
+        }
+
+        .autocomplete-input {
+            height: 2.375rem;
+            background-color: #f8f9fa !important;
+            font-size: 0.9rem;
+            color: #495057;
+            border-radius: 50rem;
+            border-color: transparent;
+
+            &:focus,
+            &[aria-expanded=true] {
+                box-shadow: none;
+            }
+        }
+
+        .autocomplete-result {
+            background: none;
+            padding: 12px;
+
+            &:hover,
+            &:focus {
+                background-color: #EFF6FF !important;
+            }
+
+            .media {
+                img {
+                    object-fit: cover;
+                    border-radius: 4px;
+                    margin-right: 0.6rem;
+                }
+
+                .icon-placeholder {
+                    display: flex;
+                    width: 32px;
+                    height: 32px;
+                    background-color: #2c78bf;
+                    border-radius: 4px;
+                    justify-content: center;
+                    align-items: center;
+                    color: #fff;
+                    margin-right: 0.6rem;
+                }
+            }
+        }
+
+        .autocomplete-result-list {
+            padding-bottom: 0;
+        }
+
+        .fade-enter-active, .fade-leave-active {
+            transition: opacity 200ms;
+        }
+
+        .fade-enter, .fade-leave-to {
+            opacity: 0;
+        }
+    }
+</style>

+ 79 - 0
resources/assets/components/GroupJoins.vue

@@ -0,0 +1,79 @@
+<template>
+    <div class="group-joins-component">
+        <div class="row border-bottom m-0 p-0">
+            <sidebar />
+
+            <div class="col-12 col-md-9 px-0 mx-0">
+                <loader v-if="!loaded" :loaded="loaded" />
+
+                <template v-else>
+                    <div class="px-5 pt-4 pb-2">
+                        <h2 class="fw-bold">My Groups</h2>
+                        <self-groups :profile="profile" />
+                    </div>
+                </template>
+            </div>
+        </div>
+    </div>
+</template>
+
+<script type="text/javascript">
+    import SidebarComponent from '@/groups/sections/Sidebar.vue';
+    import LoaderComponent from '@/groups/sections/Loader.vue';
+    import SelfGroups from '@/groups/partials/SelfGroups.vue';
+
+    export default {
+        components: {
+            "sidebar": SidebarComponent,
+            "loader": LoaderComponent,
+            "self-groups": SelfGroups
+        },
+
+        data() {
+            return {
+                loaded: false,
+                loadTimeout: undefined,
+                config: {},
+                groups: [],
+                profile: {},
+            }
+        },
+
+        methods: {
+            init() {
+                document.querySelectorAll("footer").forEach(e => e.parentNode.removeChild(e));
+                document.querySelectorAll(".mobile-footer-spacer").forEach(e => e.parentNode.removeChild(e));
+                document.querySelectorAll(".mobile-footer").forEach(e => e.parentNode.removeChild(e));
+                this.loaded = true;
+            },
+
+            fetchConfig() {
+                axios.get('/api/v0/groups/config')
+                .then(res => {
+                    this.config = res.data;
+                    this.fetchProfile();
+                });
+            },
+
+            fetchProfile() {
+                axios.get('/api/pixelfed/v1/accounts/verify_credentials')
+                .then(res => {
+                    this.profile = res.data;
+                    this.init();
+                    window._sharedData.curUser = res.data;
+                    window.App.util.navatar();
+                })
+            },
+        },
+
+        created() {
+            this.fetchConfig();
+        }
+    }
+</script>
+
+<style lang="scss" scoped>
+    .group-joins-component {
+        font-family: var(--font-family-sans-serif);
+    }
+</style>

+ 57 - 0
resources/assets/components/GroupNotifications.vue

@@ -0,0 +1,57 @@
+<template>
+    <div class="group-notifications-component">
+        <div class="row border-bottom m-0 p-0">
+            <sidebar />
+
+            <div class="col-12 col-md-9 px-0 mx-0">
+                <loader v-if="!loaded" :loaded="loaded" />
+
+                <template v-else>
+                    <div class="px-5 pt-4 pb-2">
+                        <h2 class="fw-bold">Group Notifications</h2>
+                    </div>
+                </template>
+            </div>
+        </div>
+    </div>
+</template>
+
+<script type="text/javascript">
+    import SidebarComponent from '@/groups/sections/Sidebar.vue';
+    import LoaderComponent from '@/groups/sections/Loader.vue';
+
+    export default {
+        components: {
+            "sidebar": SidebarComponent,
+            "loader": LoaderComponent
+        },
+
+        data() {
+            return {
+                loaded: false,
+                loadTimeout: undefined,
+            }
+        },
+
+        created() {
+            this.loadTimeout = setTimeout(() => {
+                this.loaded = true;
+            }, 1000);
+        },
+
+        beforeUnmount() {
+            clearTimeout(this.loadTimeout);
+        }
+    }
+</script>
+
+<style lang="scss" scoped>
+    .group-notifications-component {
+        font-family: var(--font-family-sans-serif);
+
+        .jumbotron {
+            background-color: #fff;
+            border-radius: 0px;
+        }
+    }
+</style>

+ 1190 - 0
resources/assets/components/GroupPage.vue

@@ -0,0 +1,1190 @@
+<template>
+    <div class="group-feed-component">
+        <div v-if="!initalLoad">
+            <p class="text-center mt-5 pt-5 font-weight-bold">Loading...</p>
+        </div>
+
+        <template v-else>
+            <div class="row border-bottom m-0 p-0">
+                <sidebar />
+
+                <div class="col-12 col-md-9 px-md-0">
+                    <div class="bg-white mb-3 border-bottom">
+                        <div class="">
+                            <group-banner :group="group" />
+
+                            <div class="col-12 group-feed-component-header px-3 px-md-5">
+                                <div class="media align-items-end">
+                                    <img
+                                        v-if="group.metadata && group.metadata.hasOwnProperty('avatar')"
+                                        :src="group.metadata.avatar.url"
+                                        width="169"
+                                        height="169"
+                                        class="bg-white mx-4 rounded-circle border shadow p-1"
+                                        style="object-fit: cover;"
+                                        :style="{ 'margin-top': group.metadata && group.metadata.hasOwnProperty('header') && group.metadata.header.url ? '-100px' : '0' }">
+
+                                    <div class="media-body">
+                                        <h3 class="d-flex align-items-start">
+                                            <span>{{ group.name.slice(0,118) }}</span>
+                                            <sup v-if="group.verified" class="fa-stack ml-n2" title="Verified Group" data-toggle="tooltip">
+                                                <i class="fas fa-circle fa-stack-1x fa-lg" style="color: #22a7f0cc;font-size:18px"></i>
+                                                <i class="fas fa-check fa-stack-1x text-white" style="font-size:10px"></i>
+                                            </sup>
+                                        </h3>
+                                        <p class="text-muted mb-0" style="font-weight: 300;">
+                                            <span>
+                                                <i class="fas fa-globe mr-1"></i>
+                                                {{ group.membership == 'all' ? 'Public Group' : 'Private Group'}}
+                                            </span>
+                                            <span class="mx-2">
+                                                ·
+                                            </span>
+                                            <span>{{ group.member_count == 1 ? group.member_count + ' Member' : group.member_count + ' Members' }}</span>
+                                            <span class="mx-2">
+                                                ·
+                                            </span>
+                                            <span v-if="group.local" class="rounded member-label">Local</span>
+                                            <span v-else class="rounded remote-label">Remote</span>
+                                            <span v-if="group.self && group.self.hasOwnProperty('role') && group.self.role">
+                                                <span class="mx-2">
+                                                    ·
+                                                </span>
+                                                <span class="rounded member-label">{{ group.self.role }}</span>
+                                            </span>
+                                        </p>
+                                    </div>
+                                </div>
+                                <div>
+                                    <button v-if="!isMember && !group.self.is_requested" class="btn btn-primary cta-btn font-weight-bold" @click="joinGroup" :disabled="requestingMembership">
+                                        <span v-if="!requestingMembership">
+                                            {{ group.membership == 'all' ? 'Join' : 'Request Membership' }}
+                                        </span>
+                                        <div
+                                            v-else
+                                            class="spinner-border spinner-border-sm"
+                                            role="status">
+                                            <span class="sr-only">Loading...</span>
+                                        </div>
+                                    </button>
+
+                                    <button
+                                        v-else-if="!isMember && group.self.is_requested"
+                                        class="btn btn-light border cta-btn font-weight-bold"
+                                        @click.prevent="cancelJoinRequest">
+                                        <i class="fas fa-user-clock mr-1"></i> Requested to Join
+                                    </button>
+
+                                    <button
+                                        v-else-if="!isAdmin && isMember && !group.self.is_requested"
+                                        type="button"
+                                        class="btn btn-light border cta-btn font-weight-bold"
+                                        @click.prevent="leaveGroup">
+                                        <i class="fas sign-out-alt mr-1"></i> Leave Group
+                                    </button>
+
+                                    <!-- <div v-if="isAdmin">
+                                        <a
+                                            class="btn btn-light border cta-btn font-weight-bold"
+                                            :href="group.url + '/settings'">
+                                            Settings
+                                        </a>
+                                    </div> -->
+                                </div>
+                            </div>
+                            <div class="col-12 border-top group-feed-component-menu px-5">
+                                <ul class="nav font-weight-bold group-feed-component-menu-nav">
+                                    <li class="nav-item">
+                                        <a :class="{active: tab == 'about'}" class="nav-link" href="#" @click.prevent="switchTab('about')">About</a>
+                                    </li>
+                                    <li class="nav-item">
+                                        <a :class="{active: tab == 'feed'}" class="nav-link" href="#" @click.prevent="switchTab('feed')">Feed</a>
+                                    </li>
+                                    <li v-if="group.self.is_member" class="nav-item">
+                                        <a :class="{active: tab == 'topics'}" class="nav-link" href="#" @click.prevent="switchTab('topics')">Topics</a>
+                                    </li>
+                                    <li v-if="group.self.is_member" class="nav-item">
+                                        <a :class="{active: tab == 'members'}" class="nav-link" href="#" @click.prevent="switchTab('members')">
+                                            Members
+                                            <span v-if="group.self.is_member && isAdmin && atabs.request_count" class="badge badge-danger rounded-pill ml-2" style="height: 20px;padding:4px 8px;font-size: 11px;">{{atabs.request_count}}</span>
+                                        </a>
+                                    </li>
+                                    <!-- <li v-if="group.self.is_member" class="nav-item">
+                                        <a :class="{active: tab == 'events'}" class="nav-link" href="#" @click.prevent="switchTab('events')">Events</a>
+                                    </li> -->
+                                    <li v-if="group.self.is_member" class="nav-item">
+                                        <a :class="{active: tab == 'media'}" class="nav-link" href="#" @click.prevent="switchTab('media')">Media</a>
+                                    </li>
+                                    <!-- <li v-if="group.self.is_member" class="nav-item">
+                                        <a class="nav-link" href="#">Popular</a>
+                                    </li> -->
+                                    <!-- <li v-if="group.self.is_member" class="nav-item">
+                                        <a :class="{active: tab == 'polls'}" class="nav-link" href="#" @click.prevent="switchTab('polls')">Polls</a>
+                                    </li> -->
+
+                                    <!-- <li v-if="group.self.is_member && isAdmin" class="nav-item">
+                                        <a class="nav-link" href="#">Messages</a>
+                                    </li> -->
+
+                                    <!-- <li v-if="group.self.is_member && isAdmin" class="nav-item">
+                                        <a :class="{active: tab == 'insights'}" class="nav-link" href="#" @click.prevent="switchTab('insights')">Insights</a>
+                                    </li> -->
+                                    <!-- <li v-if="group.self.is_member && isAdmin && group.membership != 'all'" class="nav-item">
+                                        <a :class="{active: tab == 'requests'}" class="nav-link" href="#" @click.prevent="switchTab('requests')">
+                                            <span class="mr-2">
+                                                <i class="far fa-user-plus mr-1"></i>
+                                                Requests
+                                            </span>
+                                            <span v-if="atabs.request_count" class="badge badge-danger rounded-pill" style="height: 20px;padding:4px 8px;font-size: 11px;">{{atabs.request_count}}</span>
+                                            <span v-if="atabs.request_count" class="badge badge-danger rounded-pill" style="height: 20px;padding:4px 8px;font-size: 11px;">99+</span>
+                                        </a>
+                                    </li> -->
+                                    <li v-if="group.self.is_member && isAdmin" class="nav-item">
+                                        <a :class="{active: tab == 'moderation'}" class="nav-link d-flex align-items-top" href="#" @click.prevent="switchTab('moderation')">
+                                            <span class="mr-2">Moderation</span>
+                                            <span v-if="atabs.moderation_count" class="badge badge-danger rounded-pill" style="height: 20px;padding:4px 8px;font-size: 11px;">{{atabs.moderation_count}}</span>
+                                        </a>
+                                    </li>
+                                </ul>
+                                <div>
+                                    <button
+                                        v-if="group.self.is_member"
+                                        class="btn btn-light btn-sm border px-3 rounded-pill mr-2"
+                                        @click="showSearchModal">
+                                        <i class="far fa-search"></i>
+                                    </button>
+                                    <div class="dropdown d-inline">
+                                        <button class="btn btn-light btn-sm border px-3 rounded-pill dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
+                                            <i class="far fa-cog"></i>
+                                        </button>
+                                        <div class="dropdown-menu dropdown-menu-right">
+                                            <a class="dropdown-item" href="#" @click.prevent="copyLink">
+                                                Copy Group Link
+                                            </a>
+
+                                            <a class="dropdown-item" href="#" @click.prevent="showInviteModal">
+                                                Invite friends
+                                            </a>
+
+                                            <a v-if="!isAdmin" class="dropdown-item" href="#" @click.prevent="reportGroup">
+                                                Report Group
+                                            </a>
+
+                                            <a v-if="isAdmin" class="dropdown-item" :href="group.url + '/settings'">
+                                                Settings
+                                            </a>
+                                        </div>
+                                    </div>
+                                </div>
+                            </div>
+                        </div>
+                    </div>
+
+                    <div class="container-xl group-feed-component-body">
+                        <keep-alive>
+                            <div v-if="tab == 'feed'" class="row mb-5">
+
+                                <div v-if="!permalinkMode" class="col-12 col-md-7 mt-3">
+                                    <div v-if="group.self.is_member">
+                                        <!-- <div class="card card-body border my-3 shadow-sm rounded-lg">
+                                            <div class="media align-items-center">
+                                                <img :src="profile.avatar" class="rounded-circle border mr-3" width="42px" height="42px">
+                                                <div class="media-body">
+                                                    <div class="reply-form form-group mb-0">
+                                                        <input class="form-control form-control-lg rounded-pill bg-light border-0" placeholder="Write something..." v-model="composeText">
+                                                    </div>
+                                                </div>
+                                            </div>
+                                            <p v-if="composeText && composeText.length > 1" class="mb-0">
+                                                <button class="btn btn-primary font-weight-bold float-right mt-3" @click="newPost()">Post</button>
+                                            </p>
+                                        </div> -->
+
+                                        <group-compose
+                                            v-if="initalLoad"
+                                            :profile="profile"
+                                            :group-id="groupId"
+                                            v-on:new-status="pushNewStatus" />
+
+                                        <div v-if="feed.length == 0" class="mt-3">
+                                            <div class="card card-body shadow-none border d-flex align-items-center justify-content-center" style="height: 200px;">
+                                                <p class="font-weight-bold mb-0">No posts yet!</p>
+                                            </div>
+                                        </div>
+
+                                        <div v-else class="group-timeline">
+                                            <p class="font-weight-bold mb-1">Recent Posts</p>
+
+
+                                            <!-- <div class="status-card-component shadow-sm mb-3 rounded-lg">
+                                                <div class="card shadow-none border rounded-0">
+                                                    <div class="card-body pb-0">
+                                                        <div class="media">
+                                                            <img src="https://pixelfed.test/storage/avatars/321493203255693312/5a6nqo.jpg?v=2" width="42px" height="42px" onerror="this.onerror=null;this.src='/storage/avatars/default.png?v=2'" alt="avatar" class="rounded-circle box-shadow mr-2">
+
+                                                            <div class="media-body">
+                                                                <div class="pl-2 d-flex align-items-top">
+                                                                    <div>
+                                                                        <p class="mb-0">
+                                                                            <a href="https://pixelfed.test/dansup" class="username font-weight-bold text-dark text-decoration-none text-break">
+                                                                                dansup
+                                                                            </a>
+                                                                        </p>
+
+                                                                        <p class="mb-0">
+                                                                            <a href="https://pixelfed.test/groups/328821658771132416/329186991407239168" class="font-weight-light text-muted small">13h</a>
+
+                                                                            <span class="text-lighter" style="padding-left: 2px; padding-right: 2px;">·</span>
+
+                                                                            <span class="text-muted small">
+                                                                                <i class="fas fa-globe"></i>
+                                                                            </span>
+                                                                        </p>
+                                                                    </div>
+
+                                                                    <span class="text-right" style="flex-grow: 1;">
+                                                                        <button type="button" class="btn btn-link text-dark py-0">
+                                                                            <span class="fas fa-ellipsis-h text-lighter"></span>
+
+                                                                            <span class="sr-only">Post Menu</span>
+                                                                        </button>
+                                                                    </span>
+                                                                </div>
+                                                            </div>
+                                                        </div>
+
+                                                        <div>
+                                                            <div>
+                                                                <div class="">
+                                                                    <p class="pt-2 text-break" style="font-size: 15px;">
+                                                                        Made some improvements!
+                                                                    </p>
+
+                                                                    <div class="my-3 row px-0 mx-0 card card-body my-0 py-0 border shadow-none">
+                                                                            <img src="https://opengraph.githubassets.com/f66d0f7bf17df4a45382b83c1ffde2f25e3d700f9d87ab8c9ec2029c3a1e16b6/pixelfed/pixelfed/pull/2865" class="img-fluid">
+                                                                        <div class="bg-light px-3 pt-2 pb-3">
+                                                                            <p class="text-muted mb-0 small">GITHUB.COM</p>
+                                                                            <p class="mb-0" style="font-size: 16px;font-weight:500;">Update LikeController, add UndoLikePipeline and federate Undo Like ac… by dansup · Pull Request #2865 · pixelfed/pixelfed</p>
+                                                                            <p class="mb-0 text-muted" style="font-size:14px;line-height:15px;">…tivities</p>
+                                                                        </div>
+                                                                    </div>
+
+                                                                    <div class="border-top my-0">
+                                                                        <div class="d-flex justify-content-between py-2 px-4">
+                                                                            <button class="btn btn-link py-0 text-decoration-none text-muted">
+                                                                                <i class="far fa-heart mr-1"></i>
+                                                                                Like
+                                                                            </button>
+
+                                                                            <div>
+                                                                                <i class="far fa-comment cursor-pointer text-muted mr-1"></i>
+                                                                                Comment
+                                                                            </div>
+
+                                                                            <div>
+                                                                                <i class="fas fa-external-link-alt cursor-pointer text-muted mr-1"></i>
+                                                                                Share
+                                                                            </div>
+                                                                        </div>
+                                                                    </div>
+                                                                </div>
+                                                            </div>
+                                                        </div>
+                                                    </div>
+                                                </div>
+                                            </div> -->
+
+                                            <!-- OGP -->
+                                            <!-- <div class="status-card-component shadow-sm mb-3 rounded-lg">
+                                                <div class="card shadow-none border rounded-0">
+                                                    <div class="card-body pb-0">
+                                                        <div class="media">
+                                                            <img src="https://pixelfed.test/storage/avatars/321493203255693312/5a6nqo.jpg?v=2" width="42px" height="42px" onerror="this.onerror=null;this.src='/storage/avatars/default.png?v=2'" alt="avatar" class="rounded-circle box-shadow mr-2">
+
+                                                            <div class="media-body">
+                                                                <div class="pl-2 d-flex align-items-top">
+                                                                    <div>
+                                                                        <p class="mb-0">
+                                                                            <a href="https://pixelfed.test/dansup" class="username font-weight-bold text-dark text-decoration-none text-break">
+                                                                                dansup
+                                                                            </a>
+                                                                        </p>
+
+                                                                        <p class="mb-0">
+                                                                            <a href="https://pixelfed.test/groups/328821658771132416/329186991407239168" class="font-weight-light text-muted small">13h</a>
+
+                                                                            <span class="text-lighter" style="padding-left: 2px; padding-right: 2px;">·</span>
+
+                                                                            <span class="text-muted small">
+                                                                                <i class="fas fa-globe"></i>
+                                                                            </span>
+                                                                        </p>
+                                                                    </div>
+
+                                                                    <span class="text-right" style="flex-grow: 1;">
+                                                                        <button type="button" class="btn btn-link text-dark py-0">
+                                                                            <span class="fas fa-ellipsis-h text-lighter"></span>
+
+                                                                            <span class="sr-only">Post Menu</span>
+                                                                        </button>
+                                                                    </span>
+                                                                </div>
+                                                            </div>
+                                                        </div>
+
+                                                        <div>
+                                                            <div>
+                                                                <div class="">
+                                                                    <div class="my-3 row px-0 mx-0 card card-body my-0 py-0 border shadow-none">
+                                                                            <img src="https://www.ctvnews.ca/polopoly_fs/1.5533318.1628281952!/httpImage/image.jpg_gen/derivatives/landscape_620/image.jpg" class="img-fluid">
+                                                                        <div class="bg-light px-3 pt-2 pb-3">
+                                                                            <p class="text-muted mb-0 small">CTVNEWS.CA</p>
+                                                                            <p class="mb-0" style="font-size: 16px;font-weight:500;">No charges against Alberta man who fatally shot home intruder: RCMP</p>
+                                                                            <p class="mb-0 text-muted" style="font-size:14px;line-height:15px;">No charges will be laid against an Alberta man who shot and killed an intruder after being beaten with a baseball bat, RCMP announced Friday.</p>
+                                                                        </div>
+                                                                    </div>
+
+                                                                    <div class="border-top my-0">
+                                                                        <div class="d-flex justify-content-between py-2 px-4">
+                                                                            <button class="btn btn-link py-0 text-decoration-none text-muted">
+                                                                                <i class="far fa-heart mr-1"></i>
+                                                                                Like
+                                                                            </button>
+
+                                                                            <div>
+                                                                                <i class="far fa-comment cursor-pointer text-muted mr-1"></i>
+                                                                                Comment
+                                                                            </div>
+
+                                                                            <div>
+                                                                                <i class="fas fa-external-link-alt cursor-pointer text-muted mr-1"></i>
+                                                                                Share
+                                                                            </div>
+                                                                        </div>
+                                                                    </div>
+                                                                </div>
+                                                            </div>
+                                                        </div>
+                                                    </div>
+                                                </div>
+                                            </div> -->
+
+                                            <!-- SOUNDCLOUD -->
+                                            <!-- <div class="status-card-component shadow-sm mb-3 rounded-lg">
+                                                <div class="card shadow-none border rounded-0">
+                                                    <div class="card-body pb-0">
+                                                        <div class="media">
+                                                            <img src="https://pixelfed.test/storage/avatars/321493203255693312/5a6nqo.jpg?v=2" width="42px" height="42px" onerror="this.onerror=null;this.src='/storage/avatars/default.png?v=2'" alt="avatar" class="rounded-circle box-shadow mr-2">
+
+                                                            <div class="media-body">
+                                                                <div class="pl-2 d-flex align-items-top">
+                                                                    <div>
+                                                                        <p class="mb-0">
+                                                                            <a href="https://pixelfed.test/dansup" class="username font-weight-bold text-dark text-decoration-none text-break">
+                                                                                dansup
+                                                                            </a>
+                                                                        </p>
+
+                                                                        <p class="mb-0">
+                                                                            <a href="https://pixelfed.test/groups/328821658771132416/329186991407239168" class="font-weight-light text-muted small">13h</a>
+
+                                                                            <span class="text-lighter" style="padding-left: 2px; padding-right: 2px;">·</span>
+
+                                                                            <span class="text-muted small">
+                                                                                <i class="fas fa-globe"></i>
+                                                                            </span>
+                                                                        </p>
+                                                                    </div>
+
+                                                                    <span class="text-right" style="flex-grow: 1;">
+                                                                        <button type="button" class="btn btn-link text-dark py-0">
+                                                                            <span class="fas fa-ellipsis-h text-lighter"></span>
+
+                                                                            <span class="sr-only">Post Menu</span>
+                                                                        </button>
+                                                                    </span>
+                                                                </div>
+                                                            </div>
+                                                        </div>
+
+                                                        <div>
+                                                            <div>
+                                                                <div class="">
+                                                                    <p class="pt-2 text-break" style="font-size: 15px;">
+                                                                        What does everyone think??
+                                                                    </p>
+
+                                                                    <div class="my-3 row p-0 m-0">
+                                                                        <iframe width="100%" height="166" scrolling="no" frameborder="no" allow="autoplay" src="https://w.soundcloud.com/player/?url=https%3A//api.soundcloud.com/tracks/34019569&color=0066cc"></iframe><div style="font-size: 10px; color: #cccccc;line-break: anywhere;word-break: normal;overflow: hidden;white-space: nowrap;text-overflow: ellipsis; font-family: Interstate,Lucida Grande,Lucida Sans Unicode,Lucida Sans,Garuda,Verdana,Tahoma,sans-serif;font-weight: 100;"><a href="https://soundcloud.com/the-bugle" title="The Bugle" target="_blank" style="color: #cccccc; text-decoration: none;">The Bugle</a> · <a href="https://soundcloud.com/the-bugle/bugle-179-playas-gon-play" title="Bugle 179 - Playas gon play" target="_blank" style="color: #cccccc; text-decoration: none;">Bugle 179 - Playas gon play</a></div>
+                                                                    </div>
+
+                                                                    <div class="border-top my-0">
+                                                                        <div class="d-flex justify-content-between py-2 px-4">
+                                                                            <button class="btn btn-link py-0 text-decoration-none text-muted">
+                                                                                <i class="far fa-heart mr-1"></i>
+                                                                                Like
+                                                                            </button>
+
+                                                                            <div>
+                                                                                <i class="far fa-comment cursor-pointer text-muted mr-1"></i>
+                                                                                Comment
+                                                                            </div>
+
+                                                                            <div>
+                                                                                <i class="fas fa-external-link-alt cursor-pointer text-muted mr-1"></i>
+                                                                                Share
+                                                                            </div>
+                                                                        </div>
+                                                                    </div>
+                                                                </div>
+                                                            </div>
+                                                        </div>
+                                                    </div>
+                                                </div>
+                                            </div> -->
+
+                                            <!-- YOUTUBE -->
+                                            <!-- <div class="status-card-component shadow-sm mb-3 rounded-lg">
+                                                <div class="card shadow-none border rounded-0">
+                                                    <div class="card-body pb-0">
+                                                        <div class="media">
+                                                            <img src="https://pixelfed.test/storage/avatars/321493203255693312/5a6nqo.jpg?v=2" width="42px" height="42px" onerror="this.onerror=null;this.src='/storage/avatars/default.png?v=2'" alt="avatar" class="rounded-circle box-shadow mr-2">
+
+                                                            <div class="media-body">
+                                                                <div class="pl-2 d-flex align-items-top">
+                                                                    <div>
+                                                                        <p class="mb-0">
+                                                                            <a href="https://pixelfed.test/dansup" class="username font-weight-bold text-dark text-decoration-none text-break">
+                                                                                dansup
+                                                                            </a>
+                                                                        </p>
+
+                                                                        <p class="mb-0">
+                                                                            <a href="https://pixelfed.test/groups/328821658771132416/329186991407239168" class="font-weight-light text-muted small">13h</a>
+
+                                                                            <span class="text-lighter" style="padding-left: 2px; padding-right: 2px;">·</span>
+
+                                                                            <span class="text-muted small">
+                                                                                <i class="fas fa-globe"></i>
+                                                                            </span>
+                                                                        </p>
+                                                                    </div>
+
+                                                                    <span class="text-right" style="flex-grow: 1;">
+                                                                        <button type="button" class="btn btn-link text-dark py-0">
+                                                                            <span class="fas fa-ellipsis-h text-lighter"></span>
+
+                                                                            <span class="sr-only">Post Menu</span>
+                                                                        </button>
+                                                                    </span>
+                                                                </div>
+                                                            </div>
+                                                        </div>
+
+                                                        <div>
+                                                            <div>
+                                                                <div class="">
+                                                                    <p class="pt-2 text-break" style="font-size: 15px;">
+                                                                        What does everyone think??
+                                                                    </p>
+
+                                                                    <div class="my-3 row p-0 m-0">
+                                                                        <iframe width="100%" height="315" src="https://www.youtube.com/embed/lH78Tb0r_f8" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>
+                                                                    </div>
+
+                                                                    <div class="border-top my-0">
+                                                                        <div class="d-flex justify-content-between py-2 px-4">
+                                                                            <button class="btn btn-link py-0 text-decoration-none text-muted">
+                                                                                <i class="far fa-heart mr-1"></i>
+                                                                                Like
+                                                                            </button>
+
+                                                                            <div>
+                                                                                <i class="far fa-comment cursor-pointer text-muted mr-1"></i>
+                                                                                Comment
+                                                                            </div>
+
+                                                                            <div>
+                                                                                <i class="fas fa-external-link-alt cursor-pointer text-muted mr-1"></i>
+                                                                                Share
+                                                                            </div>
+                                                                        </div>
+                                                                    </div>
+                                                                </div>
+                                                            </div>
+                                                        </div>
+                                                    </div>
+                                                </div>
+                                            </div> -->
+
+                                            <!-- PHOTOS -->
+                                            <!-- <div class="status-card-component shadow-sm mb-3 rounded-lg">
+                                                <div class="card shadow-none border rounded-0">
+                                                    <div class="card-body pb-0">
+                                                        <div class="media">
+                                                            <img src="https://pixelfed.test/storage/avatars/321493203255693312/5a6nqo.jpg?v=2" width="42px" height="42px" onerror="this.onerror=null;this.src='/storage/avatars/default.png?v=2'" alt="avatar" class="rounded-circle box-shadow mr-2">
+
+                                                            <div class="media-body">
+                                                                <div class="pl-2 d-flex align-items-top">
+                                                                    <div>
+                                                                        <p class="mb-0">
+                                                                            <a href="https://pixelfed.test/dansup" class="username font-weight-bold text-dark text-decoration-none text-break">
+                                                                                dansup
+                                                                            </a>
+                                                                        </p>
+
+                                                                        <p class="mb-0">
+                                                                            <a href="https://pixelfed.test/groups/328821658771132416/329186991407239168" class="font-weight-light text-muted small">13h</a>
+
+                                                                            <span class="text-lighter" style="padding-left: 2px; padding-right: 2px;">·</span>
+
+                                                                            <span class="text-muted small">
+                                                                                <i class="fas fa-globe"></i>
+                                                                            </span>
+                                                                        </p>
+                                                                    </div>
+
+                                                                    <span class="text-right" style="flex-grow: 1;">
+                                                                        <button type="button" class="btn btn-link text-dark py-0">
+                                                                            <span class="fas fa-ellipsis-h text-lighter"></span>
+
+                                                                            <span class="sr-only">Post Menu</span>
+                                                                        </button>
+                                                                    </span>
+                                                                </div>
+                                                            </div>
+                                                        </div>
+
+                                                        <div>
+                                                            <div>
+                                                                <div class="">
+                                                                    <p class="pt-2 text-break" style="font-size: 15px;">
+                                                                        What does everyone think??
+                                                                    </p>
+
+                                                                    <div class="mb-1 row px-3">
+                                                                        <div class="col px-0">
+                                                                            <img src="https://pixelfed.test/img/sample-post.jpeg" class="img-fluid border rounded-lg">
+                                                                        </div>
+                                                                        <div class="col px-0">
+                                                                            <img src="https://pixelfed.test/img/sample-post.jpeg" class="img-fluid border rounded-lg">
+                                                                        </div>
+                                                                    </div>
+
+                                                                    <div class="mb-3 row px-3">
+                                                                        <div class="col px-0">
+                                                                            <img src="https://pixelfed.test/img/sample-post.jpeg" class="img-fluid border rounded-lg">
+                                                                        </div>
+                                                                        <div class="col px-0">
+                                                                            <img src="https://pixelfed.test/img/sample-post.jpeg" class="img-fluid border rounded-lg">
+                                                                        </div>
+                                                                    </div>
+
+                                                                    <div class="border-top my-0">
+                                                                        <div class="d-flex justify-content-between py-2 px-4">
+                                                                            <button class="btn btn-link py-0 text-decoration-none text-muted">
+                                                                                <i class="far fa-heart mr-1"></i>
+                                                                                Like
+                                                                            </button>
+
+                                                                            <div>
+                                                                                <i class="far fa-comment cursor-pointer text-muted mr-1"></i>
+                                                                                Comment
+                                                                            </div>
+
+                                                                            <div>
+                                                                                <i class="fas fa-external-link-alt cursor-pointer text-muted mr-1"></i>
+                                                                                Share
+                                                                            </div>
+                                                                        </div>
+                                                                    </div>
+                                                                </div>
+                                                            </div>
+                                                        </div>
+                                                    </div>
+                                                </div>
+                                            </div> -->
+
+                                            <group-status
+                                                v-for="(status, index) in feed"
+                                                :key="'gs:' + status.id + index"
+                                                :prestatus="status"
+                                                :profile="profile"
+                                                :group-id="groupId"
+                                                v-on:comment-focus="commentFocus(index)"
+                                                v-on:status-delete="statusDelete(index)"
+                                                v-on:likes-modal="showLikesModal(index)" />
+
+                                            <b-modal
+                                                ref="likeBox"
+                                                size="sm"
+                                                centered
+                                                hide-footer
+                                                title="Likes"
+                                                body-class="list-group-flush p-0">
+                                                <div class="list-group py-1" style="max-height:300px;overflow-y:auto;">
+                                                    <div
+                                                        class="list-group-item border-top-0 border-left-0 border-right-0 py-2"
+                                                        :class="{ 'border-bottom-0': index + 1 == likes.length }"
+                                                        v-for="(user, index) in likes" :key="'modal_likes_'+index">
+                                                        <div class="media align-items-center">
+                                                            <a :href="user.url">
+                                                                <img class="mr-3 rounded-circle box-shadow" :src="user.avatar" :alt="user.username + '’s avatar'" width="30px" onerror="this.onerror=null;this.src='/storage/avatars/default.jpg';">
+                                                            </a>
+                                                            <div class="media-body">
+                                                                <p class="mb-0" style="font-size: 14px">
+                                                                    <a :href="user.url" class="font-weight-bold text-dark">
+                                                                        {{user.username}}
+                                                                    </a>
+                                                                </p>
+                                                                <p v-if="!user.local" class="text-muted mb-0 text-truncate mr-3" style="font-size: 14px" :title="user.acct" data-toggle="dropdown" data-placement="bottom">
+                                                                    <span class="font-weight-bold">{{user.acct.split('@')[0]}}</span><span class="text-lighter">&commat;{{user.acct.split('@')[1]}}</span>
+                                                                </p>
+                                                                <p v-else class="text-muted mb-0 text-truncate" style="font-size: 14px">
+                                                                    {{user.display_name}}
+                                                                </p>
+                                                            </div>
+                                                        </div>
+                                                    </div>
+                                                    <infinite-loading @infinite="infiniteLikesHandler" :distance="800" spinner="spiral">
+                                                        <div slot="no-more"></div>
+                                                        <div slot="no-results"></div>
+                                                    </infinite-loading>
+                                                </div>
+                                            </b-modal>
+
+                                            <div v-if="feed.length > 2" :distance="800">
+                                                <infinite-loading @infinite="infiniteFeed">
+                                                    <div slot="no-more"></div>
+                                                    <div slot="no-results"></div>
+                                                </infinite-loading>
+                                            </div>
+                                        </div>
+                                    </div>
+
+                                    <div v-else>
+                                        <div class="card card-body mt-3 shadow-none border d-flex align-items-center justify-content-center" style="height: 100px;">
+                                            <p class="lead mb-0">Join to participate in this group.</p>
+                                        </div>
+                                    </div>
+                                </div>
+
+                                <div v-else class="col-12 col-md-7 mt-3">
+                                    <group-status
+                                        :prestatus="status"
+                                        :profile="profile"
+                                        :group-id="groupId"
+                                        :permalink-mode="true" />
+                                </div>
+
+                                <div class="col-12 col-md-5">
+                                    <group-info-card :group="group" />
+                                </div>
+                            </div>
+
+                            <group-about v-else-if="tab == 'about'" :group="group" />
+
+                            <group-media v-else-if="tab == 'media'" :group="group" />
+
+                            <group-members
+                                v-else-if="tab == 'members'"
+                                :group="group"
+                                :request-count="atabs.request_count"
+                                :is-admin="isAdmin"
+                                :profile="profile"
+                                v-on:decrementrc="decrementJoinRequestCount"
+                                v-on:incrementMemberCount="incrementMemberCount"/>
+
+                            <group-topics v-else-if="tab == 'topics'" :group="group" />
+
+                            <group-insights v-else-if="tab == 'insights'" :group="group" />
+
+                            <group-moderation
+                                v-else-if="tab == 'moderation'"
+                                :group="group"
+                                v-on:decrement="decrementModCounter" />
+
+                            <div v-else>
+                                <p class="lead text-center font-weight-bold mt-5 text-muted">No content found</p>
+                            </div>
+
+                        </keep-alive>
+                        <search-modal ref="searchModal" :group="group" :profile="profile" />
+                        <invite-modal ref="inviteModal" :group="group" :profile="profile" />
+                    </div>
+                </div>
+            </div>
+        </template>
+    </div>
+</template>
+
+<script type="text/javascript">
+    import StatusCard from '~/partials/StatusCard.vue';
+    import GroupMembers from '@/groups/partials/GroupMembers.vue';
+    import GroupCompose from '@/groups/partials/GroupCompose.vue';
+    import GroupStatus from '@/groups/partials/GroupStatus.vue';
+    import GroupAbout from '@/groups/partials/GroupAbout.vue';
+    import GroupMedia from '@/groups/partials/GroupMedia.vue';
+    import GroupModeration from '@/groups/partials/GroupModeration.vue';
+    import GroupTopics from '@/groups/partials/GroupTopics.vue';
+    import GroupInfoCard from '@/groups/partials/GroupInfoCard.vue';
+    import LeaveGroup from '@/groups/partials/LeaveGroup.vue';
+    import GroupInsights from '@/groups/partials/GroupInsights.vue';
+    import SearchModal from '@/groups/partials/GroupSearchModal.vue';
+    import InviteModal from '@/groups/partials/GroupInviteModal.vue';
+    import SidebarComponent from '@/groups/sections/Sidebar.vue';
+    import GroupBanner from '@/groups/partials/Page/GroupBanner.vue';
+
+    export default {
+        props: {
+            groupId: {
+                type: String
+            },
+
+            path: {
+                type: String
+            },
+
+            permalinkMode: {
+                type: Boolean,
+                default: false
+            },
+
+            permalinkId: {
+                type: String,
+            }
+        },
+
+        components: {
+            'status-card': StatusCard,
+            'group-about': GroupAbout,
+            'group-status': GroupStatus,
+            'group-members': GroupMembers,
+            'group-compose': GroupCompose,
+            'group-topics': GroupTopics,
+            'group-info-card': GroupInfoCard,
+            'group-media': GroupMedia,
+            'group-moderation': GroupModeration,
+            'leave-group': LeaveGroup,
+            'group-insights': GroupInsights,
+            'search-modal': SearchModal,
+            'invite-modal': InviteModal,
+            'sidebar': SidebarComponent,
+            'group-banner': GroupBanner
+        },
+
+        data() {
+            return {
+                initalLoad: false,
+                profile: undefined,
+                group: {},
+                isMember: false,
+                isAdmin: false,
+                tab: 'feed',
+                requestingMembership: false,
+                composeText: null,
+                feed: [],
+                ids: [],
+                maxId: null,
+                status: undefined,
+                likes: [],
+                likesPage: 1,
+                likesId: undefined,
+                atabs: {
+                    moderation_count: 0,
+                    request_count: 0
+                }
+            };
+        },
+
+        mounted() {
+            axios.get('/api/pixelfed/v1/accounts/verify_credentials')
+            .then(res => {
+                this.profile = res.data;
+                this.fetchGroup();
+            })
+            .catch(err => {
+                window.location.href = '/login?_next=' + encodeURIComponent(window.location.href);
+            });
+
+            if(this.permalinkMode) {
+                this.fetchPermalink();
+            } else {
+                this.fetchFeed();
+            }
+        },
+
+        methods: {
+            initObservers() {
+                // let video = document.querySelectorAll('video');
+                // let isPaused = false;
+                // let observer = new IntersectionObserver((entries, observer) => {
+                //  entries.forEach(entry => {
+                //      if (entry.intersectionRatio !=1  && !video.paused){
+                //          video.pause();
+                //          isPaused = true;
+                //      }
+                //      else if (isPaused) {
+                //          video.play();
+                //          isPaused = false
+                //      }
+                //  });
+                // }, {threshold: 1});
+                // observer.observe(video);
+            },
+
+            fetchGroup() {
+                axios.get('/api/v0/groups/' + this.groupId)
+                .then(res => {
+                    this.group = res.data;
+                    this.isMember = res.data.self.is_member;
+                    this.isAdmin = ['founder', 'admin'].includes(res.data.self.role);
+
+                    if(this.isAdmin) {
+                        this.fetchAdminTabs();
+                    }
+
+                    if(this.path) {
+                        if(this.isMember && ['about', 'topics', 'members', 'events', 'media', 'polls'].includes(this.path)) {
+                            setTimeout(() => {
+                                this.tab = this.path;
+                                this.initalLoad = true;
+                            }, 500);
+                        } else if (this.isAdmin && ['insights', 'moderation'].includes(this.path)) {
+                            setTimeout(() => {
+                                this.tab = this.path;
+                                this.initalLoad = true;
+                            }, 500);
+                        } else {
+                            history.pushState(null, null, this.group.url);
+                            this.initalLoad = true;
+                        }
+                    } else {
+                        this.initalLoad = true;
+                    }
+                })
+                .catch(err => {
+                    // window.location.href = '/groups/unavailable';
+                });
+            },
+
+            fetchAdminTabs() {
+                axios.get('/api/v0/groups/' + this.groupId + '/atabs')
+                .then(res => {
+                    this.atabs = res.data;
+                })
+            },
+
+            fetchFeed() {
+                axios.get('/api/v0/groups/' + this.groupId + '/feed')
+                .then(res => {
+                    let self = this;
+                    if(res.data && res.data.length) {
+                        this.feed = res.data;
+                        this.maxId = this.feed[this.feed.length - 1].id;
+                        res.data.forEach(d => {
+                            if(self.ids.indexOf(d.id) == -1) {
+                                self.ids.push(d.id);
+                            }
+                        });
+                    }
+                    this.initObservers();
+                })
+            },
+
+            fetchPermalink() {
+                axios.get('/api/v0/groups/status', {
+                    params: {
+                        gid: this.groupId,
+                        sid: this.permalinkId
+                    }
+                }).then(res => {
+                    this.status = res.data;
+                    if(this.status.in_reply_to_id) {
+                        this.status.showCommentDrawer = true;
+                    }
+                }).catch(err => {
+                    this.permalinkMode = false;
+                    this.fetchFeed();
+                });
+            },
+
+            timestampFormat(date, showTime = false) {
+                let ts = new Date(date);
+                return showTime ? ts.toDateString() + ' · ' + ts.toLocaleTimeString() : ts.toDateString();
+            },
+
+            switchTab(tab) {
+                window.scrollTo(0,0);
+                if(tab == 'feed' && this.permalinkMode) {
+                    this.permalinkMode = false;
+                    this.fetchFeed();
+                }
+                let url = tab == 'feed' ? this.group.url : this.group.url + '/' + tab;
+                history.pushState(tab, null, url);
+                this.tab = tab;
+            },
+
+            joinGroup() {
+                this.requestingMembership = true;
+
+                axios.post('/api/v0/groups/'+this.groupId+'/join')
+                .then(res => {
+                    this.requestingMembership = false;
+                    this.group = res.data;
+                    this.fetchGroup();
+                    this.fetchFeed();
+                }).catch(err => {
+                    let body = err.response;
+
+                    if(body.status == 422) {
+                        this.tab = 'feed';
+                        history.pushState('', null, this.group.url);
+                        this.requestingMembership = false;
+                        swal('Oops!', body.data.error, 'error');
+                    }
+                });
+            },
+
+            cancelJoinRequest() {
+                if(!window.confirm('Are you sure you want to cancel your request to join this group?')) {
+                    return;
+                }
+
+                axios.post('/api/v0/groups/'+this.groupId+'/cjr')
+                .then(res => {
+                    this.requestingMembership = false;
+                }).catch(err => {
+                    let body = err.response;
+
+                    if(body.status == 422) {
+                        swal('Oops!', body.data.error, 'error');
+                    }
+                });
+            },
+
+            leaveGroup() {
+                if(!window.confirm('Are you sure you want to leave this group? Any content you shared will remain accessible. You won\'t be able to rejoin for 24 hours.')) {
+                    return;
+                }
+
+                axios.post('/api/v0/groups/'+this.groupId+'/leave')
+                .then(res => {
+                    this.tab = 'feed';
+                    history.pushState('', null, this.group.url);
+                    this.feed = [];
+                    this.isMember = false;
+                    this.isAdmin = false;
+                    this.group.self.role = null;
+                    this.group.self.is_member = false;
+                });
+            },
+
+            pushNewStatus(status) {
+                this.feed.unshift(status);
+            },
+
+            commentFocus(index) {
+                let status = this.feed[index];
+                status.showCommentDrawer = true;
+            },
+
+            statusDelete(index) {
+                this.feed.splice(index, 1);
+            },
+
+            infiniteFeed($state) {
+                if(this.feed.length < 3) {
+                    $state.complete();
+                    return;
+                }
+                let apiUrl = '/api/v0/groups/' + this.groupId + '/feed';
+                axios.get(apiUrl, {
+                    params: {
+                        limit: 6,
+                        max_id: this.maxId
+                    },
+                }).then(res => {
+                    if (res.data.length) {
+                        // let self = this;
+                        // data.forEach(d => {
+                        //  if(self.ids.indexOf(d.id) == -1) {
+                        //      if(self.maxId >= d.id) {
+                        //          self.maxId = d.id;
+                        //      }
+                        //      self.ids.push(d.id);
+                        //      self.feed.push(d);
+                        //  }
+                        // });
+                        let posts = res.data.filter(p => this.ids.indexOf(p.id) == -1);
+                        this.maxId = posts[posts.length - 1].id;
+                        this.feed.push(...posts);
+                        this.ids.push(...posts.map(p => p.id));
+                        setTimeout(() => {
+                            this.initObservers();
+                        }, 1000);
+                        $state.loaded();
+                    } else {
+                        $state.complete();
+                    }
+                });
+            },
+
+            decrementModCounter(amount) {
+                let count = this.atabs.moderation_count;
+                if(count == 0) {
+                    return;
+                }
+                this.atabs.moderation_count = (count - amount);
+            },
+
+            setModCounter(amount) {
+                this.atabs.moderation_count = amount;
+            },
+
+            decrementJoinRequestCount(amount = 1) {
+                let count = this.atabs.request_count;
+                this.atabs.request_count = (count - amount)
+            },
+
+            incrementMemberCount() {
+                let count = this.group.member_count;
+                this.group.member_count = (count + 1);
+            },
+
+            copyLink() {
+                window.App.util.clipboard(this.group.url);
+                this.$bvToast.toast(`Succesfully copied group url to clipboard`, {
+                    title: 'Success',
+                    variant: 'success',
+                    autoHideDelay: 5000
+                });
+            },
+
+            reportGroup() {
+                swal('Report Group', 'Are you sure you want to report this group?')
+                .then(res => {
+                    if(res) {
+                        location.href = `/i/report?id=${this.group.id}&type=group`;
+                    }
+                });
+            },
+
+            showSearchModal() {
+                event.currentTarget.blur();
+                this.$refs.searchModal.open();
+            },
+
+            showInviteModal() {
+                event.currentTarget.blur();
+                this.$refs.inviteModal.open();
+            },
+
+            showLikesModal(index) {
+                this.likesId = this.feed[index].id;
+                axios.get('/api/v0/groups/'+this.groupId+'/likes/'+this.likesId)
+                .then(res => {
+                    this.likes = res.data;
+                    this.likesPage++;
+                    this.$refs.likeBox.show();
+                });
+            },
+
+            infiniteLikesHandler($state) {
+                if(this.likes.length < 3) {
+                    $state.complete();
+                    return;
+                }
+                axios.get('/api/v0/groups/'+this.groupId+'/likes/'+this.likesId, {
+                    params: {
+                        page: this.likesPage,
+                    },
+                }).then(res => {
+                    if (res.data.length > 0) {
+                        this.likes.push(...res.data);
+                        this.likesPage++;
+                        if(res.data.length != 10) {
+                            $state.complete();
+                        } else {
+                            $state.loaded();
+                        }
+                    } else {
+                        $state.complete();
+                    }
+                });
+            },
+        }
+    }
+</script>
+
+<style lang="scss">
+    .group-feed-component {
+        &-header {
+            display: flex;
+            justify-content: space-between;
+            align-items: flex-end;
+            padding: 1rem 0;
+            background-color: #fff;
+
+            .cta-btn {
+                width: 190px;
+            }
+        }
+
+        &-menu {
+            display: flex;
+            justify-content: space-between;
+            align-items: center;
+            padding: 0;
+
+            &-nav {
+                .nav-item {
+                    .nav-link {
+                        padding-top: 1rem;
+                        padding-bottom: 1rem;
+                        color: #6c757d;
+
+                        &.active {
+                            color: #2c78bf;
+                            border-bottom: 2px solid #2c78bf;
+                        }
+                    }
+                }
+
+                &:not(last-child) {
+                    .nav-item {
+                        margin-right: 14px;
+                    }
+                }
+            }
+        }
+
+        &-body {
+            min-height: 40vh;
+        }
+
+        .member-label {
+            padding: 2px 5px;
+            font-size: 12px;
+            color: rgba(75, 119, 190, 1);
+            background:rgba(137, 196, 244, 0.2);
+            border:1px solid rgba(137, 196, 244, 0.3);
+            font-weight:400;
+            text-transform: capitalize;
+        }
+
+        .dropdown-item {
+            font-weight: 600;
+        }
+
+        .remote-label {
+            padding: 2px 5px;
+            font-size: 12px;
+            color: #B45309;
+            background: #FEF3C7;
+            border: 1px solid #FCD34D;
+            font-weight: 400;
+            text-transform: capitalize;
+        }
+    }
+</style>

+ 33 - 0
resources/assets/components/GroupPost.vue

@@ -0,0 +1,33 @@
+<template>
+    <div class="group-status-permalink-component">
+        <div class="row border-bottom m-0 p-0">
+            <sidebar />
+
+            <div class="col-12 col-md-9 px-md-0">
+                <group-feed :group-id="gid" :permalinkMode="true" :permalinkId="sid" />
+            </div>
+        </div>
+    </div>
+</template>
+
+<script type="text/javascript">
+    import GroupFeed from '@/groups/GroupFeed.vue';
+    import SidebarComponent from '@/groups/sections/Sidebar.vue';
+
+    export default {
+        props: {
+            gid: {
+                type: String
+            },
+
+            sid: {
+                type: String
+            }
+        },
+
+        components: {
+            "group-feed": GroupFeed,
+            "sidebar": SidebarComponent
+        }
+    }
+</script>

+ 443 - 0
resources/assets/components/GroupProfile.vue

@@ -0,0 +1,443 @@
+<template>
+    <div class="group-profile-component w-100 h-100">
+        <div v-if="!loaded" class="w-100 h-100">
+            <div class="d-flex justify-content-center align-items-center mt-5">
+                <div class="spinner-border" role="status">
+                  <span class="sr-only">Loading...</span>
+                </div>
+            </div>
+        </div>
+        <template v-else>
+            <div class="bg-white mb-3 border-bottom">
+                <div class="container-xl header">
+                    <div class="header-jumbotron"></div>
+
+                    <div class="header-profile-card">
+                        <img :src="profile.avatar" class="avatar" onerror="this.onerror=null;this.src='/storage/avatars/default.png?v=2'">
+                        <p class="name">
+                            {{ profile.display_name }}
+                        </p>
+                        <p class="username text-muted">
+                            <span v-if="profile.local">&commat;{{ profile.username }}</span>
+                            <span v-else>{{ profile.acct }}</span>
+
+                            <span v-if="profile.is_admin" class="text-danger ml-1" title="Site administrator" data-toggle="tooltip" data-placement="bottom"><i class="far fa-users-crown"></i></span>
+                        </p>
+                    </div>
+                    <!-- <hr> -->
+                    <div class="header-navbar">
+                        <div></div>
+
+                        <div>
+                            <a
+                                v-if="currentProfile.id === profile.id"
+                                class="btn btn-light font-weight-bold mr-2"
+                                href="/settings/home">
+                                <i class="fas fa-edit mr-1"></i> Edit Profile
+                            </a>
+
+                            <a
+                                v-if="relationship.following"
+                                class="btn btn-primary font-weight-bold mr-2"
+                                :href="profile.url">
+                                <i class="far fa-comment-alt-dots mr-1"></i> Message
+                            </a>
+
+                            <a
+                                v-if="relationship.following"
+                                class="btn btn-light font-weight-bold mr-2"
+                                :href="profile.url">
+                                <i class="fas fa-user-check mr-1"></i> {{ relationship.followed_by ? 'Friends' : 'Following' }}
+                            </a>
+
+                            <a
+                                v-if="!relationship.following"
+                                class="btn btn-light font-weight-bold mr-2"
+                                :href="profile.url">
+                                <i class="fas fa-user mr-1"></i> View Main Profile
+                            </a>
+
+                            <div class="dropdown">
+                                <button class="btn btn-light font-weight-bold dropdown-toggle" type="button" id="amenu" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
+                                    <i class="fas fa-ellipsis-h"></i>
+                                </button>
+                                <div class="dropdown-menu dropdown-menu-right" aria-labelledby="amenu">
+                                    <a v-if="currentProfile.id != profile.id" class="dropdown-item font-weight-bold" :href="`/i/report?type=user&id=${profile.id}`">Report</a>
+                                    <a v-if="currentProfile.id == profile.id" class="dropdown-item font-weight-bold" href="#">Leave Group</a>
+                                </div>
+                            </div>
+                        </div>
+                    </div>
+                </div>
+            </div>
+            <div class="w-100 h-100 group-profile-feed">
+                <div class="container-xl">
+                    <div class="row">
+                        <div class="col-12 col-md-5">
+                            <div class="card card-body shadow-sm infolet">
+                                <h5 class="font-weight-bold mb-3">Intro</h5>
+                                <div v-if="!profile.local" class="media mb-3 align-items-center">
+                                    <div class="media-icon">
+                                        <i class="far fa-globe" title="User is from a remote server" data-toggle="tooltip" data-placement="bottom"></i>
+                                    </div>
+                                    <div class="media-body">
+                                        Remote member from <strong>{{ profile.acct.split('@')[1] }}</strong>
+                                    </div>
+                                </div>
+                                <!-- <div v-if="profile.note.length" class="media mb-3 align-items-center">
+                                    <i class="far fa-book-user fa-lg text-lighter mr-3" title="User bio" data-toggle="tooltip" data-placement="bottom"></i>
+                                    <div class="media-body">
+                                        <span v-html="profile.note"></span>
+                                    </div>
+                                </div> -->
+                                <div class="media align-items-center">
+                                    <div class="media-icon">
+                                        <i class="fas fa-users" title="User joined group on this date" data-toggle="tooltip" data-placement="bottom"></i>
+                                    </div>
+                                    <div class="media-body">
+                                        {{ roleTitle }} of <strong>{{ group.name }}</strong> since {{ formatJoinedDate(profile.group?.joined) }}
+                                    </div>
+                                </div>
+                            </div>
+
+                            <div v-if="canIntersect" class="card card-body shadow-sm infolet">
+                                <h5 class="font-weight-bold mb-3">Things in Common</h5>
+
+                                <div v-if="commonIntersects.friends.length" class="media mb-3 align-items-center" v-once>
+                                    <div class="media-icon">
+                                        <i class="far fa-user-friends"></i>
+                                    </div>
+                                    <div class="media-body">
+                                        {{ commonIntersects.friends_count }} mutual friend<span v-if="commonIntersects.friends.length > 1">s</span> including
+                                        <span v-for="(friend, index) in commonIntersects.friends"><a :href="friend.url" class="text-dark font-weight-bold">{{ friend.acct }}</a><span v-if="commonIntersects.friends.length > index + 1">, </span><span v-else> </span></span>
+                                        <!-- <a href="#" class="text-dark font-weight-bold">dansup</a>, <a href="#" class="text-dark font-weight-bold">admin</a> and 1 other -->
+                                    </div>
+                                </div>
+
+                                <!-- <div class="media mb-3 align-items-center">
+                                    <div class="media-icon">
+                                        <i class="fas fa-home"></i>
+                                    </div>
+                                    <div class="media-body">
+                                        Lives in <strong>Canada</strong>
+                                    </div>
+                                </div> -->
+
+                                <div v-if="commonIntersects.groups.length" class="media mb-3 align-items-center">
+                                    <div class="media-icon">
+                                        <i class="fas fa-users"></i>
+                                    </div>
+                                    <div class="media-body">
+                                        Also member of <a :href="commonIntersects.groups[0].url" class="text-dark font-weight-bold">{{ commonIntersects.groups[0].name }}</a> and {{ commonIntersects.groups_count }} other groups
+                                    </div>
+                                </div>
+
+                                <div v-if="commonIntersects.topics.length" class="media mb-0 align-items-center">
+                                    <div class="media-icon">
+                                        <i class="far fa-thumbs-up fa-lg text-lighter"></i>
+                                    </div>
+                                    <div class="media-body">
+                                        Also interested in topics containing
+                                        <span v-for="(topic, index) in commonIntersects.topics">
+                                            <span v-if="commonIntersects.topics.length - 1 == index"> and </span><a :href="topic.url" class="font-weight-bold text-dark">#{{ topic.name }}</a><span v-if="commonIntersects.topics.length > index + 2">, </span>
+                                        </span> hashtags
+                                    </div>
+                                </div>
+                            </div>
+                        </div>
+
+                        <div class="col-12 col-md-7">
+                            <div class="card card-body shadow-sm">
+                                <h5 class="font-weight-bold mb-0">Group Posts</h5>
+                            </div>
+
+                            <div v-if="feedEmpty" class="pt-5 text-center">
+                                <h5>No New Posts</h5>
+                                <p>{{ profile.username }} hasn't posted anything yet in <strong>{{ group.name }}</strong>.</p>
+
+                                <a :href="group.url" class="font-weight-bold">Go Back</a>
+                            </div>
+
+                            <div v-if="feedLoaded" class="mt-2">
+                                <group-status
+                                    v-for="(status, index) in feed"
+                                    :key="'gps:' + status.id"
+                                    :permalinkMode="true"
+                                    :showGroupChevron="true"
+                                    :group="group"
+                                    :prestatus="status"
+                                    :profile="profile"
+                                    :group-id="group.id" />
+
+                                <div v-if="feed.length >= 1" :distance="800">
+                                    <infinite-loading @infinite="infiniteFeed">
+                                        <div slot="no-more"></div>
+                                        <div slot="no-results"></div>
+                                    </infinite-loading>
+                                </div>
+                            </div>
+                        </div>
+                    </div>
+                </div>
+            </div>
+        </template>
+    </div>
+</template>
+
+<script type="text/javascript">
+    import GroupStatus from '@/groups/partials/GroupStatus.vue';
+
+    export default {
+        props: {
+            gid: {
+                type: String
+            },
+
+            pid: {
+                type: String
+            }
+        },
+
+        components: {
+            'group-status': GroupStatus
+        },
+
+        data() {
+            return {
+                loaded: false,
+                currentProfile: {},
+                roleTitle: 'Member',
+                group: {},
+                profile: {},
+                relationship: {
+                    following: false,
+                },
+                feed: [],
+                ids: [],
+                feedLoaded: false,
+                feedEmpty: false,
+                page: 1,
+                canIntersect: false,
+                commonIntersects: []
+            }
+        },
+
+        beforeMount() {
+            $('body').css('background-color', '#f0f2f5');
+        },
+
+        mounted() {
+            this.fetchGroup();
+            this.$nextTick(() => {
+                $('[data-toggle="tooltip"]').tooltip();
+            });
+        },
+
+        methods: {
+            fetchGroup() {
+                axios.get('/api/v0/groups/' + this.gid)
+                .then(res => {
+                    this.group = res.data;
+                })
+                .finally(() => {
+                    this.fetchSelfProfile();
+                })
+            },
+
+            fetchSelfProfile() {
+                axios.get('/api/pixelfed/v1/accounts/verify_credentials')
+                .then(res => {
+                    this.currentProfile = res.data;
+                })
+                .catch(err => {
+                    this.$router.push('/groups/' + this.gid)
+                })
+                .finally(() => {
+                    this.fetchProfile();
+                })
+
+                this.$nextTick(() => {
+                    $('[data-toggle="tooltip"]').tooltip();
+                });
+            },
+
+            fetchProfile() {
+                axios.get('/api/v0/groups/accounts/' + this.gid + '/' + this.pid)
+                .then(res => {
+                    this.profile = res.data;
+                    if(res.data.group.role == 'founder') {
+                        this.roleTitle = 'Admin';
+                    }
+                })
+
+                if(window._sharedData.user.id == this.pid) {
+                    this.fetchInitialFeed();
+                    return;
+                }
+
+                axios.get('/api/v1/accounts/relationships?id[]=' + this.pid)
+                .then(res => {
+                    this.relationship = res.data[0];
+                })
+                .finally(() => {
+                    this.fetchInitialFeed();
+                })
+            },
+            fetchInitialFeed() {
+                if(window._sharedData.user && window._sharedData.user.id != this.pid) {
+                    this.fetchCommonIntersections();
+                }
+                axios.get(`/api/v0/groups/${this.gid}/user/${this.pid}/feed`)
+                .then(res => {
+                    this.feed = res.data.filter(s => {
+                        return s.pf_type != 'reply:text' || s.account.id != this.profile.id;
+                    });
+                    this.feedLoaded = true;
+                    this.feedEmpty = this.feed.length == 0;
+                    this.page++;
+                    this.loaded = true;
+                })
+                .catch(err => {
+                    this.$router.push('/groups/' + this.gid);
+                    console.log(err)
+                });
+            },
+
+            infiniteFeed($state) {
+                if(this.feed.length == 0) {
+                    $state.complete();
+                    return;
+                }
+
+                axios.get(`/api/v0/groups/${this.group.id}/user/${this.profile.id}/feed`, {
+                    params: {
+                        page: this.page
+                    },
+                }).then(res => {
+                    if (res.data.length) {
+                        let data = res.data.filter(s => {
+                            return s.pf_type != 'reply:text' || s.account.id != this.profile.id;
+                        });
+                        let self = this;
+                        data.forEach(d => {
+                            if(self.ids.indexOf(d.id) == -1) {
+                                self.ids.push(d.id);
+                                self.feed.push(d);
+                            }
+                        });
+                        $state.loaded();
+                        this.page++;
+                    } else {
+                        $state.complete();
+                    }
+                });
+            },
+
+            fetchCommonIntersections() {
+                axios.get('/api/v0/groups/member/intersect/common', {
+                    params: {
+                        gid: this.gid,
+                        pid: this.pid
+                    }
+                }).then(res => {
+                    this.commonIntersects = res.data;
+                    this.canIntersect = res.data.groups.length || res.data.topics.length;
+                });
+
+            },
+
+            formatJoinedDate(ts) {
+                let date = new Date(ts);
+                let options = { year: 'numeric', month: 'long' };
+                let formatter = new Intl.DateTimeFormat('en-US', options);
+                return formatter.format(date);
+            }
+        }
+    }
+</script>
+
+<style lang="scss">
+    .group-profile-component {
+        background-color: #f0f2f5;
+
+        .header {
+            &-jumbotron {
+                background-color: #F3F4F6;
+                height: 320px;
+                border-bottom-left-radius: 20px;
+                border-bottom-right-radius: 20px;
+            }
+
+            &-profile-card {
+                display: flex;
+                flex-direction: column;
+                justify-content: center;
+                align-items: center;
+
+                .avatar {
+                    width: 170px;
+                    height: 170px;
+                    border-radius: 50%;
+                    margin-top: -150px;
+                    margin-bottom: 20px;
+                }
+
+                .name {
+                    font-size: 30px;
+                    line-height: 30px;
+                    font-weight: 700;
+                    text-align: center;
+                    margin-bottom: 6px;
+                }
+
+                .username {
+                    font-size: 16px;
+                    font-weight: 500;
+                    text-align: center;
+                }
+            }
+
+            &-navbar {
+                display: flex;
+                justify-content: space-between;
+                align-items: center;
+                height: 60px;
+                border-top: 1px solid #F3F4F6;
+
+                .dropdown {
+                    display: inline-block;
+
+                    &-toggle:after {
+                        display: none;
+                    }
+                }
+            }
+        }
+
+        .group-profile-feed {
+            min-height: 500px;
+        }
+
+        .infolet {
+            margin-bottom: 1rem;
+
+            .media {
+                &-icon {
+                    display: flex;
+                    justify-content: center;
+                    width: 30px;
+                    margin-right: 10px;
+
+                    i {
+                        font-size: 1.1rem;
+                        color: #D1D5DB !important;
+                    }
+                }
+            }
+        }
+
+        .btn-light {
+            border-color: #F3F4F6;
+        }
+    }
+</style>

+ 80 - 0
resources/assets/components/Groups.vue

@@ -0,0 +1,80 @@
+<template>
+    <div class="group-component">
+        <div v-if="tab === 'home'">
+            <groups-home />
+        </div>
+
+        <div v-if="tab === 'createGroup'">
+            <!-- <div class="col-12 group-component-hero">
+                <h3 class="font-weight-bold">Create Group</h3>
+                <button class="btn btn-outline-primary px-3 rounded-pill font-weight-bold" @click="switchTab('home')">Back to Groups</button>
+            </div> -->
+
+            <create-group />
+        </div>
+
+        <div v-if="tab === 'show'">
+            <group-feed :group-id="groupId" :path="path" />
+        </div>
+    </div>
+</template>
+
+<script type="text/javascript">
+    import GroupsHome from '@/groups/GroupsHome.vue';
+    import GroupFeed from '@/groups/GroupFeed.vue';
+    import CreateGroup from '@/groups/CreateGroup.vue';
+
+    export default {
+        props: {
+            groupId: {
+                type: String
+            },
+
+            path: {
+                type: String
+            }
+        },
+
+        data() {
+            return {
+                tab: 'home'
+            }
+        },
+
+        components: {
+            "groups-home": GroupsHome,
+            "create-group": CreateGroup,
+            "group-feed": GroupFeed,
+        },
+
+        mounted() {
+            if(this.groupId) {
+                this.tab = 'show';
+            }
+        },
+
+        methods: {
+            switchTab(newTab) {
+                this.tab = newTab;
+            }
+        }
+    }
+</script>
+
+<style lang="scss">
+    .group-component {
+        &-hero {
+            display: flex;
+            justify-content: space-between;
+            align-items: center;
+            padding: 1rem;
+            border: 1px solid #dee2e6;
+            border-top: 0;
+            background-color: #fff;
+
+            h3 {
+                margin-bottom: 0;
+            }
+        }
+    }
+</style>

+ 359 - 0
resources/assets/components/groups/CreateGroup.vue

@@ -0,0 +1,359 @@
+<template>
+	<div class="create-group-component col-12 col-md-9" style="height: 100vh - 51px !important;overflow:hidden">
+        <div v-if="!hide" class="row h-100 bg-lighter">
+			<div class="col-12 col-md-8 border-left">
+
+                <div class="bg-dark p-5 mx-n3">
+                    <p class="h1 font-weight-bold text-light mb-2">Create Group</p>
+                    <p class="text-lighter mb-0">Create a new federated Group that is compatible with other Pixelfed and Lemmy servers</p>
+                </div>
+				<div class="px-2 mb-5">
+					<div class="mt-4">
+                        <text-input
+                            label="Group Name"
+                            :value="name"
+                            :hasLimit="true"
+                            :maxLimit="limit.name.max"
+                            placeholder="Add your group name"
+                            helpText="Alphanumeric characters only, you can change this later."
+                            :largeInput="true"
+                            @update="handleUpdate('name', $event)"
+                        />
+
+                        <hr>
+
+                        <select-input
+                            label="Group Type"
+                            :value="membership"
+                            :categories="membershipCategories"
+                            placeholder="Select a type"
+                            helpText="Select the membership type, you can change this later."
+                            @update="handleUpdate('membership', $event)"
+                        />
+
+                        <hr>
+
+                        <select-input
+                            label="Group Category"
+                            :value="category"
+                            :categories="categories"
+                            placeholder="Select a category"
+                            helpText="Choose the most relevant category to improve discovery and visibility"
+                            @update="handleUpdate('category', $event)"
+                        />
+
+						<hr>
+
+                        <text-area-input
+                            label="Group Description"
+                            :value="description"
+                            :hasLimit="true"
+                            :maxLimit="limit.description.max"
+                            placeholder="Describe your groups purpose in a few words"
+                            helpText="Describe your groups purpose in a few words, you can change this later."
+                            @update="handleUpdate('description', $event)"
+                        />
+
+                        <hr>
+
+                        <checkbox-input
+                            label="Adult Content"
+                            inputText="Allow Adult Content"
+                            :value="configuration.adult"
+                            helpText="Groups that allow adult content should enable this or risk suspension or deletion by instance admins. Illegal content is prohibited. You can change this later."
+                        />
+
+                        <hr>
+
+                        <checkbox-input
+                            label=""
+                            inputText="I agree to the the Community Guidelines and Terms of Use and will administrate this group according to the rules set by this server. I understand that failure to abide by these terms may lead to the suspension of this group, and my account."
+                            :value="hasConfirmed"
+                            :strongText="false"
+                            @update="handleUpdate('hasConfirmed', $event)"
+                        />
+
+						<!-- <div class="form-group row">
+							<div class="col-sm-10 offset-sm-2">
+								<div class="form-check">
+									<input class="form-check-input" type="checkbox" id="gridCheck1" v-model="hasConfirmed">
+									<label class="form-check-label" for="gridCheck1">
+										I agree to the <a href="#">Community Guidelines</a> and <a href="#">Terms of Use</a>
+									</label>
+								</div>
+							</div>
+						</div> -->
+
+                        <button
+                            class="btn btn-primary btn-block font-weight-bold rounded-pill mt-4"
+                            @click="createGroup"
+                            :disabled="!hasConfirmed">
+                            Create Group
+                        </button>
+					</div>
+				</div>
+			</div>
+			<div class="col-12 col-md-4 bg-white">
+				<!-- <div>
+					<button
+						v-if="page <= 4"
+						class="btn btn-primary btn-block font-weight-bold rounded-pill mt-4"
+						@click="nextPage"
+						:disabled="!membership">
+						Next
+					</button>
+
+					<button
+						v-if="page == 5"
+						class="btn btn-primary btn-block font-weight-bold rounded-pill mt-4"
+						@click="createGroup"
+						:disabled="!hasConfirmed">
+						Create Group
+					</button>
+
+					<button
+						v-if="page >= 2"
+						class="btn btn-light btn-block font-weight-bold rounded-pill border mt-4"
+						@click="prevPage">
+						Back
+					</button>
+
+					<div v-if="maxPage > 2" class="mt-4">
+						<p v-if="name && name.length">
+							<span class="text-lighter">Name:</span>
+							<span class="font-weight-bold">{{ name }}</span>
+						</p>
+
+						<p v-if="description && description.length">
+							<span class="text-lighter">Description:</span>
+							<span>{{ description }}</span>
+						</p>
+
+						<p v-if="membership && membership.length">
+							<span class="text-lighter">Membership:</span>
+							<span class="text-capitalize">{{ membership }}</span>
+						</p>
+
+						<p v-if="category && category.length">
+							<span class="text-lighter">Category:</span>
+							<span class="text-capitalize">{{ category }}</span>
+						</p>
+					</div>
+				</div> -->
+			</div>
+		</div>
+     </div>
+</template>
+
+<script type="text/javascript">
+    import TextInput from '@/groups/partials/CreateForm/TextInput.vue';
+    import SelectInput from '@/groups/partials/CreateForm/SelectInput.vue';
+    import TextAreaInput from '@/groups/partials/CreateForm/TextAreaInput.vue';
+    import CheckboxInput from '@/groups/partials/CreateForm/CheckboxInput.vue';
+
+	export default {
+        components: {
+            "text-input": TextInput,
+            "select-input": SelectInput,
+            "text-area-input": TextAreaInput,
+            "checkbox-input": CheckboxInput,
+        },
+
+		data() {
+			return {
+				hide: true,
+				name: null,
+				page: 1,
+				maxPage: 1,
+				description: null,
+				membership: "placeholder",
+				submitting: false,
+				categories: [],
+				category: "",
+				limit: {
+					name: {
+						max: 60
+					},
+					description: {
+						max: 500
+					}
+				},
+				configuration: {
+					types: {
+						text: true,
+						photos: true,
+						videos: true,
+						// events: false,
+						polls: true
+					},
+					federation: true,
+					adult: false,
+					discoverable: false,
+					autospam: false,
+					dms: false,
+					slowjoin: {
+						enabled: false,
+						age: 90,
+						limit: {
+							post: 1,
+							comment: 20,
+							threads: 2,
+							likes: 5,
+							hashtags: 5,
+							mentions: 1,
+							autolinks: 1
+						}
+					}
+				},
+				hasConfirmed: false,
+				permissionChecked: false,
+                membershipCategories: [
+                    { key: 'Public', value: 'public' },
+                    { key: 'Private', value: 'private' },
+                    { key: 'Local', value: 'local' },
+                ],
+			}
+		},
+
+		mounted() {
+			this.permissionCheck();
+			this.fetchCategories();
+		},
+
+		methods: {
+			permissionCheck() {
+				axios.post('/api/v0/groups/permission/create')
+				.then(res => {
+					if(res.data.permission == false) {
+						swal('Limit reached', 'You cannot create any more groups', 'error');
+						this.hide = true;
+					} else {
+						this.hide = false;
+					}
+					this.permissionChecked = true;
+				});
+			},
+
+			submit($event) {
+				$event.preventDefault();
+				this.submitting = true;
+
+				axios.post('/api/v0/groups/create', {
+					name: this.name,
+					description: this.description,
+					membership: this.membership
+				}).then(res => {
+					console.log(res.data);
+					window.location.href = res.data.url;
+				}).catch(err => {
+					console.log(err.response);
+
+				})
+			},
+
+			fetchCategories() {
+				axios.get('/api/v0/groups/categories/list')
+				.then(res => {
+					this.categories = res.data.map(c => {
+                        return {
+                            key: c,
+                            value: c
+                        }
+                    });
+				})
+			},
+
+			createGroup() {
+				axios.post('/api/v0/groups/create', {
+					name: this.name,
+					description: this.description,
+					membership: this.membership,
+					configuration: this.configuration
+				})
+				.then(res => {
+					console.log(res.data);
+					location.href = res.data.url;
+				})
+			},
+
+            handleUpdate(key, val) {
+                this[key] = val;
+            }
+		}
+	}
+</script>
+
+<style lang="scss">
+	.create-group-component {
+		.submit-button {
+			width: 130px;
+		}
+
+		.multistep {
+			margin-top: 30px;
+			margin-bottom: 30px;
+			overflow: hidden;
+			counter-reset: step;
+			text-align: center;
+			padding-left: 0;
+		}
+
+		.multistep li {
+			list-style-type: none;
+			text-transform: uppercase;
+			font-size: 9px;
+			font-weight: 700;
+			width: 20%;
+			float: left;
+			position: relative;
+			color: #B8C2CC;
+		}
+
+		.multistep li.active {
+			color: #000;
+		}
+
+		.multistep li:before {
+			content: counter(step);
+			counter-increment: step;
+			width: 24px;
+			height: 24px;
+			line-height: 26px;
+			display: block;
+			font-size: 12px;
+			color: #B8C2CC;
+			background: #F3F4F6;
+			border-radius: 25px;
+			margin: 0 auto 10px auto;
+			transition: background 400ms;
+		}
+
+		.multistep li:after {
+			content: '';
+			width: 100%;
+			height: 2px;
+			background: #dee2e6;
+			position: absolute;
+			left: -50%;
+			top: 11px;
+			z-index: -1;
+			transition: background 400ms;
+		}
+
+		.multistep li:first-child:after {
+			content: none;
+		}
+
+		.multistep li.active:before,
+		.multistep li.active:after {
+			background: #2c78bf;
+			color: white;
+			transition: background 400ms;
+		}
+
+		.col-form-label {
+			font-weight: 600;
+			text-align: right;
+		}
+	}
+</style>

+ 989 - 0
resources/assets/components/groups/GroupFeed.vue

@@ -0,0 +1,989 @@
+<template>
+	<div class="group-feed-component">
+		<div v-if="!initalLoad">
+			<p class="text-center mt-5 pt-5 font-weight-bold">Loading...</p>
+		</div>
+
+		<div v-else>
+			<div class="mb-3 border-bottom">
+				<div class="container-xl">
+                    <group-banner
+                        :group="group"
+                    />
+                    <group-header-details
+                        :group="group"
+                        :isAdmin="isAdmin"
+                        :isMember="isMember"
+                        @refresh="handleRefresh"
+                    />
+                    <group-nav-tabs
+                        :group="group"
+                        :isAdmin="isAdmin"
+                        :isMember="isMember"
+                        :atabs="atabs"
+                    />
+				</div>
+			</div>
+
+			<div class="container-xl group-feed-component-body">
+				<div class="row mb-5">
+					<div class="col-12 col-md-7 mt-3">
+						<div v-if="group.self.is_member">
+							<group-compose
+								v-if="initalLoad"
+								:profile="profile"
+								:group-id="groupId"
+								v-on:new-status="pushNewStatus" />
+
+							<div v-if="feed.length == 0" class="mt-3">
+								<div class="card card-body shadow-none border d-flex align-items-center justify-content-center" style="height: 200px;">
+									<p class="font-weight-bold mb-0">No posts yet!</p>
+								</div>
+							</div>
+
+							<div v-else class="group-timeline">
+								<p class="font-weight-bold mb-1">Recent Posts</p>
+
+
+								<!-- <div class="status-card-component shadow-sm mb-3 rounded-lg">
+								    <div class="card shadow-none border rounded-0">
+								        <div class="card-body pb-0">
+								            <div class="media">
+								                <img src="https://pixelfed.test/storage/avatars/321493203255693312/5a6nqo.jpg?v=2" width="42px" height="42px" onerror="this.onerror=null;this.src='/storage/avatars/default.png?v=2'" alt="avatar" class="rounded-circle box-shadow mr-2">
+
+								                <div class="media-body">
+								                    <div class="pl-2 d-flex align-items-top">
+								                        <div>
+								                            <p class="mb-0">
+								                                <a href="https://pixelfed.test/dansup" class="username font-weight-bold text-dark text-decoration-none text-break">
+								                                    dansup
+								                                </a>
+								                            </p>
+
+								                            <p class="mb-0">
+								                                <a href="https://pixelfed.test/groups/328821658771132416/329186991407239168" class="font-weight-light text-muted small">13h</a>
+
+								                                <span class="text-lighter" style="padding-left: 2px; padding-right: 2px;">·</span>
+
+								                                <span class="text-muted small">
+								                                    <i class="fas fa-globe"></i>
+								                                </span>
+								                            </p>
+								                        </div>
+
+								                        <span class="text-right" style="flex-grow: 1;">
+								                            <button type="button" class="btn btn-link text-dark py-0">
+								                                <span class="fas fa-ellipsis-h text-lighter"></span>
+
+								                                <span class="sr-only">Post Menu</span>
+								                            </button>
+								                        </span>
+								                    </div>
+								                </div>
+								            </div>
+
+								            <div>
+								                <div>
+								                    <div class="">
+								                    	<p class="pt-2 text-break" style="font-size: 15px;">
+								                            Made some improvements!
+								                        </p>
+
+								                        <div class="my-3 row px-0 mx-0 card card-body my-0 py-0 border shadow-none">
+								                        		<img src="https://opengraph.githubassets.com/f66d0f7bf17df4a45382b83c1ffde2f25e3d700f9d87ab8c9ec2029c3a1e16b6/pixelfed/pixelfed/pull/2865" class="img-fluid">
+								                        	<div class="bg-light px-3 pt-2 pb-3">
+								                        		<p class="text-muted mb-0 small">GITHUB.COM</p>
+								                        		<p class="mb-0" style="font-size: 16px;font-weight:500;">Update LikeController, add UndoLikePipeline and federate Undo Like ac… by dansup · Pull Request #2865 · pixelfed/pixelfed</p>
+								                        		<p class="mb-0 text-muted" style="font-size:14px;line-height:15px;">…tivities</p>
+								                        	</div>
+								                        </div>
+
+									                    <div class="border-top my-0">
+									                        <div class="d-flex justify-content-between py-2 px-4">
+									                            <button class="btn btn-link py-0 text-decoration-none text-muted">
+									                                <i class="far fa-heart mr-1"></i>
+									                                Like
+									                            </button>
+
+									                            <div>
+									                                <i class="far fa-comment cursor-pointer text-muted mr-1"></i>
+									                                Comment
+									                            </div>
+
+									                            <div>
+									                                <i class="fas fa-external-link-alt cursor-pointer text-muted mr-1"></i>
+									                                Share
+									                            </div>
+									                        </div>
+									                    </div>
+									                </div>
+									            </div>
+								        	</div>
+									    </div>
+									</div>
+								</div> -->
+
+								<!-- OGP -->
+								<!-- <div class="status-card-component shadow-sm mb-3 rounded-lg">
+								    <div class="card shadow-none border rounded-0">
+								        <div class="card-body pb-0">
+								            <div class="media">
+								                <img src="https://pixelfed.test/storage/avatars/321493203255693312/5a6nqo.jpg?v=2" width="42px" height="42px" onerror="this.onerror=null;this.src='/storage/avatars/default.png?v=2'" alt="avatar" class="rounded-circle box-shadow mr-2">
+
+								                <div class="media-body">
+								                    <div class="pl-2 d-flex align-items-top">
+								                        <div>
+								                            <p class="mb-0">
+								                                <a href="https://pixelfed.test/dansup" class="username font-weight-bold text-dark text-decoration-none text-break">
+								                                    dansup
+								                                </a>
+								                            </p>
+
+								                            <p class="mb-0">
+								                                <a href="https://pixelfed.test/groups/328821658771132416/329186991407239168" class="font-weight-light text-muted small">13h</a>
+
+								                                <span class="text-lighter" style="padding-left: 2px; padding-right: 2px;">·</span>
+
+								                                <span class="text-muted small">
+								                                    <i class="fas fa-globe"></i>
+								                                </span>
+								                            </p>
+								                        </div>
+
+								                        <span class="text-right" style="flex-grow: 1;">
+								                            <button type="button" class="btn btn-link text-dark py-0">
+								                                <span class="fas fa-ellipsis-h text-lighter"></span>
+
+								                                <span class="sr-only">Post Menu</span>
+								                            </button>
+								                        </span>
+								                    </div>
+								                </div>
+								            </div>
+
+								            <div>
+								                <div>
+								                    <div class="">
+								                        <div class="my-3 row px-0 mx-0 card card-body my-0 py-0 border shadow-none">
+								                        		<img src="https://www.ctvnews.ca/polopoly_fs/1.5533318.1628281952!/httpImage/image.jpg_gen/derivatives/landscape_620/image.jpg" class="img-fluid">
+								                        	<div class="bg-light px-3 pt-2 pb-3">
+								                        		<p class="text-muted mb-0 small">CTVNEWS.CA</p>
+								                        		<p class="mb-0" style="font-size: 16px;font-weight:500;">No charges against Alberta man who fatally shot home intruder: RCMP</p>
+								                        		<p class="mb-0 text-muted" style="font-size:14px;line-height:15px;">No charges will be laid against an Alberta man who shot and killed an intruder after being beaten with a baseball bat, RCMP announced Friday.</p>
+								                        	</div>
+								                        </div>
+
+									                    <div class="border-top my-0">
+									                        <div class="d-flex justify-content-between py-2 px-4">
+									                            <button class="btn btn-link py-0 text-decoration-none text-muted">
+									                                <i class="far fa-heart mr-1"></i>
+									                                Like
+									                            </button>
+
+									                            <div>
+									                                <i class="far fa-comment cursor-pointer text-muted mr-1"></i>
+									                                Comment
+									                            </div>
+
+									                            <div>
+									                                <i class="fas fa-external-link-alt cursor-pointer text-muted mr-1"></i>
+									                                Share
+									                            </div>
+									                        </div>
+									                    </div>
+									                </div>
+									            </div>
+								        	</div>
+									    </div>
+									</div>
+								</div> -->
+
+								<!-- SOUNDCLOUD -->
+								<!-- <div class="status-card-component shadow-sm mb-3 rounded-lg">
+								    <div class="card shadow-none border rounded-0">
+								        <div class="card-body pb-0">
+								            <div class="media">
+								                <img src="https://pixelfed.test/storage/avatars/321493203255693312/5a6nqo.jpg?v=2" width="42px" height="42px" onerror="this.onerror=null;this.src='/storage/avatars/default.png?v=2'" alt="avatar" class="rounded-circle box-shadow mr-2">
+
+								                <div class="media-body">
+								                    <div class="pl-2 d-flex align-items-top">
+								                        <div>
+								                            <p class="mb-0">
+								                                <a href="https://pixelfed.test/dansup" class="username font-weight-bold text-dark text-decoration-none text-break">
+								                                    dansup
+								                                </a>
+								                            </p>
+
+								                            <p class="mb-0">
+								                                <a href="https://pixelfed.test/groups/328821658771132416/329186991407239168" class="font-weight-light text-muted small">13h</a>
+
+								                                <span class="text-lighter" style="padding-left: 2px; padding-right: 2px;">·</span>
+
+								                                <span class="text-muted small">
+								                                    <i class="fas fa-globe"></i>
+								                                </span>
+								                            </p>
+								                        </div>
+
+								                        <span class="text-right" style="flex-grow: 1;">
+								                            <button type="button" class="btn btn-link text-dark py-0">
+								                                <span class="fas fa-ellipsis-h text-lighter"></span>
+
+								                                <span class="sr-only">Post Menu</span>
+								                            </button>
+								                        </span>
+								                    </div>
+								                </div>
+								            </div>
+
+								            <div>
+								                <div>
+								                    <div class="">
+								                        <p class="pt-2 text-break" style="font-size: 15px;">
+								                            What does everyone think??
+								                        </p>
+
+								                        <div class="my-3 row p-0 m-0">
+								                        	<iframe width="100%" height="166" scrolling="no" frameborder="no" allow="autoplay" src="https://w.soundcloud.com/player/?url=https%3A//api.soundcloud.com/tracks/34019569&color=0066cc"></iframe><div style="font-size: 10px; color: #cccccc;line-break: anywhere;word-break: normal;overflow: hidden;white-space: nowrap;text-overflow: ellipsis; font-family: Interstate,Lucida Grande,Lucida Sans Unicode,Lucida Sans,Garuda,Verdana,Tahoma,sans-serif;font-weight: 100;"><a href="https://soundcloud.com/the-bugle" title="The Bugle" target="_blank" style="color: #cccccc; text-decoration: none;">The Bugle</a> · <a href="https://soundcloud.com/the-bugle/bugle-179-playas-gon-play" title="Bugle 179 - Playas gon play" target="_blank" style="color: #cccccc; text-decoration: none;">Bugle 179 - Playas gon play</a></div>
+								                        </div>
+
+									                    <div class="border-top my-0">
+									                        <div class="d-flex justify-content-between py-2 px-4">
+									                            <button class="btn btn-link py-0 text-decoration-none text-muted">
+									                                <i class="far fa-heart mr-1"></i>
+									                                Like
+									                            </button>
+
+									                            <div>
+									                                <i class="far fa-comment cursor-pointer text-muted mr-1"></i>
+									                                Comment
+									                            </div>
+
+									                            <div>
+									                                <i class="fas fa-external-link-alt cursor-pointer text-muted mr-1"></i>
+									                                Share
+									                            </div>
+									                        </div>
+									                    </div>
+									                </div>
+									            </div>
+								        	</div>
+									    </div>
+									</div>
+								</div> -->
+
+								<!-- YOUTUBE -->
+								<!-- <div class="status-card-component shadow-sm mb-3 rounded-lg">
+								    <div class="card shadow-none border rounded-0">
+								        <div class="card-body pb-0">
+								            <div class="media">
+								                <img src="https://pixelfed.test/storage/avatars/321493203255693312/5a6nqo.jpg?v=2" width="42px" height="42px" onerror="this.onerror=null;this.src='/storage/avatars/default.png?v=2'" alt="avatar" class="rounded-circle box-shadow mr-2">
+
+								                <div class="media-body">
+								                    <div class="pl-2 d-flex align-items-top">
+								                        <div>
+								                            <p class="mb-0">
+								                                <a href="https://pixelfed.test/dansup" class="username font-weight-bold text-dark text-decoration-none text-break">
+								                                    dansup
+								                                </a>
+								                            </p>
+
+								                            <p class="mb-0">
+								                                <a href="https://pixelfed.test/groups/328821658771132416/329186991407239168" class="font-weight-light text-muted small">13h</a>
+
+								                                <span class="text-lighter" style="padding-left: 2px; padding-right: 2px;">·</span>
+
+								                                <span class="text-muted small">
+								                                    <i class="fas fa-globe"></i>
+								                                </span>
+								                            </p>
+								                        </div>
+
+								                        <span class="text-right" style="flex-grow: 1;">
+								                            <button type="button" class="btn btn-link text-dark py-0">
+								                                <span class="fas fa-ellipsis-h text-lighter"></span>
+
+								                                <span class="sr-only">Post Menu</span>
+								                            </button>
+								                        </span>
+								                    </div>
+								                </div>
+								            </div>
+
+								            <div>
+								                <div>
+								                    <div class="">
+								                        <p class="pt-2 text-break" style="font-size: 15px;">
+								                            What does everyone think??
+								                        </p>
+
+								                        <div class="my-3 row p-0 m-0">
+								                        	<iframe width="100%" height="315" src="https://www.youtube.com/embed/lH78Tb0r_f8" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>
+								                        </div>
+
+									                    <div class="border-top my-0">
+									                        <div class="d-flex justify-content-between py-2 px-4">
+									                            <button class="btn btn-link py-0 text-decoration-none text-muted">
+									                                <i class="far fa-heart mr-1"></i>
+									                                Like
+									                            </button>
+
+									                            <div>
+									                                <i class="far fa-comment cursor-pointer text-muted mr-1"></i>
+									                                Comment
+									                            </div>
+
+									                            <div>
+									                                <i class="fas fa-external-link-alt cursor-pointer text-muted mr-1"></i>
+									                                Share
+									                            </div>
+									                        </div>
+									                    </div>
+									                </div>
+									            </div>
+								        	</div>
+									    </div>
+									</div>
+								</div> -->
+
+								<!-- PHOTOS -->
+								<!-- <div class="status-card-component shadow-sm mb-3 rounded-lg">
+								    <div class="card shadow-none border rounded-0">
+								        <div class="card-body pb-0">
+								            <div class="media">
+								                <img src="https://pixelfed.test/storage/avatars/321493203255693312/5a6nqo.jpg?v=2" width="42px" height="42px" onerror="this.onerror=null;this.src='/storage/avatars/default.png?v=2'" alt="avatar" class="rounded-circle box-shadow mr-2">
+
+								                <div class="media-body">
+								                    <div class="pl-2 d-flex align-items-top">
+								                        <div>
+								                            <p class="mb-0">
+								                                <a href="https://pixelfed.test/dansup" class="username font-weight-bold text-dark text-decoration-none text-break">
+								                                    dansup
+								                                </a>
+								                            </p>
+
+								                            <p class="mb-0">
+								                                <a href="https://pixelfed.test/groups/328821658771132416/329186991407239168" class="font-weight-light text-muted small">13h</a>
+
+								                                <span class="text-lighter" style="padding-left: 2px; padding-right: 2px;">·</span>
+
+								                                <span class="text-muted small">
+								                                    <i class="fas fa-globe"></i>
+								                                </span>
+								                            </p>
+								                        </div>
+
+								                        <span class="text-right" style="flex-grow: 1;">
+								                            <button type="button" class="btn btn-link text-dark py-0">
+								                                <span class="fas fa-ellipsis-h text-lighter"></span>
+
+								                                <span class="sr-only">Post Menu</span>
+								                            </button>
+								                        </span>
+								                    </div>
+								                </div>
+								            </div>
+
+								            <div>
+								                <div>
+								                    <div class="">
+								                        <p class="pt-2 text-break" style="font-size: 15px;">
+								                            What does everyone think??
+								                        </p>
+
+								                        <div class="mb-1 row px-3">
+								                        	<div class="col px-0">
+								                        		<img src="https://pixelfed.test/img/sample-post.jpeg" class="img-fluid border rounded-lg">
+								                        	</div>
+								                        	<div class="col px-0">
+								                        		<img src="https://pixelfed.test/img/sample-post.jpeg" class="img-fluid border rounded-lg">
+								                        	</div>
+								                        </div>
+
+								                        <div class="mb-3 row px-3">
+								                        	<div class="col px-0">
+								                        		<img src="https://pixelfed.test/img/sample-post.jpeg" class="img-fluid border rounded-lg">
+								                        	</div>
+								                        	<div class="col px-0">
+								                        		<img src="https://pixelfed.test/img/sample-post.jpeg" class="img-fluid border rounded-lg">
+								                        	</div>
+								                        </div>
+
+									                    <div class="border-top my-0">
+									                        <div class="d-flex justify-content-between py-2 px-4">
+									                            <button class="btn btn-link py-0 text-decoration-none text-muted">
+									                                <i class="far fa-heart mr-1"></i>
+									                                Like
+									                            </button>
+
+									                            <div>
+									                                <i class="far fa-comment cursor-pointer text-muted mr-1"></i>
+									                                Comment
+									                            </div>
+
+									                            <div>
+									                                <i class="fas fa-external-link-alt cursor-pointer text-muted mr-1"></i>
+									                                Share
+									                            </div>
+									                        </div>
+									                    </div>
+									                </div>
+									            </div>
+								        	</div>
+									    </div>
+									</div>
+								</div> -->
+
+								<group-status
+									v-for="(status, index) in feed"
+									:key="'gs:' + status.id + index"
+									:prestatus="status"
+									:profile="profile"
+									:group-id="groupId"
+									v-on:comment-focus="commentFocus(index)"
+									v-on:status-delete="statusDelete(index)"
+									v-on:likes-modal="showLikesModal(index)" />
+
+								<b-modal
+									ref="likeBox"
+									size="sm"
+									centered
+									hide-footer
+									title="Likes"
+									body-class="list-group-flush p-0">
+									<div class="list-group py-1" style="max-height:300px;overflow-y:auto;">
+										<div
+											class="list-group-item border-top-0 border-left-0 border-right-0 py-2"
+											:class="{ 'border-bottom-0': index + 1 == likes.length }"
+											v-for="(user, index) in likes" :key="'modal_likes_'+index">
+											<div class="media align-items-center">
+												<a :href="user.url">
+													<img class="mr-3 rounded-circle box-shadow" :src="user.avatar" :alt="user.username + '’s avatar'" width="30px" onerror="this.onerror=null;this.src='/storage/avatars/default.jpg';">
+												</a>
+												<div class="media-body">
+													<p class="mb-0" style="font-size: 14px">
+														<a :href="user.url" class="font-weight-bold text-dark">
+															{{user.username}}
+														</a>
+													</p>
+													<p v-if="!user.local" class="text-muted mb-0 text-truncate mr-3" style="font-size: 14px" :title="user.acct" data-toggle="dropdown" data-placement="bottom">
+														<span class="font-weight-bold">{{user.acct.split('@')[0]}}</span><span class="text-lighter">&commat;{{user.acct.split('@')[1]}}</span>
+													</p>
+													<p v-else class="text-muted mb-0 text-truncate" style="font-size: 14px">
+														{{user.display_name}}
+													</p>
+												</div>
+											</div>
+										</div>
+										<infinite-loading @infinite="infiniteLikesHandler" :distance="800" spinner="spiral">
+											<div slot="no-more"></div>
+											<div slot="no-results"></div>
+										</infinite-loading>
+									</div>
+								</b-modal>
+
+								<div v-if="feed.length > 2" :distance="800">
+									<infinite-loading @infinite="infiniteFeed">
+										<div slot="no-more"></div>
+										<div slot="no-results"></div>
+									</infinite-loading>
+								</div>
+							</div>
+						</div>
+
+						<div v-else>
+							<div class="card card-body mt-3 shadow-none border d-flex align-items-center justify-content-center" style="height: 100px;">
+								<p class="lead mb-0">Join to participate in this group.</p>
+							</div>
+						</div>
+					</div>
+
+					<div class="col-12 col-md-5">
+						<group-info-card :group="group" />
+					</div>
+                </div>
+				<search-modal ref="searchModal" :group="group" :profile="profile" />
+				<invite-modal ref="inviteModal" :group="group" :profile="profile" />
+			</div>
+		</div>
+	</div>
+</template>
+
+<script type="text/javascript">
+	import StatusCard from '~/partials/StatusCard.vue';
+	import GroupCompose from './partials/GroupCompose.vue';
+	import GroupStatus from './partials/GroupStatus.vue';
+	import GroupInfoCard from './partials/GroupInfoCard.vue';
+	import LeaveGroup from './partials/LeaveGroup.vue';
+	import SearchModal from './partials/GroupSearchModal.vue';
+	import InviteModal from './partials/GroupInviteModal.vue';
+    import GroupBanner from '@/groups/partials/Page/GroupBanner.vue';
+    import GroupNavTabs from '@/groups/partials/Page/GroupNavTabs.vue';
+    import GroupHeaderDetails from '@/groups/partials/Page/GroupHeaderDetails.vue';
+
+	export default {
+		props: {
+			groupId: {
+				type: String
+			},
+
+			path: {
+				type: String
+			},
+
+			permalinkMode: {
+				type: Boolean,
+				default: false
+			},
+
+			permalinkId: {
+				type: String,
+			}
+		},
+
+		components: {
+			'status-card': StatusCard,
+			'group-status': GroupStatus,
+			'group-compose': GroupCompose,
+			'group-info-card': GroupInfoCard,
+			'leave-group': LeaveGroup,
+			'search-modal': SearchModal,
+			'invite-modal': InviteModal,
+            'group-banner': GroupBanner,
+            'group-header-details': GroupHeaderDetails,
+            'group-nav-tabs': GroupNavTabs,
+		},
+
+		data() {
+			return {
+				initalLoad: false,
+				profile: undefined,
+				group: {},
+				isMember: false,
+				isAdmin: false,
+				tab: 'feed',
+				requestingMembership: false,
+				composeText: null,
+				feed: [],
+				ids: [],
+				maxId: null,
+				status: undefined,
+				likes: [],
+				likesPage: 1,
+				likesId: undefined,
+                renderIdx: 1,
+				atabs: {
+					moderation_count: 0,
+					request_count: 0
+				}
+			};
+		},
+
+		created() {
+			this.fetchSelf();
+		},
+
+		methods: {
+            fetchSelf() {
+                axios.get('/api/v1/accounts/verify_credentials?_pe=1')
+                .then(res => {
+                    this.profile = res.data;
+                })
+                .catch(err => {
+                    window.location.href = '/login?_next=' + encodeURIComponent(window.location.href);
+                })
+                .finally(() => {
+                    this.fetchGroup();
+                });
+            },
+
+			initObservers() {
+				// let video = document.querySelectorAll('video');
+				// let isPaused = false;
+				// let observer = new IntersectionObserver((entries, observer) => {
+				// 	entries.forEach(entry => {
+				// 		if (entry.intersectionRatio !=1  && !video.paused){
+				// 			video.pause();
+				// 			isPaused = true;
+				// 		}
+				// 		else if (isPaused) {
+				// 			video.play();
+				// 			isPaused = false
+				// 		}
+				// 	});
+				// }, {threshold: 1});
+				// observer.observe(video);
+			},
+
+			fetchGroup() {
+				axios.get('/api/v0/groups/' + this.groupId)
+				.then(res => {
+					this.group = res.data;
+					this.isMember = res.data.self.is_member;
+					this.isAdmin = ['founder', 'admin'].includes(res.data.self.role);
+
+					if(this.isAdmin) {
+						this.fetchAdminTabs();
+					}
+
+					if(this.path) {
+						if(this.isMember && ['about', 'topics', 'members', 'events', 'media', 'polls'].includes(this.path)) {
+							setTimeout(() => {
+								this.tab = this.path;
+								this.initalLoad = true;
+							}, 500);
+						} else if (this.isAdmin && ['insights', 'moderation'].includes(this.path)) {
+							setTimeout(() => {
+								this.tab = this.path;
+								this.initalLoad = true;
+							}, 500);
+						} else {
+							history.pushState(null, null, this.group.url);
+							this.initalLoad = true;
+						}
+					} else {
+						this.initalLoad = true;
+					}
+				})
+				.catch(err => {
+					window.location.href = '/groups/unavailable';
+				})
+                .finally(() => {
+                    this.fetchFeed();
+                });
+			},
+
+			fetchAdminTabs() {
+				axios.get('/api/v0/groups/' + this.groupId + '/atabs')
+				.then(res => {
+					this.atabs = res.data;
+				})
+			},
+
+			fetchFeed() {
+				axios.get('/api/v0/groups/' + this.groupId + '/feed')
+				.then(res => {
+					let self = this;
+                    if(res.data && res.data.length) {
+    					this.feed = res.data;
+
+    					this.maxId = this.feed[this.feed.length - 1].id;
+    					res.data.forEach(d => {
+    						if(self.ids.indexOf(d.id) == -1) {
+    							self.ids.push(d.id);
+    						}
+    					});
+                    }
+					this.initObservers();
+				})
+			},
+
+			fetchPermalink() {
+				axios.get('/api/v0/groups/status', {
+					params: {
+						gid: this.groupId,
+						sid: this.permalinkId
+					}
+				}).then(res => {
+					this.status = res.data;
+					if(this.status.in_reply_to_id) {
+						this.status.showCommentDrawer = true;
+					}
+				}).catch(err => {
+					this.permalinkMode = false;
+					this.fetchFeed();
+				});
+			},
+
+            handleRefresh() {
+                this.initialLoad = false;
+                this.init();
+                this.renderIdx++;
+            },
+
+			timestampFormat(date, showTime = false) {
+				let ts = new Date(date);
+				return showTime ? ts.toDateString() + ' · ' + ts.toLocaleTimeString() : ts.toDateString();
+			},
+
+			switchTab(tab) {
+				window.scrollTo(0,0);
+				if(tab == 'feed' && this.permalinkMode) {
+					this.permalinkMode = false;
+					this.fetchFeed();
+				}
+				let url = tab == 'feed' ? this.group.url : this.group.url + '/' + tab;
+				history.pushState(tab, null, url);
+				this.tab = tab;
+			},
+
+			joinGroup() {
+				this.requestingMembership = true;
+
+				axios.post('/api/v0/groups/'+this.groupId+'/join')
+				.then(res => {
+					this.requestingMembership = false;
+					this.group = res.data;
+					this.fetchGroup();
+					this.fetchFeed();
+				}).catch(err => {
+					let body = err.response;
+
+					if(body.status == 422) {
+						this.tab = 'feed';
+						history.pushState('', null, this.group.url);
+						this.requestingMembership = false;
+						swal('Oops!', body.data.error, 'error');
+					}
+				});
+			},
+
+			cancelJoinRequest() {
+				if(!window.confirm('Are you sure you want to cancel your request to join this group?')) {
+					return;
+				}
+
+				axios.post('/api/v0/groups/'+this.groupId+'/cjr')
+				.then(res => {
+					this.requestingMembership = false;
+				}).catch(err => {
+					let body = err.response;
+
+					if(body.status == 422) {
+						swal('Oops!', body.data.error, 'error');
+					}
+				});
+			},
+
+			leaveGroup() {
+				if(!window.confirm('Are you sure you want to leave this group? Any content you shared will remain accessible. You won\'t be able to rejoin for 24 hours.')) {
+					return;
+				}
+
+				axios.post('/api/v0/groups/'+this.groupId+'/leave')
+				.then(res => {
+					this.tab = 'feed';
+					history.pushState('', null, this.group.url);
+					this.feed = [];
+					this.isMember = false;
+					this.isAdmin = false;
+					this.group.self.role = null;
+					this.group.self.is_member = false;
+				});
+			},
+
+			pushNewStatus(status) {
+				this.feed.unshift(status);
+			},
+
+			commentFocus(index) {
+				let status = this.feed[index];
+				status.showCommentDrawer = true;
+			},
+
+			statusDelete(index) {
+				this.feed.splice(index, 1);
+			},
+
+			infiniteFeed($state) {
+				if(this.feed.length < 3) {
+					$state.complete();
+					return;
+				}
+				let apiUrl = '/api/v0/groups/' + this.groupId + '/feed';
+				axios.get(apiUrl, {
+					params: {
+						limit: 6,
+						max_id: this.maxId
+					},
+				}).then(res => {
+					if (res.data.length) {
+						// let self = this;
+						// data.forEach(d => {
+						// 	if(self.ids.indexOf(d.id) == -1) {
+						// 		if(self.maxId >= d.id) {
+						// 			self.maxId = d.id;
+						// 		}
+						// 		self.ids.push(d.id);
+						// 		self.feed.push(d);
+						// 	}
+						// });
+						let posts = res.data.filter(p => this.ids.indexOf(p.id) == -1);
+						this.maxId = posts[posts.length - 1].id;
+						this.feed.push(...posts);
+						this.ids.push(...posts.map(p => p.id));
+						setTimeout(() => {
+							this.initObservers();
+						}, 1000);
+						$state.loaded();
+					} else {
+						$state.complete();
+					}
+				});
+			},
+
+			decrementModCounter(amount) {
+				let count = this.atabs.moderation_count;
+				if(count == 0) {
+					return;
+				}
+				this.atabs.moderation_count = (count - amount);
+			},
+
+			setModCounter(amount) {
+				this.atabs.moderation_count = amount;
+			},
+
+			decrementJoinRequestCount(amount = 1) {
+				let count = this.atabs.request_count;
+				this.atabs.request_count = (count - amount)
+			},
+
+			incrementMemberCount() {
+				let count = this.group.member_count;
+				this.group.member_count = (count + 1);
+			},
+
+			copyLink() {
+				window.App.util.clipboard(this.group.url);
+				this.$bvToast.toast(`Succesfully copied group url to clipboard`, {
+					title: 'Success',
+					variant: 'success',
+					autoHideDelay: 5000
+				});
+			},
+
+			reportGroup() {
+				swal('Report Group', 'Are you sure you want to report this group?')
+				.then(res => {
+					if(res) {
+						location.href = `/i/report?id=${this.group.id}&type=group`;
+					}
+				});
+			},
+
+			showSearchModal() {
+				event.currentTarget.blur();
+				this.$refs.searchModal.open();
+			},
+
+			showInviteModal() {
+				event.currentTarget.blur();
+				this.$refs.inviteModal.open();
+			},
+
+			showLikesModal(index) {
+				this.likesId = this.feed[index].id;
+				axios.get('/api/v0/groups/'+this.groupId+'/likes/'+this.likesId)
+				.then(res => {
+					this.likes = res.data;
+					this.likesPage++;
+					this.$refs.likeBox.show();
+				});
+			},
+
+			infiniteLikesHandler($state) {
+				if(this.likes.length < 3) {
+					$state.complete();
+					return;
+				}
+				axios.get('/api/v0/groups/'+this.groupId+'/likes/'+this.likesId, {
+					params: {
+						page: this.likesPage,
+					},
+				}).then(res => {
+					if (res.data.length > 0) {
+						this.likes.push(...res.data);
+						this.likesPage++;
+						if(res.data.length != 10) {
+							$state.complete();
+						} else {
+							$state.loaded();
+						}
+					} else {
+						$state.complete();
+					}
+				});
+			},
+		}
+	}
+</script>
+
+<style lang="scss">
+	.group-feed-component {
+		&-header {
+			display: flex;
+			justify-content: space-between;
+			align-items: flex-end;
+			padding: 1rem 0;
+			background-color: transparent;
+
+			.cta-btn {
+				width: 190px;
+			}
+		}
+
+		.header-jumbotron {
+			background-color: #F3F4F6;
+			height: 320px;
+			border-bottom-left-radius: 20px;
+			border-bottom-right-radius: 20px;
+		}
+
+		&-menu {
+			display: flex;
+			justify-content: space-between;
+			align-items: center;
+			padding: 0;
+
+			&-nav {
+				.nav-item {
+					.nav-link {
+						padding-top: 1rem;
+						padding-bottom: 1rem;
+						color: #6c757d;
+
+						&.active {
+							color: #2c78bf;
+							border-bottom: 2px solid #2c78bf;
+						}
+					}
+				}
+
+				&:not(last-child) {
+					.nav-item {
+						margin-right: 14px;
+					}
+				}
+			}
+		}
+
+		&-body {
+			min-height: 40vh;
+		}
+
+		.member-label {
+			padding: 2px 5px;
+			font-size: 12px;
+			color: rgba(75, 119, 190, 1);
+			background:rgba(137, 196, 244, 0.2);
+			border:1px solid rgba(137, 196, 244, 0.3);
+			font-weight:400;
+			text-transform: capitalize;
+		}
+
+		.dropdown-item {
+			font-weight: 600;
+		}
+
+		.remote-label {
+			padding: 2px 5px;
+			font-size: 12px;
+			color: #B45309;
+			background: #FEF3C7;
+			border: 1px solid #FCD34D;
+			font-weight: 400;
+			text-transform: capitalize;
+		}
+	}
+</style>

+ 217 - 0
resources/assets/components/groups/GroupInvite.vue

@@ -0,0 +1,217 @@
+<template>
+	<div class="group-invite-component">
+		<div class="container">
+			<div class="row justify-content-center mt-5">
+				<div class="col-12 col-md-7">
+					<div class="card shadow-none border" style="min-height: 300px;">
+						<div class="card-body d-flex justify-content-center align-items-center">
+
+						<transition-group name="fade">
+							<div v-if="tab === 'initial'" key="initial">
+								<p class="text-center mb-1"><b-spinner variant="lighter" /></p>
+								<p class="text-center small text-muted mb-0">{{ loadingStatus }}</p>
+							</div>
+
+							<div v-else-if="tab === 'loading'" key="loading">
+								<p class="text-center mb-1"><b-spinner variant="lighter" /></p>
+							</div>
+
+							<div v-else-if="tab === 'login'" key="login">
+								<p class="text-center mb-0">Please <a href="/login">login</a> to continue</p>
+							</div>
+
+							<div v-else-if="tab === 'form'" key="form">
+								<div class="d-flex justify-content-center align-items-center flex-column">
+									<p class="text-center h4 font-weight-bold"><a href="#">@dansup</a> invited you to join</p>
+
+									<div class="card my-3 shadow-none border" style="width: 300px;">
+										<img v-if="group.metadata && group.metadata.hasOwnProperty('header')" :src="group.metadata.header.url" class="card-img-top" style="width: 100%; height: 100px;object-fit: cover;">
+										<div v-else class="card-img-top" style="width: 100px; height: 100px;padding: 5px;">
+											<div class="bg-primary d-flex align-items-center justify-content-center" style="width: 100%; height:100%;">
+												<i class="fal fa-users text-white fa-lg"></i>
+											</div>
+										</div>
+
+										<div class="card-body">
+											<p class="h5 font-weight-bold mb-1 text-dark">
+												{{ group.name || 'Untitled Group' }}
+											</p>
+
+											<transition name="fade">
+												<p v-if="showMore" class="text-muted small mb-1">
+													{{ group.description }}
+												</p>
+											</transition>
+
+											<p class="mb-1">
+												<span class="text-muted mr-2">
+													<i class="far fa-users fa-sm text-lighter mr-1"></i>
+													<span class="small font-weight-bold">{{ prettyCount(group.member_count) }} Members</span>
+												</span>
+
+												<span v-if="!group.local" class="remote-label ml-2">
+													<i class="fal fa-globe"></i> Remote
+												</span>
+											</p>
+
+											<transition name="fade">
+												<div v-if="showMore">
+													<p class="text-muted small mb-1">
+														<i class="far fa-tag fa-sm text-lighter mr-2"></i>
+														<span class="font-weight-bold">Category: {{ group.category.name }}</span>
+													</p>
+													<p class="text-muted small mb-1">
+														<i class="far fa-clock fa-sm text-lighter mr-2"></i>
+														<span class="font-weight-bold">Created {{ timeago(group.created_at) }} ago</span>
+													</p>
+												</div>
+											</transition>
+										</div>
+									</div>
+
+								</div>
+								<div class="d-flex justify-content-between">
+									<button class="btn btn-light border-lighter font-weight-bold btn-sm" @click="showMoreInfo">
+										{{ showMore ? 'Less' :'More' }} info
+									</button>
+									<div>
+										<button class="btn btn-light font-weight-bold btn-sm" @click="declineInvite">Decline</button>
+										<button class="btn btn-primary font-weight-bold btn-sm" @click="acceptInvite">Accept</button>
+									</div>
+								</div>
+								<!-- <p class="text-center h4 font-weight-bold">by <a href="#" class="font-weight-bold">@dansup</a></p> -->
+							</div>
+
+							<div v-else-if="tab === 'existingmember'" key="existingmember">
+								<p class="text-center mb-0">You already are a member of this group</p>
+								<p class="text-center mb-0">
+									<a :href="group.url" class="font-weight-bold">View Group</a>
+								</p>
+							</div>
+
+							<div v-else-if="tab === 'notinvited'" key="notinvited">
+								<p class="text-center mb-0">We cannot find an active invitation for your account.</p>
+							</div>
+
+							<div v-else-if="tab === 'error'" key="error">
+								<p class="text-center mb-0">An unknown error occured. Please try again later.</p>
+							</div>
+
+							<div v-else key="unknown">
+								<p class="text-center mb-0">An unknown error occured. Please try again later.</p>
+							</div>
+
+						</transition-group>
+						</div>
+					</div>
+					<!-- <h4>You've been invited to {{ group.name }}</h4> -->
+				</div>
+			</div>
+		</div>
+	</div>
+</template>
+
+<script type="text/javascript">
+	export default {
+		props: [
+			'id'
+		],
+
+		data() {
+			return {
+				loadingStatus: 'Determining invite eligibility',
+				tab: 'initial',
+				profile: {},
+				group: {},
+				showMore: false
+			}
+		},
+
+		mounted() {
+			axios.get('/api/pixelfed/v1/accounts/verify_credentials')
+			.then(res => {
+				this.profile = res.data;
+				this.fetchGroup();
+			}).catch(err => {
+				if(err.response.status === 403) {
+					this.tab = 'login';
+					return;
+				} else {
+					this.tab = 'error';
+					return
+				}
+			});
+		},
+
+		methods: {
+			fetchGroup() {
+				axios.get(`/api/v0/groups/${this.id}`)
+				.then(res => {
+					this.group = res.data;
+					this.loadingStatus = 'Checking group invitations';
+					this.checkForInvitation();
+				}).catch(err => {
+					this.tab = 'error';
+				});
+			},
+
+			checkForInvitation() {
+				axios.post(`/api/v0/groups/${this.group.id}/invite/check`)
+				.then(res => {
+					this.tab = res.data.can_join == true ? 'form' : 'notinvited';
+				}).catch(err => {
+					if(err.response.status === 422 && err.response.data.error === 'Already a member') {
+						this.tab = 'existingmember';
+					} else {
+						this.tab = 'error';
+					}
+				})
+			},
+
+			prettyCount(val) {
+				return App.util.format.count(val);
+			},
+
+			timeago(ts) {
+				return App.util.format.timeAgo(ts);
+			},
+
+			showMoreInfo() {
+				event.currentTarget.blur();
+				this.showMore = !this.showMore;
+			},
+
+			acceptInvite() {
+				event.currentTarget.blur();
+				this.tab = 'loading';
+				axios.post(`/api/v0/groups/${this.group.id}/invite/accept`)
+				.then(res => {
+					setTimeout(() => {
+						location.href = res.data.next_url;
+					}, 2000);
+				}).catch(err => {
+					this.tab = 'error';
+				});
+			},
+
+			declineInvite() {
+				event.currentTarget.blur();
+				this.tab = 'loading';
+				axios.post(`/api/v0/groups/${this.group.id}/invite/decline`)
+				.then(res => {
+					location.href = res.data.next_url;
+				}).catch(err => {
+					this.tab = 'error';
+				});
+			},
+		}
+	}
+</script>
+
+<style lang="scss">
+	.group-invite-component {
+		.btn-light {
+			border-color: #E5E7EB;
+		}
+	}
+</style>

+ 379 - 0
resources/assets/components/groups/GroupProfile.vue

@@ -0,0 +1,379 @@
+<template>
+	<div class="group-profile-component w-100 h-100">
+		<div class="bg-white mb-3 border-bottom">
+			<div class="container-xl header">
+				<div class="header-jumbotron"></div>
+
+				<div class="header-profile-card">
+					<img :src="profile.avatar" class="avatar" onerror="this.onerror=null;this.src='/storage/avatars/default.png?v=2'">
+					<p class="name">
+						{{ profile.display_name }}
+					</p>
+					<p class="username text-muted">
+						<span v-if="profile.local">&commat;{{ profile.username }}</span>
+						<span v-else>{{ profile.acct }}</span>
+
+						<span v-if="profile.is_admin" class="text-danger ml-1" title="Site administrator" data-toggle="tooltip" data-placement="bottom"><i class="far fa-users-crown"></i></span>
+					</p>
+				</div>
+				<!-- <hr> -->
+				<div class="header-navbar">
+					<div></div>
+
+					<div>
+						<a
+							v-if="currentProfile.id === profile.id"
+							class="btn btn-light font-weight-bold mr-2"
+							href="/settings/home">
+							<i class="fas fa-edit mr-1"></i> Edit Profile
+						</a>
+
+						<a
+							v-if="profile.relationship.following"
+							class="btn btn-primary font-weight-bold mr-2"
+							:href="profile.url">
+							<i class="far fa-comment-alt-dots mr-1"></i> Message
+						</a>
+
+						<a
+							v-if="profile.relationship.following"
+							class="btn btn-light font-weight-bold mr-2"
+							:href="profile.url">
+							<i class="fas fa-user-check mr-1"></i> {{ profile.relationship.followed_by ? 'Friends' : 'Following' }}
+						</a>
+
+						<a
+							v-if="!profile.relationship.following"
+							class="btn btn-light font-weight-bold mr-2"
+							:href="profile.url">
+							<i class="fas fa-user mr-1"></i> View Main Profile
+						</a>
+
+						<div class="dropdown">
+							<button class="btn btn-light font-weight-bold dropdown-toggle" type="button" id="amenu" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
+								<i class="fas fa-ellipsis-h"></i>
+							</button>
+							<div class="dropdown-menu dropdown-menu-right" aria-labelledby="amenu">
+								<a v-if="currentProfile.id != profile.id" class="dropdown-item font-weight-bold" :href="`/i/report?type=user&id=${profile.id}`">Report</a>
+								<a v-if="currentProfile.id == profile.id" class="dropdown-item font-weight-bold" href="#">Leave Group</a>
+							</div>
+						</div>
+					</div>
+				</div>
+			</div>
+		</div>
+		<div class="w-100 h-100 group-profile-feed">
+			<div class="container-xl">
+				<div class="row">
+					<div class="col-12 col-md-5">
+						<div class="card card-body shadow-sm infolet">
+							<h5 class="font-weight-bold mb-3">Intro</h5>
+							<div v-if="!profile.local" class="media mb-3 align-items-center">
+								<div class="media-icon">
+									<i class="far fa-globe" title="User is from a remote server" data-toggle="tooltip" data-placement="bottom"></i>
+								</div>
+								<div class="media-body">
+									Remote member from <strong>{{ profile.acct.split('@')[1] }}</strong>
+								</div>
+							</div>
+							<!-- <div v-if="profile.note.length" class="media mb-3 align-items-center">
+								<i class="far fa-book-user fa-lg text-lighter mr-3" title="User bio" data-toggle="tooltip" data-placement="bottom"></i>
+								<div class="media-body">
+									<span v-html="profile.note"></span>
+								</div>
+							</div> -->
+							<div class="media align-items-center">
+								<div class="media-icon">
+									<i class="fas fa-users" title="User joined group on this date" data-toggle="tooltip" data-placement="bottom"></i>
+								</div>
+								<div class="media-body">
+									{{ roleTitle }} of <strong>{{ group.name }}</strong> since {{ profile.group.joined }}
+								</div>
+							</div>
+						</div>
+
+						<div v-if="canIntersect" class="card card-body shadow-sm infolet">
+							<h5 class="font-weight-bold mb-3">Things in Common</h5>
+
+							<div v-if="commonIntersects.friends.length" class="media mb-3 align-items-center" v-once>
+								<div class="media-icon">
+									<i class="far fa-user-friends"></i>
+								</div>
+								<div class="media-body">
+									{{ commonIntersects.friends_count }} mutual friend<span v-if="commonIntersects.friends.length > 1">s</span> including
+									<span v-for="(friend, index) in commonIntersects.friends"><a :href="friend.url" class="text-dark font-weight-bold">{{ friend.acct }}</a><span v-if="commonIntersects.friends.length > index + 1">, </span><span v-else> </span></span>
+									<!-- <a href="#" class="text-dark font-weight-bold">dansup</a>, <a href="#" class="text-dark font-weight-bold">admin</a> and 1 other -->
+								</div>
+							</div>
+
+							<div class="media mb-3 align-items-center">
+								<div class="media-icon">
+									<i class="fas fa-home"></i>
+								</div>
+								<div class="media-body">
+									Lives in <strong>Canada</strong>
+								</div>
+							</div>
+
+							<div v-if="commonIntersects.groups.length" class="media mb-3 align-items-center">
+								<div class="media-icon">
+									<i class="fas fa-users"></i>
+								</div>
+								<div class="media-body">
+									Also member of <a :href="commonIntersects.groups[0].url" class="text-dark font-weight-bold">{{ commonIntersects.groups[0].name }}</a> and {{ commonIntersects.groups_count }} other groups
+								</div>
+							</div>
+
+							<div v-if="commonIntersects.topics.length" class="media mb-0 align-items-center">
+								<div class="media-icon">
+									<i class="far fa-thumbs-up fa-lg text-lighter"></i>
+								</div>
+								<div class="media-body">
+									Also interested in topics containing
+									<span v-for="(topic, index) in commonIntersects.topics">
+										<span v-if="commonIntersects.topics.length - 1 == index"> and </span><a :href="topic.url" class="font-weight-bold text-dark">#{{ topic.name }}</a><span v-if="commonIntersects.topics.length > index + 2">, </span>
+									</span> hashtags
+								</div>
+							</div>
+						</div>
+					</div>
+
+					<div class="col-12 col-md-7">
+						<div class="card card-body shadow-sm">
+							<h5 class="font-weight-bold mb-0">Group Posts</h5>
+						</div>
+
+						<div v-if="feedEmpty" class="pt-5 text-center">
+							<h5>No New Posts</h5>
+							<p>{{ profile.username }} hasn't posted anything yet in <strong>{{ group.name }}</strong>.</p>
+
+							<a :href="group.url" class="font-weight-bold">Go Back</a>
+						</div>
+
+						<div v-if="feedLoaded" class="mt-2">
+							<group-status
+								v-for="(status, index) in feed"
+								:key="'gps:' + status.id"
+								:permalinkMode="true"
+								:showGroupChevron="true"
+								:group="group"
+								:prestatus="status"
+								:profile="profile"
+								:group-id="group.id" />
+
+							<div v-if="feed.length >= 1" :distance="800">
+								<infinite-loading @infinite="infiniteFeed">
+									<div slot="no-more"></div>
+									<div slot="no-results"></div>
+								</infinite-loading>
+							</div>
+						</div>
+					</div>
+				</div>
+			</div>
+		</div>
+	</div>
+</template>
+
+<script type="text/javascript">
+	import GroupStatus from '@/groups/partials/GroupStatus.vue';
+
+	export default {
+		props: {
+			pg: {
+				type: String
+			},
+
+			pp: {
+				type: String
+			}
+		},
+
+		components: {
+			'group-status': GroupStatus
+		},
+
+		data() {
+			return {
+				currentProfile: {},
+				roleTitle: 'Member',
+				group: {},
+				profile: {},
+				feed: [],
+				ids: [],
+				feedLoaded: false,
+				feedEmpty: false,
+				page: 1,
+				canIntersect: false,
+				commonIntersects: []
+			}
+		},
+
+		beforeMount() {
+			$('body').css('background-color', '#f0f2f5');
+			this.group = JSON.parse(this.pg);
+			this.profile = JSON.parse(this.pp);
+
+			if(this.profile.group.role == 'founder') {
+				this.roleTitle = 'Admin';
+			}
+		},
+
+		mounted() {
+			axios.get('/api/pixelfed/v1/accounts/verify_credentials')
+			.then(res => {
+				this.currentProfile = res.data;
+				this.fetchInitialFeed();
+				if(res.data.id != this.profile.id) {
+					this.fetchCommonIntersections();
+				}
+			})
+			this.$nextTick(() => {
+				$('[data-toggle="tooltip"]').tooltip();
+			});
+		},
+
+		methods: {
+			fetchInitialFeed() {
+				axios.get(`/api/v0/groups/${this.group.id}/user/${this.profile.id}/feed`)
+				.then(res => {
+					this.feed = res.data.filter(s => {
+						return s.pf_type != 'reply:text' || s.account.id != this.profile.id;
+					});
+					this.feedLoaded = true;
+					this.feedEmpty = this.feed.length == 0;
+					this.page++;
+				});
+			},
+
+			infiniteFeed($state) {
+				if(this.feed.length == 0) {
+					$state.complete();
+					return;
+				}
+
+				axios.get(`/api/v0/groups/${this.group.id}/user/${this.profile.id}/feed`, {
+					params: {
+						page: this.page
+					},
+				}).then(res => {
+					if (res.data.length) {
+						let data = res.data.filter(s => {
+							return s.pf_type != 'reply:text' || s.account.id != this.profile.id;
+						});
+						let self = this;
+						data.forEach(d => {
+							if(self.ids.indexOf(d.id) == -1) {
+								self.ids.push(d.id);
+								self.feed.push(d);
+							}
+						});
+						$state.loaded();
+						this.page++;
+					} else {
+						$state.complete();
+					}
+				});
+			},
+
+			fetchCommonIntersections() {
+				axios.get('/api/v0/groups/member/intersect/common', {
+					params: {
+						gid: this.group.id,
+						pid: this.profile.id
+					}
+				}).then(res => {
+					this.commonIntersects = res.data;
+					this.canIntersect = res.data.groups.length || res.data.topics.length;
+				});
+
+			}
+		}
+	}
+</script>
+
+<style lang="scss">
+	.group-profile-component {
+		background-color: #f0f2f5;
+
+		.header {
+			&-jumbotron {
+				background-color: #F3F4F6;
+				height: 320px;
+				border-bottom-left-radius: 20px;
+				border-bottom-right-radius: 20px;
+			}
+
+			&-profile-card {
+				display: flex;
+				flex-direction: column;
+				justify-content: center;
+				align-items: center;
+
+				.avatar {
+					width: 170px;
+					height: 170px;
+					border-radius: 50%;
+					margin-top: -150px;
+					margin-bottom: 20px;
+				}
+
+				.name {
+					font-size: 30px;
+					line-height: 30px;
+					font-weight: 700;
+					text-align: center;
+					margin-bottom: 6px;
+				}
+
+				.username {
+					font-size: 16px;
+					font-weight: 500;
+					text-align: center;
+				}
+			}
+
+			&-navbar {
+				display: flex;
+				justify-content: space-between;
+				align-items: center;
+				height: 60px;
+				border-top: 1px solid #F3F4F6;
+
+				.dropdown {
+					display: inline-block;
+
+					&-toggle:after {
+						display: none;
+					}
+				}
+			}
+		}
+
+		.group-profile-feed {
+			min-height: 500px;
+		}
+
+		.infolet {
+			margin-bottom: 1rem;
+
+			.media {
+				&-icon {
+					display: flex;
+					justify-content: center;
+					width: 30px;
+					margin-right: 10px;
+
+					i {
+						font-size: 1.1rem;
+						color: #D1D5DB !important;
+					}
+				}
+			}
+		}
+
+		.btn-light {
+			border-color: #F3F4F6;
+		}
+	}
+</style>

+ 1079 - 0
resources/assets/components/groups/GroupSettings.vue

@@ -0,0 +1,1079 @@
+<template>
+	<div class="group-settings-component">
+		<div v-if="!initalLoad">
+			<p class="text-center mt-5 pt-5 font-weight-bold">Loading...</p>
+		</div>
+
+		<div v-else>
+			<div class="bg-white mb-3 border-bottom">
+				<div class="container">
+					<div class="col-12 group-settings-component-header">
+						<div>
+							<h1 class="font-weight-bold mb-4">Group Settings</h1>
+							<p class="text-muted mb-0">
+								<span v-once>
+									<i class="fas fa-globe mr-1"></i>
+									{{ group.membership == 'all' ? 'Public Group' : 'Private Group'}}
+								</span>
+								<span class="mx-2">
+									·
+								</span>
+								<span>{{ group.member_count == 1 ? group.member_count + ' Member' : group.member_count + ' Members' }}</span>
+								<span class="mx-2">
+									·
+								</span>
+								<span class="text-lighter">ID:{{ group.id }}</span>
+							</p>
+						</div>
+						<div>
+							<a
+								v-if="isAdmin"
+								class="mr-2 btn btn-outline-secondary rounded-pill cta-btn font-weight-bold"
+								:href="group.url">
+								<i class="fas fa-chevron-left mr-1"></i> Back to Group
+							</a>
+							<button class="btn btn-primary font-weight-bold rounded-pill px-4" :disabled="savingChanges" @click="submit">
+								Save Changes
+							</button>
+						</div>
+					</div>
+					<div class="col-12">
+						<ul class="nav nav-tabs border-bottom-0 font-weight-bold small">
+							<li class="nav-item">
+								<a
+									:class="{ active: tab == 'home'}"
+									class="nav-link"
+									href="#"
+									@click.prevent="toggleTab('home')">
+									General
+								</a>
+							</li>
+							<li class="nav-item">
+								<a
+									:class="{ active: tab == 'customize'}"
+									class="nav-link"
+									href="#"
+									@click.prevent="toggleTab('customize')">
+									Customize
+								</a>
+							</li>
+							<!-- <li class="nav-item">
+								<a
+									:class="{ active: tab == 'mod'}"
+									class="nav-link"
+									href="#"
+									@click.prevent="toggleTab('mod')">
+									Moderation
+								</a>
+							</li> -->
+							<li class="nav-item">
+								<a
+									:class="{ active: tab == 'blocked'}"
+									class="nav-link"
+									href="#"
+									@click.prevent="toggleTab('blocked')">
+									Domain/User Blocks
+								</a>
+							</li>
+							<li class="nav-item">
+								<a
+									:class="{ active: tab == 'interactions'}"
+									class="nav-link"
+									href="#"
+									@click.prevent="toggleTab('interactions')">
+									Interactions
+								</a>
+							</li>
+							<!-- <li class="nav-item">
+								<a class="nav-link" href="#">Interaction Log</a>
+							</li>
+							<li class="nav-item">
+								<a class="nav-link" href="#">Blocked Instances &amp; Users</a>
+							</li> -->
+							<li class="nav-item">
+								<a
+									:class="{ active: tab == 'limits'}"
+									class="nav-link"
+									href="#"
+									@click.prevent="toggleTab('limits')">
+									Limits
+								</a>
+							</li>
+							<!-- <li class="nav-item">
+								<a class="nav-link" href="#">Limits</a>
+							</li>
+							<li class="nav-item">
+								<a class="nav-link" href="#">Roles</a>
+							</li>
+							<li class="nav-item">
+								<a class="nav-link" href="#">Import &amp; Export</a>
+							</li> -->
+							<li class="nav-item">
+								<a
+									:class="{ active: tab == 'advanced'}"
+									class="nav-link"
+									href="#"
+									@click.prevent="toggleTab('advanced')">
+									Advanced
+								</a>
+							</li>
+						</ul>
+					</div>
+				</div>
+			</div>
+
+			<div class="container-xl pt-3">
+
+				<div v-if="tab == 'home'" class="row">
+					<div class="col-12 col-md-6 offset-md-3">
+						<div class="">
+							<div class="form-group">
+								<label class="font-weight-bold">Name</label>
+								<input class="form-control" :value="group.name" disabled>
+								<p class="form-text small text-muted">You cannot change a groups name at this time.</p>
+							</div>
+							<hr>
+
+							<div class="form-group">
+								<label class="font-weight-bold">Category</label>
+								<select class="custom-select" v-model="category">
+									<option value="" selected="" disabled="">Select a category</option>
+									<option v-for="c in categories" :value="c">{{ c }}</option>
+								</select>
+								<p class="form-text small text-muted">Choose the most relevant category to improve discovery and visibility</p>
+							</div>
+							<hr>
+
+							<div class="form-group">
+								<label class="font-weight-bold">Description</label>
+								<textarea class="form-control" rows="4" v-model="group.description" style="resize: none;">
+								</textarea>
+								<span class="form-text small text-muted font-weight-bold text-right">
+									{{group.description ? group.description.length : 0}}/500
+								</span>
+								<p class="form-text small text-muted">A plain text description of your group. Be as descriptive as possible to give potential members a better idea of what to expect.</p>
+							</div>
+							<!-- <hr>
+							<div class="form-group">
+								<label class="font-weight-bold">Avatar Photo</label>
+								<div v-if="group.metadata && group.metadata.hasOwnProperty('avatar')" class="d-flex justify-content-between align-items-center">
+									<img :src="group.metadata.avatar.url" width="100" height="100" class="rounded-circle border" style="object-fit: cover;">
+									<p class="mb-0 mt-2 text-lighter">
+										<a href="" class="text-muted font-weight-bold">
+											Preview
+										</a>
+										<span class="mx-1">·</span>
+										<a href="" class="text-muted font-weight-bold">
+											Update
+										</a>
+										<span class="mx-1">·</span>
+										<a href="" class="text-danger font-weight-bold">
+											Delete
+										</a>
+									</p>
+								</div>
+								<div v-else>
+									<div class="custom-file">
+										<input type="file" class="custom-file-input" ref="avatarInput">
+										<label class="custom-file-label" for="avatarInput">Choose file</label>
+									</div>
+									<p class="form-text small text-muted">Must be jpeg or png format, up to 2MB</p>
+								</div>
+							</div>
+							<hr>
+							<div class="form-group">
+								<label class="font-weight-bold">Header Photo</label>
+
+								<div v-if="group.metadata && group.metadata.hasOwnProperty('header')" class="d-flex justify-content-between align-items-center">
+									<img :src="group.metadata.header.url" width="200" height="100" class="rounded border" style="object-fit: cover;">
+									<p class="mb-0 mt-2 text-lighter">
+										<a href="" class="text-muted font-weight-bold">
+											Preview
+										</a>
+										<span class="mx-1">·</span>
+										<a href="" class="text-muted font-weight-bold">
+											Update
+										</a>
+										<span class="mx-1">·</span>
+										<a href="" class="text-danger font-weight-bold">
+											Delete
+										</a>
+									</p>
+								</div>
+								<div v-else>
+									<div class="custom-file">
+										<input type="file" class="custom-file-input" ref="headerInput">
+										<label class="custom-file-label" for="headerInput">Choose file</label>
+									</div>
+									<p class="form-text small text-muted">Must be jpeg or png format, up to 10MB</p>
+								</div>
+							</div> -->
+						</div>
+					</div>
+				</div>
+
+				<div v-if="tab == 'customize'" class="row">
+					<div class="col-12 col-md-6 offset-md-3">
+						<div class="">
+							<div class="form-group">
+								<label class="font-weight-bold">Avatar Photo</label>
+								<div v-if="group.metadata && group.metadata.hasOwnProperty('avatar')" class="d-flex justify-content-between align-items-center">
+									<img :src="group.metadata.avatar.url" width="100" height="100" class="rounded-circle border" style="object-fit: cover;">
+									<p class="mb-0 mt-2 text-lighter">
+										<a href="" class="text-muted font-weight-bold">
+											Preview
+										</a>
+										<span class="mx-1">·</span>
+										<a href="" class="text-muted font-weight-bold">
+											Update
+										</a>
+										<span class="mx-1">·</span>
+										<a href="" class="text-danger font-weight-bold">
+											Delete
+										</a>
+									</p>
+								</div>
+								<div v-else>
+									<div class="custom-file">
+										<input type="file" class="custom-file-input" ref="avatarInput">
+										<label class="custom-file-label" for="avatarInput">Choose file</label>
+									</div>
+									<p class="form-text small text-muted">Must be jpeg or png format, up to 2MB</p>
+								</div>
+							</div>
+							<hr>
+							<div class="form-group">
+								<label class="font-weight-bold">Header Photo</label>
+
+								<div v-if="group.metadata && group.metadata.hasOwnProperty('header')" class="d-flex justify-content-between align-items-center">
+									<img :src="group.metadata.header.url" width="200" height="100" class="rounded border" style="object-fit: cover;">
+									<p class="mb-0 mt-2 text-lighter">
+										<a href="" class="text-muted font-weight-bold">
+											Preview
+										</a>
+										<span class="mx-1">·</span>
+										<a href="" class="text-muted font-weight-bold">
+											Update
+										</a>
+										<span class="mx-1">·</span>
+										<a href="" class="text-danger font-weight-bold">
+											Delete
+										</a>
+									</p>
+								</div>
+								<div v-else>
+									<div class="custom-file">
+										<input type="file" class="custom-file-input" ref="headerInput">
+										<label class="custom-file-label" for="headerInput">Choose file</label>
+									</div>
+									<p class="form-text small text-muted">Must be jpeg or png format, up to 10MB</p>
+								</div>
+							</div>
+						</div>
+					</div>
+				</div>
+
+				<div v-if="tab == 'interactions'" class="row">
+					<div class="col-12 col-md-3">
+						<p class="lead">The <strong>Interaction Log</strong> displays all member activities relating to this group.</p>
+						<p class="lead">You may see logs from blocked, deleted and remote accounts.</p>
+					</div>
+					<div class="col-12 col-md-6">
+						<div class="list-group">
+							<div v-for="(log, index) in interactionLog" class="list-group-item">
+								<div class="media align-items-center">
+									<img :src="log.profile.avatar" width="32" height="32" class="rounded-circle border mr-3" onerror="this.onerror=null;this.src='/storage/avatars/default.png?v=2'">
+									<div class="media-body">
+										<span class="font-weight-bold">{{log.profile.username}}</span>
+
+										<span v-if="log.type == 'group:comment:created'">
+											commented on a <a :href="sidToUrl(log.metadata.status_id)" class="font-weight-bold">post</a>
+										</span>
+
+										<span v-else-if="log.type == 'group:joined'">
+											joined the group
+										</span>
+
+										<span v-else-if="log.type == 'group:like'">
+											liked a <a :href="sidToUrl(log.metadata.status_id)" class="font-weight-bold">post</a>
+										</span>
+
+										<span v-else-if="log.type == 'group:settings:updated'">
+											updated the <a href="" class="font-weight-bold">group settings</a>
+										</span>
+
+										<span v-else-if="log.type == 'group:status:created'">
+											created a <a :href="sidToUrl(log.metadata.status_id)" class="font-weight-bold">post</a>
+										</span>
+
+										<span v-else-if="log.type == 'group:status:deleted'">
+											deleted a <a :href="sidToUrl(log.metadata.status_id)" class="font-weight-bold">post</a>
+										</span>
+
+										<span v-else-if="log.type == 'group:unlike'">
+											unliked a <a :href="sidToUrl(log.metadata.status_id)" class="font-weight-bold">post</a>
+										</span>
+
+										<span v-else-if="log.type == 'group:admin:block:instance'">
+											blocked <span class="font-weight-bold text-primary">{{ log.metadata.domain }}</span>
+										</span>
+
+										<span v-else-if="log.type == 'group:admin:block:user'">
+											blocked <a class="font-weight-bold text-primary" :href="'/' + log.metadata.username">{{ log.metadata.username }}</a>
+										</span>
+
+										<span v-else-if="log.type == 'group:report:create'">
+											created a <a :href="reportUrl(log.metadata.report_id)" class="font-weight-bold">report</a> about <a :href="'/' + log.metadata.username" class="font-weight-bold">{{ log.metadata.username}}</a>'s <a :href="log.metadata.url" class="font-weight-bold">post</a>
+										</span>
+
+										<span v-else-if="log.type == 'group:moderation:action'">
+											handled a <a :href="reportUrl(log.metadata.report_id)" class="font-weight-bold">mod report</a> regarding <a :href="log.metadata.status_url" class="font-weight-bold">this post</a>
+										</span>
+
+										<span v-else-if="log.type =='group:member-limits:updated'">
+											updated <a :href="memberInteractionUrl(log.metadata.profile_id)" class="font-weight-bold">interaction limits</a> for <a :href="'/' + log.metadata.username" class="font-weight-bold">{{ log.metadata.username }}</a>
+										</span>
+
+										<span v-else>{{log.type}}</span>
+
+										<div class="float-right text-muted small font-weight-bold">{{timeago(log.created_at)}}</div>
+									</div>
+								</div>
+							</div>
+							<div v-if="interactionLogShowMore" class="list-group-item">
+								<button class="btn btn-light font-weight-bold btn-block" @click="loadMoreInteractions">Load more</button>
+							</div>
+						</div>
+					</div>
+					<div class="col-12 col-md-3">
+						<p class="font-weight-bold small">SEARCH</p>
+						<div class="form-group">
+							<input class="form-control rounded-pill" placeholder="Search username, type or url"/>
+						</div>
+						<hr>
+
+						<p class="font-weight-bold small">ACTIVITIES</p>
+
+						<div class="form-check">
+							<input class="form-check-input" type="checkbox" checked id="filter1">
+							<label class="form-check-label font-weight-bold" for="filter1">
+								Joined Group
+							</label>
+						</div>
+
+						<div class="form-check">
+							<input class="form-check-input" type="checkbox" checked id="filter1">
+							<label class="form-check-label font-weight-bold" for="filter1">
+								Left Group
+							</label>
+						</div>
+
+						<div class="form-check">
+							<input class="form-check-input" type="checkbox" checked id="filter1">
+							<label class="form-check-label font-weight-bold" for="filter1">
+								Posts
+							</label>
+						</div>
+
+						<div class="form-check">
+							<input class="form-check-input" type="checkbox" checked id="filter1">
+							<label class="form-check-label font-weight-bold" for="filter1">
+								Comments
+							</label>
+						</div>
+
+						<div class="form-check">
+							<input class="form-check-input" type="checkbox" checked id="filter1">
+							<label class="form-check-label font-weight-bold" for="filter1">
+								Likes
+							</label>
+						</div>
+
+						<hr>
+
+						<p class="font-weight-bold small">FILTERS</p>
+
+						<div class="form-check">
+							<input class="form-check-input" type="checkbox" value="" id="filter1">
+							<label class="form-check-label font-weight-bold" for="filter1">
+								Local members only
+							</label>
+						</div>
+
+						<div class="form-check">
+							<input class="form-check-input" type="checkbox" value="" id="filter1">
+							<label class="form-check-label font-weight-bold" for="filter1">
+								Remote members only
+							</label>
+						</div>
+
+						<div class="form-check">
+							<input class="form-check-input" type="checkbox" value="" id="filter1">
+							<label class="form-check-label font-weight-bold" for="filter1">
+								Blocked members only
+							</label>
+						</div>
+					</div>
+				</div>
+
+				<div v-if="tab == 'blocked'" class="row">
+					<div class="col-12 col-md-3">
+						<p class="h5">Blocked Instances &amp; Users</p>
+						<p>Fine-grained control over who can join and interact with your group</p>
+						<p>Blocking an instance will revoke membership from users on that instance and prevent other users on that instance from joining</p>
+						<p>Blocking a user will revoke membership and remove all interactions from that user</p>
+						<p>Moderating an instance will require all new membership requests from that instance to be approved by a group admin before the specific user can join</p>
+					</div>
+					<div class="col-12 col-md-6">
+						<div class="card mb-3">
+							<div class="card-header text-muted font-weight-bold small">Blocked Instances</div>
+							<div class="list-group list-group-flush">
+								<div v-for="instance in blockedInstances" class="list-group-item d-flex justify-content-between align-items-center">
+									<div>
+										{{instance}}
+									</div>
+									<button class="btn btn-light" @click.prevent="undoBlock('instance', instance)">
+										<i class="far fa-trash-alt text-lighter"></i>
+									</button>
+								</div>
+								<div v-if="blockedInstances.length == 3" class="list-group-item">
+									<p class="mb-0 small font-weight-bold text-lighter text-center">View All</p>
+								</div>
+							</div>
+						</div>
+
+						<div class="card mb-3">
+							<div class="card-header text-muted font-weight-bold small">Blocked Users</div>
+							<div class="list-group list-group-flush">
+								<div v-for="instance in blockedUsers" class="list-group-item d-flex justify-content-between align-items-center">
+									<div>
+										<img src="/storage/avatars/default.jpg" width="32" height="32" class="rounded-circle border mr-3">{{instance}}
+									</div>
+									<button class="btn btn-light" @click.prevent="undoBlock('user', instance)">
+										<i class="far fa-trash-alt text-lighter"></i>
+									</button>
+								</div>
+								<div v-if="blockedUsers.length == 3" class="list-group-item">
+									<p class="mb-0 small font-weight-bold text-lighter text-center">View All</p>
+								</div>
+							</div>
+						</div>
+
+						<div class="card mb-3">
+							<div class="card-header text-muted font-weight-bold small">Moderated Join Requests</div>
+							<div class="list-group list-group-flush">
+								<div v-for="instance in moderatedInstances" class="list-group-item d-flex justify-content-between align-items-center">
+									<div>
+										{{instance}}
+									</div>
+									<button class="btn btn-light" @click.prevent="undoBlock('moderate', instance)">
+										<i class="far fa-trash-alt text-lighter"></i>
+									</button>
+								</div>
+							</div>
+						</div>
+					</div>
+					<div class="col-12 col-md-3">
+						<button class="btn btn-light border btn-block font-weight-bold" @click.prevent="blockAction('instance')">Block Instance</button>
+						<button class="btn btn-light border btn-block font-weight-bold" @click.prevent="blockAction('user')">Block User</button>
+						<button class="btn btn-light border btn-block font-weight-bold" @click.prevent="blockAction('moderate')">Moderate Join Requests</button>
+						<hr>
+						<button class="btn btn-light border btn-block font-weight-bold">Import</button>
+						<button class="btn btn-light border btn-block font-weight-bold" @click.prevent="exportBlocks()">Export</button>
+					</div>
+				</div>
+
+				<div v-if="tab == 'advanced'" class="row">
+					<div class="col-12 col-md-6 offset-md-3">
+
+						<div class="mt-3">
+							<div class="form-group">
+								<label class="font-weight-bold">Membership</label>
+								<select class="form-control rounded-pill" v-model="group.membership">
+									<option value="all">Public</option>
+									<option value="private">Private</option>
+									<option value="local">Local</option>
+								</select>
+
+								<p class="help-text mt-1">
+									{{ membershipDescription[group.membership] }}
+								</p>
+							</div>
+							<!-- <hr>
+							<div class="form-group">
+								<label class="font-weight-bold">Post Types</label>
+								<div class="custom-control custom-checkbox mb-2">
+									<input type="checkbox" class="custom-control-input" id="textType" checked disabled>
+									<label class="custom-control-label" for="textType">Text</label>
+								</div>
+								<div class="custom-control custom-checkbox mb-2">
+									<input type="checkbox" class="custom-control-input" id="photoType" checked>
+									<label class="custom-control-label" for="photoType">Photos</label>
+								</div>
+								<div class="custom-control custom-checkbox mb-2">
+									<input type="checkbox" class="custom-control-input" id="videoType" checked>
+									<label class="custom-control-label" for="videoType">Videos</label>
+								</div>
+								<div class="custom-control custom-checkbox mb-2">
+									<input type="checkbox" class="custom-control-input" id="pollType" checked>
+									<label class="custom-control-label" for="pollType">Polls</label>
+								</div>
+								<div class="custom-control custom-checkbox">
+									<input type="checkbox" class="custom-control-input" id="eventType" disabled>
+									<label class="custom-control-label" for="eventType">Events</label>
+								</div>
+							</div> -->
+						</div>
+
+						<hr>
+
+						<div v-if="group.membership !== 'local'" class="form-group row">
+							<div class="col-sm-12">
+								<div class="mb-1">
+									<div class="form-check">
+										<input class="form-check-input" type="checkbox" v-model="advanced.activitypub">
+										<label class="form-check-label font-weight-bold text-dark text-capitalize ml-1">Enable ActivityPub</label>
+									</div>
+								</div>
+
+								<transition name="fade">
+									<div v-if="!advanced.activitypub" class="alert alert-info mt-2">
+										<div class="media align-items-center">
+											<i class="far fa-exclamation-circle fa-2x mr-3"></i>
+											<div class="media-body">
+												<p class="font-weight-bold mb-0">Federation Warning</p>
+												<p class="small mb-0" style="font-weight:600;">Groups that choose to disable federation later will lose remote content and members and cannot re-enable federation for 24 hours. You can change this later</p>
+											</div>
+										</div>
+									</div>
+								</transition>
+							</div>
+						</div>
+
+						<hr v-if="group.membership !== 'local'">
+
+						<div class="form-group row">
+							<div class="col-sm-12">
+								<div class="mb-1">
+									<div class="form-check">
+										<input class="form-check-input" type="checkbox" v-model="advanced.is_nsfw">
+										<label class="form-check-label font-weight-bold text-dark text-capitalize ml-1">Allow adult content (18+)</label>
+									</div>
+								</div>
+
+								<transition name="fade">
+									<div v-if="!advanced.is_nsfw" class="alert alert-info mt-2">
+										<div class="media align-items-center">
+											<i class="far fa-exclamation-circle fa-2x mr-3"></i>
+											<div class="media-body">
+												<p class="font-weight-bold mb-0">Adult Content Warning</p>
+												<p class="small mb-0" style="font-weight:600;">Groups that allow adult content should enable this or risk suspension or deletion by instance admins. Illegal content is prohibited. You can change this later</p>
+											</div>
+										</div>
+									</div>
+								</transition>
+							</div>
+						</div>
+
+						<hr>
+
+						<div class="form-group row">
+							<div class="col-sm-12">
+								<div class="mb-1">
+									<div class="form-check">
+										<input class="form-check-input" type="checkbox" v-model="advanced.discoverable">
+										<label class="form-check-label font-weight-bold text-dark text-capitalize ml-1">Make group discoverable</label>
+									</div>
+
+									<p class="help-text small text-muted">
+										<span>
+											Being discoverable means that your group appears in search results, on the discover page and can be used in group recommendations
+										</span>
+									</p>
+								</div>
+							</div>
+							<hr>
+						</div>
+
+
+						<div v-if="group.member_count >= 25" class="form-group row">
+							<div class="col-sm-12">
+								<div class="mb-1">
+									<div class="form-check">
+										<input class="form-check-input" type="checkbox">
+										<label class="form-check-label font-weight-bold text-dark text-capitalize ml-1">Enable spam detection</label>
+									</div>
+
+									<p class="help-text small text-muted">
+										<span>
+											Detect and temporarily remove content classified as spam from new members until it can be reviewed by a group admin. <strong>We do not recommend enabling this unless you have or expect periodic spam as it may produce false-positives and reduce member experience &amp; retention.</strong>
+										</span>
+									</p>
+								</div>
+							</div>
+							<hr>
+						</div>
+
+
+						<div v-if="group.member_count >= 25"  class="form-group row">
+							<div class="col-sm-12">
+								<div class="mb-1">
+									<div class="form-check">
+										<input class="form-check-input" type="checkbox">
+										<label class="form-check-label font-weight-bold text-dark text-capitalize ml-1">Enable admin direct messages</label>
+									</div>
+
+									<p class="help-text small text-muted">
+										<span>
+											Allow {{ group.membership == 'local' ? 'local users' : group.membership == 'private' ? 'members' : 'anyone'}} to <a href="#">direct message</a> group admins. The direct message inbox is separate from your own account.
+										</span>
+									</p>
+								</div>
+							</div>
+							<hr>
+						</div>
+
+
+						<h4 class="font-weight-bold pt-3">Danger Zone</h4>
+						<div class="mb-4 border rounded border-danger">
+							<ul class="list-group mb-0 pb-0">
+								<li class="list-group-item border-left-0 border-right-0 py-3 d-flex justify-content-between disabled">
+									<div>
+										<p class="font-weight-bold mb-1">Temporarily Disable Group</p>
+										<p class="mb-0 small">Not available</p>
+									</div>
+									<div>
+										<a class="btn btn-outline-danger font-weight-bold py-1" href="#">Disable</a>
+									</div>
+								</li>
+								<li class="list-group-item border-left-0 border-right-0 py-3 d-flex justify-content-between">
+									<div>
+										<p class="font-weight-bold mb-1">Delete Group</p>
+										<p class="mb-0 small">Once you delete your group, there is no going back.</p>
+									</div>
+									<div>
+										<button class="btn btn-outline-danger font-weight-bold py-1" @click="deleteGroup">Delete Group</button>
+									</div>
+								</li>
+							</ul>
+						</div>
+					</div>
+				</div>
+
+				<div v-if="tab == 'limits'" class="row">
+					<div class="col-12 col-md-6 offset-md-3">
+						<div class="mt-3">
+						</div>
+					</div>
+				</div>
+			</div>
+		</div>
+	</div>
+</template>
+
+<script type="text/javascript">
+	export default {
+		props: {
+			groupId: {
+				type: String
+			}
+		},
+
+		data() {
+			return {
+				initalLoad: false,
+				profile: undefined,
+				group: {},
+				isMember: false,
+				isAdmin: false,
+				changed: false,
+				savingChanges: false,
+				categories: [],
+				category: 'General',
+				tab: 'home',
+				tabs: [
+					'home',
+					'customize',
+					'interactions',
+					'blocked',
+					'advanced',
+					'limits',
+					'blocked:import'
+				],
+				interactionLog: [],
+				interactionLogPage: 1,
+				interactionLogInitialLoad: false,
+				interactionLogShowMore: true,
+				blockedInitialLoad: false,
+				blockedInstances: [
+					'facebook.com',
+					'instagram.com'
+				],
+				blockedUsers: [
+					'mark@facebook.com',
+					'user@example.org',
+					'troll'
+				],
+				moderatedInstances: [
+					'pawoo.net',
+					'pixelfed.com'
+				],
+				importBlocksData: {},
+				importBlocksUploaded: false,
+				membershipDescription: {
+					all: 'Anyone can join your group',
+					local: 'Only local users can join your group',
+					private: 'Only users you approve can join your group'
+				},
+				advanced: {}
+			};
+		},
+
+		beforeMount() {
+			axios.get('/api/v0/groups/categories/list')
+				.then(res => {
+					this.categories = res.data;
+				})
+		},
+
+		mounted() {
+			let u = new URLSearchParams(window.location.search);
+
+			if(u.has('tab') && this.tabs.includes(u.get('tab'))) {
+				this.tab = u.get('tab');
+				this.toggleTab(this.tab);
+			}
+
+			axios.get('/api/pixelfed/v1/accounts/verify_credentials')
+			.then(res => {
+				this.profile = res.data;
+
+				axios.get('/api/v0/groups/' + this.groupId)
+				.then(res => {
+					this.group = res.data;
+					this.initalLoad = true;
+					this.isMember = res.data.self.is_member;
+					this.isAdmin = ['founder', 'admin'].includes(res.data.self.role);
+					this.advanced = res.data.config;
+					this.category = res.data.category.name;
+				})
+			});
+		},
+
+		methods: {
+			timestampFormat(date, showTime = false) {
+				let ts = new Date(date);
+				return showTime ? ts.toDateString() + ' · ' + ts.toLocaleTimeString() : ts.toDateString();
+			},
+
+			timeago(ts) {
+				return window.App.util.format.timeAgo(ts);
+			},
+
+			sidToUrl(sid) {
+				return `/groups/${this.groupId}/p/${sid}`;
+			},
+
+			submit() {
+				this.savingChanges = true;
+
+				let formData = new FormData();
+				formData.append('category', this.category);
+				formData.append('membership', this.group.membership);
+				formData.append('discoverable', this.advanced.discoverable);
+				formData.append('activitypub', this.advanced.activitypub);
+				formData.append('is_nsfw', this.advanced.is_nsfw);
+
+				if(this.group.description) {
+					formData.append('description', this.group.description);
+				}
+
+				if(this.$refs.avatarInput) {
+					formData.append('avatar', this.$refs.avatarInput.files[0]);
+				}
+
+				if(this.$refs.headerInput) {
+					formData.append('header', this.$refs.headerInput.files[0]);
+				}
+
+				axios.post('/api/v0/groups/' + this.group.id + '/settings', formData)
+				.then(res => {
+					this.savingChanges = false;
+					this.group = res.data;
+					swal('Updated!', 'Successfully updated group settings.', 'success');
+				}).catch(err => {
+					this.savingChanges = false;
+					console.log(err.response);
+					swal('Oops!', 'An error occured while attempting to save changes. Please try again later.', 'error');
+				});
+			},
+
+			toggleTab(tab) {
+				if(event) {
+					event.currentTarget.blur();
+				}
+
+				switch(tab) {
+					case 'home':
+						this.tab = 'home';
+						history.pushState(null, null, `/groups/${this.groupId}/settings`);
+					break;
+
+					case 'customize':
+						this.tab = 'customize';
+						history.pushState(null, null, `/groups/${this.groupId}/settings?tab=customize`);
+					break;
+
+					case 'limits':
+						this.tab = 'limits';
+						history.pushState(null, null, `/groups/${this.groupId}/settings?tab=limits`);
+					break;
+
+					case 'interactions':
+						if(!this.interactionLogInitialLoad) {
+							this.loadInteractions();
+						}
+						this.tab = 'interactions';
+						history.pushState(null, null, `/groups/${this.groupId}/settings?tab=interactions`);
+					break;
+
+					case 'blocked':
+						if(!this.blockedInitialLoad) {
+							this.loadBlocks();
+						}
+						this.tab = 'blocked';
+						history.pushState(null, null, `/groups/${this.groupId}/settings?tab=blocked`);
+					break;
+
+					case 'advanced':
+						this.tab = 'advanced';
+						history.pushState(null, null, `/groups/${this.groupId}/settings?tab=advanced`);
+					break;
+
+					default:
+						this.tab = 'home';
+						history.pushState(null, null, `/groups/${this.groupId}/settings`);
+					break;
+				}
+			},
+
+			loadInteractions() {
+				axios.get('/api/v0/groups/' + this.groupId + '/admin/interactions')
+				.then(res => {
+					this.interactionLog = res.data;
+					this.interactionLogPage++;
+					this.interactionLogInitialLoad = true;
+				});
+			},
+
+			loadMoreInteractions() {
+				axios.get('/api/v0/groups/' + this.groupId + '/admin/interactions', {
+					params: {
+						page: this.interactionLogPage
+					}
+				}).then(res => {
+					if(res.data.length == 0) {
+						this.interactionLogShowMore = false;
+						return;
+					}
+					this.interactionLog.push(...res.data);
+					this.interactionLogPage++;
+				})
+			},
+
+			loadBlocks() {
+				axios.get(`/api/v0/groups/${this.groupId}/admin/blocks`)
+				.then(res => {
+					this.blockedInstances = res.data.instances;
+					this.blockedUsers = res.data.users;
+					this.moderatedInstances = res.data.moderated;
+					this.blockedInitialLoad = true;
+				})
+			},
+
+			blockAction(action) {
+				let type = action == 'user' ? 'user' : 'instance domain';
+
+				swal({
+					text: `Which ${type}?`,
+					content: {
+						element: 'input',
+						attributes: {
+      						placeholder: type == 'user' ? 'pixelfed' : 'pixelfed.org'
+      					}
+					},
+					button: {
+						text: "Next",
+						closeModal: false,
+					},
+				})
+				.then(name => {
+					if (!name) throw null;
+					if(action !== 'user' && name.startsWith('http')) {
+						swal('Oops!', 'Please enter the instance domain (eg: pixelfed.social)', 'error');
+						return null;
+					}
+					return name;
+				}).then(name => {
+					return axios.post('/api/v0/groups/' + this.groupId + '/admin/mbs', {
+						type: action == 'user' ? 'user' : 'instance',
+						item: name
+					}).then(res => {
+						if(res.data) {
+							return name;
+						} else {
+							swal.stopLoading();
+							swal.close();
+							return null;
+						}
+					}).catch(err => {
+						swal.stopLoading();
+						swal.close();
+						return null;
+					});
+				}).then(name => {
+					if(!name) {
+						this.$bvToast.toast(`Invalid ${action}, please try again`, {
+							title: 'Error',
+							variant: 'danger',
+							autoHideDelay: 5000
+						});
+						return;
+					}
+					swal({
+						title: "Are you sure?",
+						text: action === 'moderate' ? `Manually approve all membership requests from ${name}` : `Limiting ${name} will purge and reject all interactions with this group`,
+						icon: "warning",
+						buttons: true,
+						dangerMode: true,
+					})
+					.then((confirm) => {
+						if (confirm) {
+							axios.post('/api/v0/groups/' + this.groupId + '/admin/blocks/add', {
+								item: name,
+								type: action
+							}).then(res => {
+								switch(action) {
+									case 'instance':
+										this.blockedInstances.push(name);
+									break;
+
+									case 'user':
+										this.blockedUsers.push(name);
+									break;
+
+									case 'moderate':
+										this.moderatedInstances.push(name);
+									break;
+								}
+								// swal("Poof! Your imaginary file has been deleted!", {
+								// 	icon: "success",
+								// });
+							})
+						}
+					});
+				});
+			},
+
+			reportUrl(report) {
+				return `/groups/${this.groupId}/moderation?tab=view&id=${report}`;
+			},
+
+			memberInteractionUrl(pid) {
+				return `/groups/${this.groupId}/members?a=il&pid=${pid}`;
+			},
+
+			undoBlock(type, val) {
+				let action = type == 'moderate' ? `unblock ${val}?` : `allow anyone to join without approval from ${val}?`;
+				swal({
+					'title': 'Confirm',
+					'text': `Are you sure you want to ${action}`,
+					'buttons': {
+						cancel: {
+							text: "Cancel",
+							value: null,
+							visible: true,
+							className: "",
+							closeModal: true,
+						},
+						confirm: {
+							text: "Proceed",
+							value: true,
+							visible: true,
+							className: "",
+							closeModal: true
+						}
+					}
+				}).then(res => {
+					if(res) {
+						axios.post(`/api/v0/groups/${this.groupId}/admin/blocks/undo`, {
+							item: val,
+							type: type
+						}).then(res => {
+							switch(type) {
+								case 'instance':
+									this.blockedInstances = this.blockedInstances.filter(i => {
+										return i != val;
+									})
+								break;
+
+								case 'user':
+									this.blockedUsers = this.blockedUsers.filter(i => {
+										return i != val;
+									})
+								break;
+
+								case 'moderate':
+									this.moderatedInstances = this.moderatedInstances.filter(i => {
+										return i != val;
+									})
+								break;
+							}
+						})
+					}
+				});
+			},
+
+			exportBlocks() {
+				event.currentTarget.blur();
+				axios({
+					url: '/api/v0/groups/'+this.groupId+'/admin/blocks/export',
+					method: 'POST',
+					responseType: 'blob',
+				}).then((response) => {
+					const url = window.URL.createObjectURL(new Blob([response.data]));
+					const link = document.createElement('a');
+					link.href = url;
+					link.setAttribute('download', `pixelfed-group-blocks-${Date.now()}.json`);
+					document.body.appendChild(link);
+					link.click();
+				});
+			},
+
+			deleteGroup() {
+				axios.post('/api/v0/groups/delete', {
+					gid: this.groupId
+				})
+				.then(res => {
+					location.href = `/groups/${this.groupId}`;
+				})
+			}
+		}
+	}
+</script>
+
+<style lang="scss">
+	.group-settings-component {
+		&-header {
+			display: flex;
+			justify-content: space-between;
+			align-items: flex-end;
+			padding: 2rem 1rem 1rem 1rem;
+			background-color: #fff;
+
+			.cta-btn {
+				min-width: 140px;
+			}
+		}
+	}
+</style>

+ 170 - 0
resources/assets/components/groups/GroupTopicFeed.vue

@@ -0,0 +1,170 @@
+<template>
+	<div class="group-topic-feed-component">
+		<div v-if="isLoaded" class="bg-white py-5 border-bottom">
+			<div class="container">
+				<div class="d-flex justify-content-between align-items-center">
+					<div>
+						<h3 class="font-weight-bold mb-1">#{{ name }}</h3>
+						<p class="mb-0 lead text-muted">
+							<span>
+								Posts in <a :href="group.url" class="text-muted font-weight-bold">{{ group.name }}</a>
+							</span>
+							<span>·</span>
+							<span><i class="fas fa-globe"></i></span>
+							<span>·</span>
+							<span>{{ group.membership != 'all' ? 'Private' : 'Public'}} Group</span>
+						</p>
+					</div>
+					<!-- <div>
+						<button class="btn btn-light border btn-lg text-muted">
+							<i class="fas fa-ellipsis-h"></i>
+						</button>
+					</div> -->
+				</div>
+			</div>
+		</div>
+		<div v-if="isLoaded" class="row justify-content-center mt-3">
+			<div v-if="feed.length" class="col-12 col-md-5">
+				<group-status
+					v-for="(status, index) in feed"
+					:key="'gs:' + status.id + index"
+					:prestatus="status"
+					:profile="profile"
+					:group="group"
+					:show-group-chevron="true"
+					:group-id="gid" />
+
+				<div v-if="feed.length > 2">
+					<infinite-loading @infinite="infiniteFeed">
+						<div slot="no-more"></div>
+						<div slot="no-results"></div>
+					</infinite-loading>
+				</div>
+			</div>
+			<div v-else class="col-12 col-md-5 d-flex justify-content-center">
+				<div class="mt-5">
+					<p class="text-lighter text-center">
+						<i class="fal fa-exclamation-circle fa-4x"></i>
+					</p>
+
+					<p class="font-weight-bold text-muted">Cannot load any posts containg the <span class="font-weight-normal">#{{ name }}</span> hashtag</p>
+
+					<p class="text-left">
+						This can happen for a few reasons:
+					</p>
+
+					<ul class="text-left">
+						<li>There is a typo in the url</li>
+						<li>No posts exist that contain this hashtag</li>
+						<li>This hashtag has been banned by group admins</li>
+						<li>The hashtag is new or used infrequently</li>
+						<li>A technical issue has occured</li>
+					</ul>
+				</div>
+			</div>
+		</div>
+	</div>
+</template>
+
+<script type="text/javascript">
+	import GroupStatus from '@/groups/partials/GroupStatus.vue';
+
+	export default {
+		props: {
+			gid: {
+				type: String
+			},
+
+			name: {
+				type: String
+			}
+		},
+
+		components: {
+			GroupStatus
+		},
+
+		data() {
+			return {
+				isLoaded: false,
+				group: false,
+				profile: false,
+				feed: [],
+				page: 1,
+				ids: []
+			}
+		},
+
+		mounted() {
+			this.fetchProfile();
+		},
+
+		methods: {
+			fetchProfile() {
+				axios.get('/api/pixelfed/v1/accounts/verify_credentials')
+				.then(res => {
+					this.profile = res.data;
+					this.fetchGroup();
+				});
+			},
+
+			fetchGroup() {
+				axios.get('/api/v0/groups/' + this.gid)
+				.then(res => {
+					this.group = res.data;
+					this.fetchFeed();
+				});
+			},
+
+			fetchFeed() {
+				axios.get('/api/v0/groups/topics/tag', {
+					params: {
+						gid: this.gid,
+						name: this.name
+					}
+				}).then(res => {
+					this.feed = res.data;
+					this.isLoaded = true;
+					let self = this;
+					res.data.forEach(d => {
+						if(self.ids.indexOf(d.id) == -1) {
+							self.ids.push(d.id);
+						}
+					});
+					this.page++;
+				})
+			},
+
+			infiniteFeed($state) {
+				if(this.feed.length < 2) {
+					$state.complete();
+					return;
+				}
+
+				axios.get('/api/v0/groups/topics/tag', {
+					params: {
+						gid: this.gid,
+						name: this.name,
+						limit: 1,
+						page: this.page
+					},
+				}).then(res => {
+					if (res.data.length) {
+						let data = res.data;
+						let self = this;
+						data.forEach(d => {
+							if(self.ids.indexOf(d.id) == -1) {
+								self.ids.push(d.id);
+								self.feed.push(d);
+							}
+						});
+						$state.loaded();
+						this.page++;
+					} else {
+						$state.complete();
+					}
+				});
+			}
+		}
+	}
+</script>

+ 473 - 0
resources/assets/components/groups/GroupsHome.vue

@@ -0,0 +1,473 @@
+<template>
+	<div class="groups-home-component w-100 h-100">
+		<div v-if="initialLoad" class="row border-bottom m-0 p-0">
+			<div class="col-2 shadow" style="height: 100vh;background:#fff;top:51px;overflow: hidden;z-index: 1;position: sticky;">
+				<div class="p-1">
+					<div class="d-flex justify-content-between align-items-center py-3">
+						<p class="h2 font-weight-bold mb-0">Groups</p>
+						<a class="btn btn-light px-2 rounded-circle" href="/settings/home">
+							<i class="fas fa-cog fa-lg"></i>
+						</a>
+					</div>
+
+					<div class="mb-3">
+						<autocomplete
+							:search="autocompleteSearch"
+							placeholder="Search groups by name"
+							aria-label="Search groups by name"
+							:get-result-value="getSearchResultValue"
+							:debounceTime="700"
+							@submit="onSearchSubmit"
+							ref="autocomplete"
+							>
+							<template #result="{ result, props }">
+								<li
+								v-bind="props"
+								class="autocomplete-result"
+								>
+
+									<div class="media align-items-center">
+										<img v-if="result.local && result.metadata && result.metadata.hasOwnProperty('header') && result.metadata.header.hasOwnProperty('url')" :src="result.metadata.header.url" width="32" height="32">
+										<div v-else class="icon-placeholder">
+											<i class="fal fa-user-friends"></i>
+										</div>
+										<div class="media-body text-truncate mr-3">
+											<p class="result-name mb-n1 font-weight-bold">
+												{{ truncateName(result.name) }}
+												<span v-if="result.verified" class="fa-stack ml-n2" title="Verified Group" data-toggle="tooltip">
+													<i class="fas fa-circle fa-stack-1x fa-lg" style="color: #22a7f0cc;font-size:18px"></i>
+													<i class="fas fa-check fa-stack-1x text-white" style="font-size:10px"></i>
+												</span>
+											</p>
+											<p class="mb-0 text-muted" style="font-size: 10px;">
+												<span v-if="!result.local" title="Remote Group">
+													<i class="far fa-globe"></i>
+												</span>
+												<span v-if="!result.local">·</span>
+												<span class="font-weight-bold">{{ result.member_count }} members</span>
+											</p>
+										</div>
+									</div>
+								</li>
+							</template>
+						</autocomplete>
+					</div>
+
+					<button
+						class="btn btn-light group-nav-btn"
+						:class="{ active: tab == 'feed' }"
+						@click="switchTab('feed')">
+						<div class="group-nav-btn-icon">
+							<i class="fas fa-list"></i>
+						</div>
+						<div class="group-nav-btn-name">
+							Your Feed
+						</div>
+					</button>
+
+					<button
+						class="btn btn-light group-nav-btn"
+						:class="{ active: tab == 'discover' }"
+						@click="switchTab('discover')">
+						<div class="group-nav-btn-icon">
+							<i class="fas fa-compass"></i>
+						</div>
+						<div class="group-nav-btn-name">
+							Discover
+						</div>
+					</button>
+
+					<button
+						class="btn btn-light group-nav-btn"
+						:class="{ active: tab == 'mygroups' }"
+						@click="switchTab('mygroups')">
+						<div class="group-nav-btn-icon">
+							<i class="fas fa-list"></i>
+						</div>
+						<div class="group-nav-btn-name">
+							My Groups
+						</div>
+					</button>
+
+					<button
+						class="btn btn-light group-nav-btn"
+						:class="{ active: tab == 'notifications' }"
+						@click="switchTab('notifications')">
+						<div class="group-nav-btn-icon">
+							<i class="far fa-bell"></i>
+						</div>
+						<div class="group-nav-btn-name">
+							Your Notifications
+						</div>
+					</button>
+
+					<!-- <button
+						class="btn btn-light group-nav-btn"
+						:class="{ active: tab == 'invitations' }"
+						@click="switchTab('invitations')">
+						<div class="group-nav-btn-icon">
+							<i class="fas fa-user-plus"></i>
+						</div>
+						<div class="group-nav-btn-name">
+							Group Invitations
+						</div>
+					</button> -->
+
+					<button
+						class="btn btn-light group-nav-btn"
+						:class="{ active: tab == 'remotesearch' }"
+						@click="switchTab('remotesearch')">
+						<div class="group-nav-btn-icon">
+							<i class="fas fa-search-plus"></i>
+						</div>
+						<div class="group-nav-btn-name">
+							Find a remote group
+						</div>
+					</button>
+
+					<button
+						v-if="config && config.limits.user.create.new"
+						class="btn btn-primary btn-block rounded-pill font-weight-bold mt-3"
+						@click="switchTab('creategroup')"
+						:disabled="tab == 'creategroup'">
+						<i class="fas fa-plus mr-2"></i> Create New Group
+					</button>
+
+					<hr>
+					<div v-for="group in groups" class="ml-2">
+						<div class="card shadow-sm border text-decoration-none text-dark">
+							<img v-if="group.metadata && group.metadata.hasOwnProperty('header')" :src="group.metadata.header.url" class="card-img-top" style="width: 100%; height: auto;object-fit: cover;max-height: 160px;">
+							<div v-else class="bg-primary" style="width:100%;height:160px;"></div>
+							<div class="card-body">
+								<div class="lead font-weight-bold d-flex align-items-top" style="height: 60px;">
+									{{ group.name }}
+									<span v-if="group.verified" class="fa-stack ml-n2 mt-n2">
+										<i class="fas fa-circle fa-stack-1x fa-lg" style="color: #22a7f0cc;font-size:18px"></i>
+										<i class="fas fa-check fa-stack-1x text-white" style="font-size:10px"></i>
+									</span>
+								</div>
+								<div class="text-muted font-weight-light d-flex justify-content-between">
+									<span>{{group.member_count}} Members</span>
+									<span style="font-size: 12px;padding: 2px 5px;color: rgba(75, 119, 190, 1);background:rgba(137, 196, 244, 0.2);border:1px solid rgba(137, 196, 244, 0.3);font-weight:400;text-transform: capitalize;" class="rounded">{{ group.self.role }}</span>
+								</div>
+								<hr>
+								<p class="mb-0">
+									<a class="btn btn-light btn-block border rounded-lg font-weight-bold" :href="group.url">View Group</a>
+								</p>
+							</div>
+						</div>
+					</div>
+				</div>
+			</div>
+
+			<keep-alive>
+				<transition name="fade">
+					<self-feed v-if="tab == 'feed'" :profile="profile" v-on:switchtab="switchTab" />
+					<self-discover v-if="tab == 'discover'" :profile="profile" />
+					<self-notifications v-if="tab == 'notifications'" :profile="profile" />
+					<self-invitations v-if="tab == 'invitations'" :profile="profile" />
+					<self-remote-search v-if="tab == 'remotesearch'" :profile="profile" />
+					<self-groups v-if="tab == 'mygroups'" :profile="profile" />
+					<create-group v-if="tab == 'creategroup'" :profile="profile" />
+					<div v-if="tab == 'gsearch'">
+						<div class="col-12 px-5">
+							<div class="my-4">
+		                        <p class="h1 font-weight-bold mb-1">Group Search</p>
+		                        <p class="lead text-muted mb-0">Search and explore groups.</p>
+		                    </div>
+		                    <div class="media align-items-center text-lighter">
+		                    	<i class="far fa-chevron-left fa-lg mr-3"></i>
+		                    	<div class="media-body">
+		                    		<p class="lead mb-0">Use the search bar on the side menu</p>
+		                    	</div>
+		                    </div>
+						</div>
+					</div>
+				</transition>
+			</keep-alive>
+		</div>
+		<div v-else class="row justify-content-center mt-5">
+			<b-spinner />
+		</div>
+	</div>
+</template>
+
+<script type="text/javascript">
+	import GroupStatus from './partials/GroupStatus.vue';
+	import SelfFeed from './partials/SelfFeed.vue';
+	import SelfDiscover from './partials/SelfDiscover.vue';
+	import SelfGroups from './partials/SelfGroups.vue';
+	import SelfNotifications from './partials/SelfNotifications.vue';
+	import SelfInvitations from './partials/SelfInvitations.vue';
+	import SelfRemoteSearch from './partials/SelfRemoteSearch.vue';
+	import CreateGroup from './CreateGroup.vue';
+	import Autocomplete from '@trevoreyre/autocomplete-vue'
+	import '@trevoreyre/autocomplete-vue/dist/style.css'
+
+	export default {
+		data() {
+			return {
+				initialLoad: false,
+				config: {},
+				groups: [],
+				profile: {},
+				tab: null,
+				searchQuery: undefined,
+			};
+		},
+
+		components: {
+			'autocomplete-input': Autocomplete,
+			'group-status': GroupStatus,
+			'self-discover': SelfDiscover,
+			'self-groups': SelfGroups,
+			'self-feed': SelfFeed,
+			'self-notifications': SelfNotifications,
+			'self-invitations': SelfInvitations,
+			'self-remote-search': SelfRemoteSearch,
+			"create-group": CreateGroup
+		},
+
+		mounted() {
+			this.fetchConfig();
+		},
+
+		methods: {
+			init() {
+				document.querySelectorAll("footer").forEach(e => e.parentNode.removeChild(e));
+				document.querySelectorAll(".mobile-footer-spacer").forEach(e => e.parentNode.removeChild(e));
+				document.querySelectorAll(".mobile-footer").forEach(e => e.parentNode.removeChild(e));
+				// let u = new URLSearchParams(window.location.search);
+				// if(u.has('ct')) {
+				// 	if(['mygroups', 'notifications', 'discover', 'remotesearch', 'creategroup', 'gsearch'].includes(u.get('ct'))) {
+				// 		if(u.get('ct') == 'creategroup' && this.config.limits.user.create.new == false) {
+				// 			this.tab = 'feed';
+				// 			history.pushState(null, null, '/groups/feed');
+				// 		} else {
+				// 			this.tab = u.get('ct');
+				// 		}
+				// 	} else {
+				// 		this.tab = 'feed';
+				// 		history.pushState(null, null, '/groups/feed');
+				// 	}
+				// } else {
+				// 	this.tab = 'feed';
+				// }
+				this.initialLoad = true;
+			},
+
+			fetchConfig() {
+				axios.get('/api/v0/groups/config')
+				.then(res => {
+					this.config = res.data;
+					this.fetchProfile();
+				});
+			},
+
+			fetchProfile() {
+				axios.get('/api/pixelfed/v1/accounts/verify_credentials')
+				.then(res => {
+					this.profile = res.data;
+					this.init();
+					window._sharedData.curUser = res.data;
+					window.App.util.navatar();
+				})
+			},
+
+			fetchSelfGroups() {
+				axios.get('/api/v0/groups/self/list')
+				.then(res => {
+					this.groups = res.data;
+				});
+			},
+
+			switchTab(tab) {
+				event.currentTarget.blur();
+				window.scrollTo(0,0);
+				this.tab = tab;
+
+				if(tab != 'feed') {
+					history.pushState(null, null, '/groups/home?ct=' + tab);
+				} else {
+					history.pushState(null, null, '/groups/home');
+				}
+			},
+
+			autocompleteSearch(input) {
+				if (!input || input.length < 2) {
+					if(this.tab = 'searchresults') {
+						this.tab = 'feed';
+					}
+					return [];
+				};
+
+				this.searchQuery = input;
+				// this.tab = 'searchresults';
+
+				if(input.startsWith('http')) {
+					let url = new URL(input);
+					if(url.hostname == location.hostname) {
+						location.href = input;
+						return [];
+					}
+					return [];
+				}
+
+				if(input.startsWith('#')) {
+					this.$bvToast.toast(input, {
+						title: 'Hashtag detected',
+						variant: 'info',
+						autoHideDelay: 5000
+					});
+					return [];
+				}
+
+				return axios.post('/api/v0/groups/search/global', {
+					q: input,
+					v: '0.2'
+				})
+				.then(res => {
+					this.searchLoading = false;
+					return res.data;
+				}).catch(err => {
+
+					if(err.response.status === 422) {
+						this.$bvToast.toast(err.response.data.error.message, {
+							title: 'Cannot display search results',
+							variant: 'danger',
+							autoHideDelay: 5000
+						});
+					}
+
+					return [];
+				})
+			},
+
+			getSearchResultValue(result) {
+				return result.name;
+			},
+
+			onSearchSubmit(result) {
+				if (result.length < 1) {
+					return [];
+				}
+
+				location.href = result.url;
+			},
+
+			truncateName(val) {
+				if(val.length < 24) {
+					return val;
+				}
+
+				return val.substr(0, 23) + '...';
+			}
+		}
+	}
+</script>
+
+<style lang="scss">
+	.groups-home-component {
+        font-family: var(--font-family-sans-serif);
+
+		.group-nav-btn {
+			display: block;
+			width: 100%;
+			padding-left: 0;
+			padding-top: 0.3rem;
+			padding-bottom: 0.3rem;
+			margin-bottom: 0.3rem;
+			border-radius: 1.5rem;
+			text-align: left;
+			color: #6c757d;
+			background-color: transparent;
+			border-color: transparent;
+			justify-content: flex-start;
+
+			&.active {
+				background-color: #EFF6FF !important;
+				border:1px solid #DBEAFE !important;
+				color: #212529;
+
+				.group-nav-btn-icon {
+					background-color: #2c78bf !important;
+					color: #fff !important;
+				}
+			}
+
+			&-icon {
+				display: inline-flex;
+				width: 35px;
+				height: 35px;
+				padding: 12px;
+				background-color: #E5E7EB;
+				border-radius: 17px;
+				margin: auto 0.3rem;
+				align-items: center;
+				justify-content: center;
+			}
+
+			&-name {
+				display: inline-block;
+				margin-left: 0.3rem;
+				font-weight: 700;
+			}
+		}
+
+		.autocomplete-input {
+			height: 2.375rem;
+			background-color: #f8f9fa !important;
+			font-size: 0.9rem;
+			color: #495057;
+			border-radius: 50rem;
+			border-color: transparent;
+
+			&:focus,
+			&[aria-expanded=true] {
+				box-shadow: none;
+			}
+		}
+
+		.autocomplete-result {
+			background: none;
+			padding: 12px;
+
+			&:hover,
+			&:focus {
+				background-color: #EFF6FF !important;
+			}
+
+			.media {
+				img {
+					object-fit: cover;
+					border-radius: 4px;
+					margin-right: 0.6rem;
+				}
+
+				.icon-placeholder {
+					display: flex;
+					width: 32px;
+					height: 32px;
+					background-color: #2c78bf;
+					border-radius: 4px;
+					justify-content: center;
+					align-items: center;
+					color: #fff;
+					margin-right: 0.6rem;
+				}
+			}
+		}
+
+		.autocomplete-result-list {
+			padding-bottom: 0;
+		}
+
+		.fade-enter-active, .fade-leave-active {
+			transition: opacity 200ms;
+		}
+
+		.fade-enter, .fade-leave-to {
+			opacity: 0;
+		}
+	}
+</style>

+ 168 - 0
resources/assets/components/groups/Page/GroupAbout.vue

@@ -0,0 +1,168 @@
+<template>
+    <div class="group-feed-component">
+        <div class="row border-bottom m-0 p-0">
+            <sidebar />
+
+            <div class="col-12 col-md-9 px-md-0">
+                <div class="bg-white mb-3 border-bottom">
+                    <div>
+                        <group-banner :group="group" />
+                        <group-header-details
+                            :group="group"
+                            :isAdmin="isAdmin"
+                            :isMember="isMember"
+                            @refresh="handleRefresh"
+                        />
+                        <group-nav-tabs
+                            :group="group"
+                            :isAdmin="isAdmin"
+                            :isMember="isMember"
+                            :atabs="atabs"
+                        />
+                    </div>
+                </div>
+
+                <div v-if="!initialLoad">
+                    <p class="text-center mt-5 pt-5 font-weight-bold">Loading...</p>
+                </div>
+
+                <template v-else>
+                    <div class="container-xl group-feed-component-body">
+                        <template v-if="initialLoad && group.self.is_member">
+                            <group-about :key="renderIdx" :group="group" :profile="profile" />
+                        </template>
+
+                        <member-only-warning v-else />
+                    </div>
+                </template>
+            </div>
+        </div>
+    </div>
+</template>
+
+<script type="text/javascript">
+    import StatusCard from '~/partials/StatusCard.vue';
+    import GroupMembers from '@/groups/partials/GroupMembers.vue';
+    import GroupCompose from '@/groups/partials/GroupCompose.vue';
+    import GroupStatus from '@/groups/partials/GroupStatus.vue';
+    import GroupAbout from '@/groups/partials/GroupAbout.vue';
+    import GroupMedia from '@/groups/partials/GroupMedia.vue';
+    import GroupModeration from '@/groups/partials/GroupModeration.vue';
+    import GroupTopics from '@/groups/partials/GroupTopics.vue';
+    import GroupInfoCard from '@/groups/partials/GroupInfoCard.vue';
+    import LeaveGroup from '@/groups/partials/LeaveGroup.vue';
+    import GroupInsights from '@/groups/partials/GroupInsights.vue';
+    import SearchModal from '@/groups/partials/GroupSearchModal.vue';
+    import InviteModal from '@/groups/partials/GroupInviteModal.vue';
+    import SidebarComponent from '@/groups/sections/Sidebar.vue';
+    import GroupBanner from '@/groups/partials/Page/GroupBanner.vue';
+    import GroupHeaderDetails from '@/groups/partials/Page/GroupHeaderDetails.vue';
+    import GroupNavTabs from '@/groups/partials/Page/GroupNavTabs.vue';
+    import MemberOnlyWarning from '@/groups/partials/Membership/MemberOnlyWarning.vue';
+
+    export default {
+        props: {
+            groupId: {
+                type: String
+            },
+
+            path: {
+                type: String
+            }
+        },
+
+        components: {
+            'status-card': StatusCard,
+            'group-about': GroupAbout,
+            'group-status': GroupStatus,
+            'group-members': GroupMembers,
+            'group-compose': GroupCompose,
+            'group-topics': GroupTopics,
+            'group-info-card': GroupInfoCard,
+            'group-media': GroupMedia,
+            'group-moderation': GroupModeration,
+            'leave-group': LeaveGroup,
+            'group-insights': GroupInsights,
+            'search-modal': SearchModal,
+            'invite-modal': InviteModal,
+            'sidebar': SidebarComponent,
+            'group-banner': GroupBanner,
+            'group-header-details': GroupHeaderDetails,
+            'group-nav-tabs': GroupNavTabs,
+            'member-only-warning': MemberOnlyWarning
+        },
+
+        data() {
+            return {
+                initialLoad: false,
+                profile: undefined,
+                group: {},
+                isMember: false,
+                isAdmin: false,
+                renderIdx: 1,
+                atabs: {
+                    moderation_count: 0,
+                    request_count: 0
+                }
+            };
+        },
+
+        created() {
+            this.init();
+        },
+
+        methods: {
+            init() {
+                this.initialLoad = false;
+                axios.get('/api/pixelfed/v1/accounts/verify_credentials')
+                .then(res => {
+                    this.profile = res.data;
+                    this.fetchGroup();
+                })
+                .catch(err => {
+                    window.location.href = '/login?_next=' + encodeURIComponent(window.location.href);
+                });
+            },
+
+            handleRefresh() {
+                this.initialLoad = false;
+                this.init();
+                this.renderIdx++;
+            },
+
+            fetchGroup() {
+                axios.get('/api/v0/groups/' + this.groupId)
+                .then(res => {
+                    this.group = res.data;
+                    this.isMember = res.data.self.is_member;
+                    this.isAdmin = ['founder', 'admin'].includes(res.data.self.role);
+
+                    if(this.isAdmin) {
+                        this.fetchAdminTabs();
+                    }
+
+                    this.initialLoad = true;
+                })
+                .catch(err => {
+                    //window.location.href = '/groups/unavailable';
+                    alert('error');
+                });
+            },
+
+            fetchAdminTabs() {
+                axios.get('/api/v0/groups/' + this.groupId + '/atabs')
+                .then(res => {
+                    this.atabs = res.data;
+                })
+            }
+        }
+    }
+</script>
+
+<style lang="scss">
+    .group-feed-component {
+        &-body {
+            min-height: 40vh;
+        }
+    }
+</style>

+ 168 - 0
resources/assets/components/groups/Page/GroupMedia.vue

@@ -0,0 +1,168 @@
+<template>
+    <div class="group-feed-component">
+        <div class="row border-bottom m-0 p-0">
+            <sidebar />
+
+            <div class="col-12 col-md-9 px-md-0">
+                <div class="bg-white mb-3 border-bottom">
+                    <div>
+                        <group-banner :group="group" />
+                        <group-header-details
+                            :group="group"
+                            :isAdmin="isAdmin"
+                            :isMember="isMember"
+                            @refresh="handleRefresh"
+                        />
+                        <group-nav-tabs
+                            :group="group"
+                            :isAdmin="isAdmin"
+                            :isMember="isMember"
+                            :atabs="atabs"
+                        />
+                    </div>
+                </div>
+
+                <div v-if="!initialLoad">
+                    <p class="text-center mt-5 pt-5 font-weight-bold">Loading...</p>
+                </div>
+
+                <template v-else>
+                    <div class="container-xl group-feed-component-body">
+                        <template v-if="initialLoad && group.self.is_member">
+                            <group-media :key="renderIdx" :group="group" :profile="profile" />
+                        </template>
+
+                        <member-only-warning v-else />
+                    </div>
+                </template>
+            </div>
+        </div>
+    </div>
+</template>
+
+<script type="text/javascript">
+    import StatusCard from '~/partials/StatusCard.vue';
+    import GroupMembers from '@/groups/partials/GroupMembers.vue';
+    import GroupCompose from '@/groups/partials/GroupCompose.vue';
+    import GroupStatus from '@/groups/partials/GroupStatus.vue';
+    import GroupAbout from '@/groups/partials/GroupAbout.vue';
+    import GroupMedia from '@/groups/partials/GroupMedia.vue';
+    import GroupModeration from '@/groups/partials/GroupModeration.vue';
+    import GroupTopics from '@/groups/partials/GroupTopics.vue';
+    import GroupInfoCard from '@/groups/partials/GroupInfoCard.vue';
+    import LeaveGroup from '@/groups/partials/LeaveGroup.vue';
+    import GroupInsights from '@/groups/partials/GroupInsights.vue';
+    import SearchModal from '@/groups/partials/GroupSearchModal.vue';
+    import InviteModal from '@/groups/partials/GroupInviteModal.vue';
+    import SidebarComponent from '@/groups/sections/Sidebar.vue';
+    import GroupBanner from '@/groups/partials/Page/GroupBanner.vue';
+    import GroupHeaderDetails from '@/groups/partials/Page/GroupHeaderDetails.vue';
+    import GroupNavTabs from '@/groups/partials/Page/GroupNavTabs.vue';
+    import MemberOnlyWarning from '@/groups/partials/Membership/MemberOnlyWarning.vue';
+
+    export default {
+        props: {
+            groupId: {
+                type: String
+            },
+
+            path: {
+                type: String
+            }
+        },
+
+        components: {
+            'status-card': StatusCard,
+            'group-about': GroupAbout,
+            'group-status': GroupStatus,
+            'group-members': GroupMembers,
+            'group-compose': GroupCompose,
+            'group-topics': GroupTopics,
+            'group-info-card': GroupInfoCard,
+            'group-media': GroupMedia,
+            'group-moderation': GroupModeration,
+            'leave-group': LeaveGroup,
+            'group-insights': GroupInsights,
+            'search-modal': SearchModal,
+            'invite-modal': InviteModal,
+            'sidebar': SidebarComponent,
+            'group-banner': GroupBanner,
+            'group-header-details': GroupHeaderDetails,
+            'group-nav-tabs': GroupNavTabs,
+            'member-only-warning': MemberOnlyWarning
+        },
+
+        data() {
+            return {
+                initialLoad: false,
+                profile: undefined,
+                group: {},
+                isMember: false,
+                isAdmin: false,
+                renderIdx: 1,
+                atabs: {
+                    moderation_count: 0,
+                    request_count: 0
+                }
+            };
+        },
+
+        created() {
+            this.init();
+        },
+
+        methods: {
+            init() {
+                this.initialLoad = false;
+                axios.get('/api/pixelfed/v1/accounts/verify_credentials')
+                .then(res => {
+                    this.profile = res.data;
+                    this.fetchGroup();
+                })
+                .catch(err => {
+                    window.location.href = '/login?_next=' + encodeURIComponent(window.location.href);
+                });
+            },
+
+            handleRefresh() {
+                this.initialLoad = false;
+                this.init();
+                this.renderIdx++;
+            },
+
+            fetchGroup() {
+                axios.get('/api/v0/groups/' + this.groupId)
+                .then(res => {
+                    this.group = res.data;
+                    this.isMember = res.data.self.is_member;
+                    this.isAdmin = ['founder', 'admin'].includes(res.data.self.role);
+
+                    if(this.isAdmin) {
+                        this.fetchAdminTabs();
+                    }
+
+                    this.initialLoad = true;
+                })
+                .catch(err => {
+                    //window.location.href = '/groups/unavailable';
+                    alert('error');
+                });
+            },
+
+            fetchAdminTabs() {
+                axios.get('/api/v0/groups/' + this.groupId + '/atabs')
+                .then(res => {
+                    this.atabs = res.data;
+                })
+            }
+        }
+    }
+</script>
+
+<style lang="scss">
+    .group-feed-component {
+        &-body {
+            min-height: 40vh;
+        }
+    }
+</style>

+ 168 - 0
resources/assets/components/groups/Page/GroupMembers.vue

@@ -0,0 +1,168 @@
+<template>
+    <div class="group-feed-component">
+        <div class="row border-bottom m-0 p-0">
+            <sidebar />
+
+            <div class="col-12 col-md-9 px-md-0">
+                <div class="bg-white mb-3 border-bottom">
+                    <div>
+                        <group-banner :group="group" />
+                        <group-header-details
+                            :group="group"
+                            :isAdmin="isAdmin"
+                            :isMember="isMember"
+                            @refresh="handleRefresh"
+                        />
+                        <group-nav-tabs
+                            :group="group"
+                            :isAdmin="isAdmin"
+                            :isMember="isMember"
+                            :atabs="atabs"
+                        />
+                    </div>
+                </div>
+
+                <div v-if="!initialLoad">
+                    <p class="text-center mt-5 pt-5 font-weight-bold">Loading...</p>
+                </div>
+
+                <template v-else>
+                    <div class="container-xl group-feed-component-body">
+                        <template v-if="initialLoad && group.self.is_member">
+                            <group-members :key="renderIdx" :group="group" :profile="profile" />
+                        </template>
+
+                        <member-only-warning v-else />
+                    </div>
+                </template>
+            </div>
+        </div>
+    </div>
+</template>
+
+<script type="text/javascript">
+    import StatusCard from '~/partials/StatusCard.vue';
+    import GroupMembers from '@/groups/partials/GroupMembers.vue';
+    import GroupCompose from '@/groups/partials/GroupCompose.vue';
+    import GroupStatus from '@/groups/partials/GroupStatus.vue';
+    import GroupAbout from '@/groups/partials/GroupAbout.vue';
+    import GroupMedia from '@/groups/partials/GroupMedia.vue';
+    import GroupModeration from '@/groups/partials/GroupModeration.vue';
+    import GroupTopics from '@/groups/partials/GroupTopics.vue';
+    import GroupInfoCard from '@/groups/partials/GroupInfoCard.vue';
+    import LeaveGroup from '@/groups/partials/LeaveGroup.vue';
+    import GroupInsights from '@/groups/partials/GroupInsights.vue';
+    import SearchModal from '@/groups/partials/GroupSearchModal.vue';
+    import InviteModal from '@/groups/partials/GroupInviteModal.vue';
+    import SidebarComponent from '@/groups/sections/Sidebar.vue';
+    import GroupBanner from '@/groups/partials/Page/GroupBanner.vue';
+    import GroupHeaderDetails from '@/groups/partials/Page/GroupHeaderDetails.vue';
+    import GroupNavTabs from '@/groups/partials/Page/GroupNavTabs.vue';
+    import MemberOnlyWarning from '@/groups/partials/Membership/MemberOnlyWarning.vue';
+
+    export default {
+        props: {
+            groupId: {
+                type: String
+            },
+
+            path: {
+                type: String
+            }
+        },
+
+        components: {
+            'status-card': StatusCard,
+            'group-about': GroupAbout,
+            'group-status': GroupStatus,
+            'group-members': GroupMembers,
+            'group-compose': GroupCompose,
+            'group-topics': GroupTopics,
+            'group-info-card': GroupInfoCard,
+            'group-media': GroupMedia,
+            'group-moderation': GroupModeration,
+            'leave-group': LeaveGroup,
+            'group-insights': GroupInsights,
+            'search-modal': SearchModal,
+            'invite-modal': InviteModal,
+            'sidebar': SidebarComponent,
+            'group-banner': GroupBanner,
+            'group-header-details': GroupHeaderDetails,
+            'group-nav-tabs': GroupNavTabs,
+            'member-only-warning': MemberOnlyWarning
+        },
+
+        data() {
+            return {
+                initialLoad: false,
+                profile: undefined,
+                group: {},
+                isMember: false,
+                isAdmin: false,
+                renderIdx: 1,
+                atabs: {
+                    moderation_count: 0,
+                    request_count: 0
+                }
+            };
+        },
+
+        created() {
+            this.init();
+        },
+
+        methods: {
+            init() {
+                this.initialLoad = false;
+                axios.get('/api/pixelfed/v1/accounts/verify_credentials')
+                .then(res => {
+                    this.profile = res.data;
+                    this.fetchGroup();
+                })
+                .catch(err => {
+                    window.location.href = '/login?_next=' + encodeURIComponent(window.location.href);
+                });
+            },
+
+            handleRefresh() {
+                this.initialLoad = false;
+                this.init();
+                this.renderIdx++;
+            },
+
+            fetchGroup() {
+                axios.get('/api/v0/groups/' + this.groupId)
+                .then(res => {
+                    this.group = res.data;
+                    this.isMember = res.data.self.is_member;
+                    this.isAdmin = ['founder', 'admin'].includes(res.data.self.role);
+
+                    if(this.isAdmin) {
+                        this.fetchAdminTabs();
+                    }
+
+                    this.initialLoad = true;
+                })
+                .catch(err => {
+                    //window.location.href = '/groups/unavailable';
+                    alert('error');
+                });
+            },
+
+            fetchAdminTabs() {
+                axios.get('/api/v0/groups/' + this.groupId + '/atabs')
+                .then(res => {
+                    this.atabs = res.data;
+                })
+            }
+        }
+    }
+</script>
+
+<style lang="scss">
+    .group-feed-component {
+        &-body {
+            min-height: 40vh;
+        }
+    }
+</style>

+ 168 - 0
resources/assets/components/groups/Page/GroupTopics.vue

@@ -0,0 +1,168 @@
+<template>
+    <div class="group-feed-component">
+        <div class="row border-bottom m-0 p-0">
+            <sidebar />
+
+            <div class="col-12 col-md-9 px-md-0">
+                <div class="bg-white mb-3 border-bottom">
+                    <div>
+                        <group-banner :group="group" />
+                        <group-header-details
+                            :group="group"
+                            :isAdmin="isAdmin"
+                            :isMember="isMember"
+                            @refresh="handleRefresh"
+                        />
+                        <group-nav-tabs
+                            :group="group"
+                            :isAdmin="isAdmin"
+                            :isMember="isMember"
+                            :atabs="atabs"
+                        />
+                    </div>
+                </div>
+
+                <div v-if="!initialLoad">
+                    <p class="text-center mt-5 pt-5 font-weight-bold">Loading...</p>
+                </div>
+
+                <template v-else>
+                    <div class="container-xl group-feed-component-body">
+                        <template v-if="initialLoad && group.self.is_member">
+                            <group-topics :key="renderIdx" :group="group" :profile="profile" />
+                        </template>
+
+                        <member-only-warning v-else />
+                    </div>
+                </template>
+            </div>
+        </div>
+    </div>
+</template>
+
+<script type="text/javascript">
+    import StatusCard from '~/partials/StatusCard.vue';
+    import GroupMembers from '@/groups/partials/GroupMembers.vue';
+    import GroupCompose from '@/groups/partials/GroupCompose.vue';
+    import GroupStatus from '@/groups/partials/GroupStatus.vue';
+    import GroupAbout from '@/groups/partials/GroupAbout.vue';
+    import GroupMedia from '@/groups/partials/GroupMedia.vue';
+    import GroupModeration from '@/groups/partials/GroupModeration.vue';
+    import GroupTopics from '@/groups/partials/GroupTopics.vue';
+    import GroupInfoCard from '@/groups/partials/GroupInfoCard.vue';
+    import LeaveGroup from '@/groups/partials/LeaveGroup.vue';
+    import GroupInsights from '@/groups/partials/GroupInsights.vue';
+    import SearchModal from '@/groups/partials/GroupSearchModal.vue';
+    import InviteModal from '@/groups/partials/GroupInviteModal.vue';
+    import SidebarComponent from '@/groups/sections/Sidebar.vue';
+    import GroupBanner from '@/groups/partials/Page/GroupBanner.vue';
+    import GroupHeaderDetails from '@/groups/partials/Page/GroupHeaderDetails.vue';
+    import GroupNavTabs from '@/groups/partials/Page/GroupNavTabs.vue';
+    import MemberOnlyWarning from '@/groups/partials/Membership/MemberOnlyWarning.vue';
+
+    export default {
+        props: {
+            groupId: {
+                type: String
+            },
+
+            path: {
+                type: String
+            }
+        },
+
+        components: {
+            'status-card': StatusCard,
+            'group-about': GroupAbout,
+            'group-status': GroupStatus,
+            'group-members': GroupMembers,
+            'group-compose': GroupCompose,
+            'group-topics': GroupTopics,
+            'group-info-card': GroupInfoCard,
+            'group-media': GroupMedia,
+            'group-moderation': GroupModeration,
+            'leave-group': LeaveGroup,
+            'group-insights': GroupInsights,
+            'search-modal': SearchModal,
+            'invite-modal': InviteModal,
+            'sidebar': SidebarComponent,
+            'group-banner': GroupBanner,
+            'group-header-details': GroupHeaderDetails,
+            'group-nav-tabs': GroupNavTabs,
+            'member-only-warning': MemberOnlyWarning
+        },
+
+        data() {
+            return {
+                initialLoad: false,
+                profile: undefined,
+                group: {},
+                isMember: false,
+                isAdmin: false,
+                renderIdx: 1,
+                atabs: {
+                    moderation_count: 0,
+                    request_count: 0
+                }
+            };
+        },
+
+        created() {
+            this.init();
+        },
+
+        methods: {
+            init() {
+                this.initialLoad = false;
+                axios.get('/api/pixelfed/v1/accounts/verify_credentials')
+                .then(res => {
+                    this.profile = res.data;
+                    this.fetchGroup();
+                })
+                .catch(err => {
+                    window.location.href = '/login?_next=' + encodeURIComponent(window.location.href);
+                });
+            },
+
+            handleRefresh() {
+                this.initialLoad = false;
+                this.init();
+                this.renderIdx++;
+            },
+
+            fetchGroup() {
+                axios.get('/api/v0/groups/' + this.groupId)
+                .then(res => {
+                    this.group = res.data;
+                    this.isMember = res.data.self.is_member;
+                    this.isAdmin = ['founder', 'admin'].includes(res.data.self.role);
+
+                    if(this.isAdmin) {
+                        this.fetchAdminTabs();
+                    }
+
+                    this.initialLoad = true;
+                })
+                .catch(err => {
+                    //window.location.href = '/groups/unavailable';
+                    alert('error');
+                });
+            },
+
+            fetchAdminTabs() {
+                axios.get('/api/v0/groups/' + this.groupId + '/atabs')
+                .then(res => {
+                    this.atabs = res.data;
+                })
+            }
+        }
+    }
+</script>
+
+<style lang="scss">
+    .group-feed-component {
+        &-body {
+            min-height: 40vh;
+        }
+    }
+</style>

+ 841 - 0
resources/assets/components/groups/partials/CommentDrawer.vue

@@ -0,0 +1,841 @@
+<template>
+	<div class="comment-drawer-component">
+		<input type="file" ref="fileInput" class="d-none" accept="image/jpeg,image/png" @change="handleImageUpload">
+		<div v-if="hide"></div>
+		<div v-else-if="!isLoaded" class="border-top d-flex justify-content-center py-3">
+			<div class="text-center">
+				<div
+					class="spinner-border text-lighter"
+					role="status">
+					<span class="sr-only">Loading...</span>
+				</div>
+				<p class="text-muted">Loading Comments ...</p>
+			</div>
+		</div>
+		<div v-else class="border-top">
+			<!-- <div v-if="profile && canReply" class="my-3">
+				<div class="d-flex align-items-top reply-form">
+					<img class="rounded-circle mr-2 border" :src="avatar" width="38" height="38">
+					<div v-if="isUploading" class="w-100">
+						<p class="font-weight-light mb-1">Uploading image ...</p>
+						<div class="progress rounded-pill" style="height:4px">
+							<div class="progress-bar" role="progressbar" :aria-valuenow="uploadProgress" aria-valuemin="0" aria-valuemax="100" :style="{ width: uploadProgress + '%'}"></div>
+						</div>
+					</div>
+					<div v-else class="reply-form-input">
+						<input
+							class="form-control bg-light border-lighter rounded-pill"
+							placeholder="Write a comment...."
+							v-model="replyContent"
+							v-on:keyup.enter="storeComment">
+
+						<div class="reply-form-input-actions">
+							<button
+								class="btn btn-link text-muted px-1 mr-2"
+								@click="uploadImage">
+								<i class="far fa-image fa-lg"></i>
+							</button>
+							<button class="btn btn-link text-muted px-1 small font-weight-bold border py-0 rounded-pill text-decoration-none">
+								GIF
+							</button>
+						</div>
+					</div>
+				</div>
+			</div> -->
+			<div class="my-3">
+				<div
+					v-for="(status, index) in feed"
+					:key="'cdf' + index + status.id"
+					class="media media-status align-items-top">
+
+					<a
+						v-if="replyChildId == status.id"
+						href="#comment-1"
+						class="comment-border-link"
+						@click.prevent="replyToChild(status)">
+						<span class="sr-only">Jump to comment-{{ index }}</span>
+					</a>
+
+					<a :href="status.account.url">
+						<img class="rounded-circle media-avatar border" :src="status.account.avatar" width="32" height="32" onerror="this.onerror=null;this.src='/storage/avatars/default.png?v=2'" />
+					</a>
+
+					<div class="media-body">
+						<div v-if="!status.media_attachments.length" class="media-body-comment">
+							<p class="media-body-comment-username">
+								<a :href="status.account.url">
+									{{status.account.acct}}
+								</a>
+							</p>
+							<read-more :status="status" />
+						</div>
+						<div v-else>
+							<p class="media-body-comment-username">
+								<a :href="status.account.url">
+									{{status.account.acct}}
+								</a>
+							</p>
+							<div class="bh-comment" @click="lightbox(status)">
+								<blur-hash-image
+									:width="blurhashWidth(status)"
+									:height="blurhashHeight(status)"
+									:punch="1"
+									class="img-fluid rounded-lg border shadow"
+									:hash="status.media_attachments[0].blurhash"
+									:src="getMediaSource(status)" />
+							</div>
+						</div>
+						<p class="media-body-reactions">
+							<a
+								v-if="profile"
+								href="#"
+								class="font-weight-bold"
+								:class="[ status.favourited ? 'text-primary' : 'text-muted' ]"
+								@click.prevent="likeComment(status, index, $event)">
+									{{ status.favourited ? 'Liked' : 'Like' }}
+								</a>
+							<span class="mx-1">·</span>
+							<a href="#" class="text-muted font-weight-bold" @click.prevent="replyToChild(status, index)">Reply</a>
+							<span v-if="profile" class="mx-1">·</span>
+							<a
+								class="font-weight-bold text-muted"
+								:href="status.url"
+								v-once>
+								{{ shortTimestamp(status.created_at) }}
+							</a>
+							<span v-if="profile && status.account.id === profile.id">
+								<span class="mx-1">·</span>
+								<a
+									class="font-weight-bold text-lighter"
+									href="#"
+									@click.prevent="deleteComment(index)">
+										Delete
+									</a>
+							</span>
+						</p>
+
+						<!-- <div v-if="replyChildId == status.id && status.reply_count" class="media media-status align-items-top mt-3">
+							<div class="comment-border-arrow"></div>
+							<a href="https://pixelfed.test/groups/328821658771132416/user/321493203255693312"><img src="https://pixelfed.test/storage/avatars/321493203255693312/5a6nqo.jpg?v=2" width="32" height="32" class="rounded-circle media-avatar border"></a>
+							<div class="media-body"><div class="media-body-comment"><p class="media-body-comment-username"><a href="https://pixelfed.test/groups/328821658771132416/user/321493203255693312">
+								dansup
+							</a></p> <div class="read-more-component" style="word-break: break-all;"><div>test</div></div></div> <p class="media-body-reactions"><a href="#" class="font-weight-bold text-muted">
+								Like
+							</a> <span class="mx-1">·</span> <a href="https://pixelfed.test/groups/328821658771132416/p/358529382599041029" class="font-weight-bold text-muted">
+							1h
+						</a> <span><span class="mx-1">·</span> <a href="#" class="font-weight-bold text-lighter">
+									Delete
+								</a></span></p>
+							</div>
+						</div> -->
+
+						<div v-if="replyChildIndex == index && status.hasOwnProperty('children') && status.children.hasOwnProperty('feed') && status.children.feed.length">
+							<comment-post
+								v-for="(s, index) in status.children.feed"
+								:status="s"
+								:profile="profile"
+								:commentBorderArrow="true"
+								:key="'scp_'+index+'_'+s.id"
+								/>
+						</div>
+
+						<a
+							v-if="replyChildIndex == index &&
+								status.hasOwnProperty('children') &&
+								status.children.hasOwnProperty('can_load_more') &&
+								status.children.can_load_more == true"
+							class="text-muted font-weight-bold mt-1 mb-0"
+							style="font-size: 13px;"
+							href="#"
+							:disabled="loadingChildComments"
+							@click.prevent="loadMoreChildComments(status, index)">
+							<div class="comment-border-arrow"></div>
+							<i class="far fa-long-arrow-right mr-1"></i>
+							{{ loadingChildComments ? 'Loading' : 'Load' }} more comments
+						</a>
+
+						<a
+							v-else-if="replyChildIndex !== index &&
+								status.hasOwnProperty('children') &&
+								status.children.hasOwnProperty('can_load_more') &&
+								status.children.can_load_more == true &&
+								status.reply_count > 0 &&
+								!loadingChildComments"
+							class="text-muted font-weight-bold mt-1 mb-0"
+							style="font-size: 13px;"
+							href="#"
+							:disabled="loadingChildComments"
+							@click.prevent="replyToChild(status, index)">
+							<i class="far fa-long-arrow-right mr-1"></i>
+							{{ loadingChildComments ? 'Loading' : 'Load' }} more comments
+						</a>
+
+						<div v-if="replyChildId == status.id" class="mt-3 mb-3 d-flex align-items-top reply-form child-reply-form">
+							<div class="comment-border-arrow"></div>
+							<img class="rounded-circle mr-2 border" :src="avatar" width="38" height="38" onerror="this.onerror=null;this.src='/storage/avatars/default.png?v=2'">
+							<div v-if="isUploading" class="w-100">
+								<p class="font-weight-light mb-1">Uploading image ...</p>
+								<div class="progress rounded-pill" style="height:10px">
+									<div class="progress-bar progress-bar-striped progress-bar-animated" role="progressbar" :aria-valuenow="uploadProgress" aria-valuemin="0" aria-valuemax="100" :style="{ width: uploadProgress + '%'}"></div>
+								</div>
+							</div>
+							<div v-else class="reply-form-input">
+								<input
+									class="form-control bg-light border-lighter rounded-pill"
+									placeholder="Write a comment...."
+									v-model="childReplyContent"
+									:disabled="postingChildComment"
+									v-on:keyup.enter="storeChildComment(index)">
+
+								<!-- <div class="reply-form-input-actions">
+									<button
+										class="btn btn-link text-muted px-1 mr-2"
+										@click="uploadImage">
+										<i class="far fa-image fa-lg"></i>
+									</button>
+									<button class="btn btn-link text-muted px-1 small font-weight-bold border py-0 rounded-pill text-decoration-none">
+										GIF
+									</button>
+								</div> -->
+							</div>
+						</div>
+					</div>
+
+				</div>
+				<!-- <a v-if="permalinkMode && canLoadMore" class="text-muted mb-n1" style="font-size: 13px;font-weight: 600;" href="#">Load more comments ...</a> -->
+			</div>
+			<button
+				v-if="canLoadMore"
+				class="btn btn-link btn-sm text-muted mb-2"
+				@click="loadMoreComments"
+				:disabled="isLoadingMore">
+
+				<span v-if="!isLoadingMore">
+					Load more comments ...
+				</span>
+				<div
+					v-else
+					class="spinner-border spinner-border-sm text-muted"
+					role="status">
+					<span class="sr-only">Loading...</span>
+				</div>
+			</button>
+
+			<div v-if="profile && canReply" class="mt-3 mb-n3">
+				<div class="d-flex align-items-top reply-form cdrawer-reply-form">
+					<img class="rounded-circle mr-2 border" :src="avatar" width="38" height="38" onerror="this.onerror=null;this.src='/storage/avatars/default.png?v=2'">
+					<div v-if="isUploading" class="w-100">
+						<p class="font-weight-light small text-muted mb-1">Uploading image ...</p>
+						<div class="progress rounded-pill" style="height:10px">
+							<div class="progress-bar progress-bar-striped progress-bar-animated" role="progressbar" :aria-valuenow="uploadProgress" aria-valuemin="0" aria-valuemax="100" :style="{ width: uploadProgress + '%'}"></div>
+						</div>
+					</div>
+					<div v-else class="w-100">
+                        <div class="reply-form-input">
+                            <textarea
+                                class="form-control bg-light border-lighter"
+                                placeholder="Write a comment...."
+                                :rows="replyContent && replyContent.length > 40 ? 4 : 1"
+                                v-model="replyContent"></textarea>
+
+    						<div class="reply-form-input-actions">
+    							<button
+    								class="btn btn-link text-muted px-1 mr-2"
+    								@click="uploadImage">
+    								<i class="far fa-image fa-lg"></i>
+    							</button>
+    							<!-- <button class="btn btn-link text-muted px-1 small font-weight-bold border py-0 rounded-pill text-decoration-none">
+    								GIF
+    							</button> -->
+    						</div>
+                        </div>
+                        <div class="d-flex justify-content-between reply-form-menu">
+                            <div class="char-counter">
+                                <span>{{ replyContent?.length ?? 0 }}</span>
+                                <span>/</span>
+                                <span>500</span>
+                            </div>
+
+                        </div>
+					</div>
+                    <button
+                        class="btn btn-link btn-sm font-weight-bold align-self-center ml-3 mb-3"
+                        @click="storeComment">Post</button>
+				</div>
+			</div>
+		</div>
+
+		<b-modal ref="lightboxModal"
+			id="lightbox"
+			:hide-header="true"
+			:hide-footer="true"
+			centered
+			size="lg"
+			body-class="p-0"
+			content-class="bg-transparent border-0"
+			>
+			<div v-if="lightboxStatus" @click="hideLightbox">
+				<img :src="lightboxStatus.url" style="width: 100%;max-height: 90vh;object-fit: contain;">
+			</div>
+		</b-modal>
+	</div>
+</template>
+
+<script type="text/javascript">
+	import ReadMore from './ReadMore.vue';
+	import CommentPost from './CommentPost.vue';
+
+	export default {
+		props: {
+			groupId: {
+				type: String
+			},
+
+			profile: {
+				type: Object
+			},
+
+			status: {
+				type: Object
+			},
+
+			show: {
+				type: Boolean,
+				default: false
+			},
+
+			permalinkMode: {
+				type: Boolean,
+				default: false
+			},
+
+			permalinkStatus: {
+				type: Object
+			},
+
+			canReply: {
+				type: Boolean,
+				default: true
+			}
+		},
+
+		components: {
+			"read-more": ReadMore,
+			"comment-post": CommentPost
+		},
+
+		data() {
+			return {
+				isLoaded: false,
+				hide: false,
+				feed: [],
+				canLoadMore: false,
+				isLoadingMore: false,
+				replyContent: null,
+				maxReplyId: null,
+				readMoreCursor: 200,
+				avatar: '/storage/avatars/default.png',
+				isUploading: false,
+				uploadProgress: 0,
+				lightboxStatus: null,
+				replyChildId: undefined,
+				replyChildIndex: undefined,
+				childReplyContent: null,
+				postingChildComment: false,
+				loadingChildComments: false,
+				replyChildMinId: undefined
+			}
+		},
+
+		mounted() {
+			if(this.permalinkMode && this.permalinkStatus) {
+				let status = this.permalinkStatus;
+				if(status.reply_count) {
+					status.children = {
+						feed: [],
+						can_load_more: true
+					}
+				}
+				this.feed.push(status);
+				this.isLoaded = true;
+				this.canLoadMore = false;
+			} else {
+				this.fetchComments();
+			}
+
+			if(this.profile && this.profile.hasOwnProperty('avatar')) {
+				this.avatar = this.profile.avatar;
+			}
+		},
+
+		methods: {
+			fetchComments() {
+				axios.get('/api/v0/groups/comments', {
+					params: {
+						gid: this.groupId,
+						sid: this.status.id,
+						limit: 3
+					}
+				}).then(res => {
+					let data = res.data.map(function(c) {
+						if(c.reply_count && c.reply_count > 0) {
+							c.children = {
+								feed: [],
+								can_load_more: true
+							}
+						}
+						return c;
+					})
+					this.feed = data;
+					this.isLoaded = true;
+					this.maxReplyId = res.data[(res.data.length - 1)].id;
+					if(this.feed.length == 3) {
+						this.canLoadMore = true;
+					} else {
+					}
+				}).catch(err => {
+					this.isLoaded = true;
+				})
+			},
+
+			loadMoreComments() {
+				this.isLoadingMore = true;
+
+				axios.get('/api/v0/groups/comments', {
+					params: {
+						gid: this.groupId,
+						sid: this.status.id,
+						limit: 3,
+						max_id: this.maxReplyId
+					}
+				}).then(res => {
+					if(res.data[res.data.length - 1].id == this.maxReplyId) {
+						this.isLoadingMore = false;
+						this.canLoadMore = false;
+						return;
+					}
+					this.feed.push(...res.data);
+                    setTimeout(() => {
+					   this.isLoadingMore = false;
+                    }, 500);
+					this.maxReplyId = res.data[res.data.length - 1].id;
+					if(res.data.length > 0) {
+						this.canLoadMore = true;
+					} else {
+						this.canLoadMore = false;
+					}
+				}).catch(err => {
+					this.isLoadingMore = false;
+					this.canLoadMore = false;
+				})
+			},
+
+			storeComment($event) {
+                $event.currentTarget?.blur();
+				axios.post('/api/v0/groups/comment', {
+					gid: this.groupId,
+					sid: this.status.id,
+					content: this.replyContent
+				})
+				.then(res => {
+					this.replyContent = null;
+					this.feed.unshift(res.data);
+				}).catch(err => {
+					if(err.response.status == 422) {
+						this.isUploading = false;
+						this.uploadProgress = 0;
+						swal('Oops!', err.response.data.error, 'error');
+					} else {
+						this.isUploading = false;
+						this.uploadProgress = 0;
+						swal('Oops!', 'An error occured while processing your request, please try again later', 'error');
+					}
+				})
+			},
+
+			shortTimestamp(ts) {
+				return window.App.util.format.timeAgo(ts);
+			},
+
+			readMore() {
+				this.readMoreCursor = this.readMoreCursor + 200;
+			},
+
+			likeComment(status, index, $event) {
+				$event.target.blur();
+				let l = status.favourited ? false : true;
+				this.feed[index].favourited = l;
+				status.favourited = l;
+				axios.post(`/api/v0/groups/comment/${l ? 'like' : 'unlike'}`, {
+					sid: status.id,
+					gid: this.groupId
+				});
+			},
+
+			deleteComment(index) {
+				if(window.confirm('Are you sure you want to delete this post?') == false) {
+					return;
+				}
+
+				axios.post('/api/v0/groups/comment/delete', {
+					gid: this.groupId,
+					id: this.feed[index].id
+				}).then(res => {
+					this.feed.splice(index, 1);
+				}).catch(err => {
+					console.log(err.response);
+					swal('Error', 'Something went wrong. Please try again later.', 'error');
+				});
+			},
+
+			uploadImage() {
+				this.$refs.fileInput.click();
+			},
+
+			handleImageUpload() {
+				if(!this.$refs.fileInput.files.length) {
+					return;
+				}
+				this.isUploading = true;
+				let self = this;
+				let data = new FormData();
+				data.append('gid', this.groupId);
+				data.append('sid', this.status.id);
+				data.append('photo', this.$refs.fileInput.files[0]);
+
+				axios.post('/api/v0/groups/comment/photo', data, {
+					onUploadProgress: function(progressEvent) {
+						self.uploadProgress = Math.floor(progressEvent.loaded / progressEvent.total * 100);
+					}
+				})
+				.then(res => {
+					this.isUploading = false;
+					this.uploadProgress = 0;
+					this.feed.unshift(res.data);
+				}).catch(err => {
+					if(err.response.status == 422) {
+						this.isUploading = false;
+						this.uploadProgress = 0;
+						swal('Oops!', err.response.data.error, 'error');
+					} else {
+						this.isUploading = false;
+						this.uploadProgress = 0;
+						swal('Oops!', 'An error occured while processing your request, please try again later', 'error');
+					}
+				});
+			},
+
+			lightbox(status) {
+				this.lightboxStatus = status.media_attachments[0];
+				this.$refs.lightboxModal.show();
+			},
+
+			hideLightbox() {
+				this.lightboxStatus = null;
+				this.$refs.lightboxModal.hide();
+			},
+
+			blurhashWidth(status) {
+				if(!status.media_attachments[0].meta) {
+					return 25;
+				}
+				let aspect = status.media_attachments[0].meta.original.aspect;
+				if(aspect == 1) {
+					return 25;
+				} else if(aspect > 1) {
+					return 30;
+				} else {
+					return 20;
+				}
+			},
+
+			blurhashHeight(status) {
+				if(!status.media_attachments[0].meta) {
+					return 25;
+				}
+				let aspect = status.media_attachments[0].meta.original.aspect;
+				if(aspect == 1) {
+					return 25;
+				} else if(aspect > 1) {
+					return 20;
+				} else {
+					return 30;
+				}
+			},
+
+			getMediaSource(status) {
+				let media = status.media_attachments[0];
+
+				if(media.preview_url.endsWith('storage/no-preview.png')) {
+					return media.url;
+				}
+
+				return media.preview_url;
+			},
+
+			replyToChild(status, index) {
+
+				if(this.replyChildId == status.id) {
+					this.replyChildId = null;
+					this.replyChildIndex = null;
+					return;
+				} else {
+					this.childReplyContent = null;
+				}
+
+				this.replyChildId = status.id;
+				this.replyChildIndex = index;
+
+				if(!status.hasOwnProperty('replies_loaded') || !status.replies_loaded) {
+					this.$nextTick(() => {
+						this.fetchChildReplies(status, index);
+					});
+				} else {
+
+				}
+			},
+
+			fetchChildReplies(status, index) {
+				axios.get('/api/v0/groups/comments', {
+					params: {
+						gid: this.groupId,
+						sid: status.id,
+						cid: 1,
+						limit: 3
+					}
+				}).then(res => {
+					if(this.feed[index].hasOwnProperty('children')) {
+						this.feed[index].children.feed.push(res.data);
+						this.feed[index].children.can_load_more = res.data.length == 3;
+					} else {
+						this.feed[index].children = {
+							feed: res.data,
+							can_load_more: res.data.length == 3
+						}
+					}
+					this.replyChildMinId = res.data[res.data.length - 1].id;
+					this.$nextTick(() => {
+						this.feed[index].replies_loaded = true;
+					});
+				}).catch(err => {
+					this.feed[index].children.can_load_more = false;
+				})
+			},
+
+			storeChildComment(index) {
+				this.postingChildComment = true;
+
+				axios.post('/api/v0/groups/comment', {
+					gid: this.groupId,
+					sid: this.status.id,
+					cid: this.replyChildId,
+					content: this.childReplyContent
+				})
+				.then(res => {
+					this.childReplyContent = null;
+					this.postingChildComment = false;
+					this.feed[index].children.feed.push(res.data);
+					// this.feed.unshift(res.data);
+				}).catch(err => {
+					if(err.response.status == 422) {
+						// this.isUploading = false;
+						// this.uploadProgress = 0;
+						swal('Oops!', err.response.data.error, 'error');
+					} else {
+						// this.isUploading = false;
+						// this.uploadProgress = 0;
+						swal('Oops!', 'An error occured while processing your request, please try again later', 'error');
+					}
+				});
+			},
+
+			loadMoreChildComments(status, index) {
+				this.loadingChildComments = true;
+
+				axios.get('/api/v0/groups/comments', {
+					params: {
+						gid: this.groupId,
+						sid: status.id,
+						max_id: this.replyChildMinId,
+						cid: 1,
+						limit: 3
+					}
+				}).then(res => {
+					if(this.feed[index].hasOwnProperty('children')) {
+						this.feed[index].children.feed.push(...res.data);
+						this.feed[index].children.can_load_more = res.data.length == 3;
+					} else {
+						this.feed[index].children = {
+							feed: res.data,
+							can_load_more: res.data.length == 3
+						}
+					}
+					this.replyChildMinId = res.data[res.data.length - 1].id;
+					this.feed[index].replies_loaded = true;
+					this.loadingChildComments = false;
+				}).catch(err => {
+				})
+			}
+		}
+	}
+</script>
+
+<style lang="scss">
+	.comment-drawer-component {
+		.media {
+			position: relative;
+
+			.comment-border-link {
+				display: block;
+				position: absolute;
+				top: 40px;
+				left: 11px;
+				width: 10px;
+				height: calc(100% - 100px);
+				border-left: 4px solid transparent;
+				border-right: 4px solid transparent;
+				background-color: #E5E7EB;
+				background-clip: padding-box;
+
+				&:hover {
+					background-color: #BFDBFE;
+				}
+			}
+
+			.child-reply-form {
+				position: relative;
+			}
+
+			.comment-border-arrow {
+				display: block;
+				position: absolute;
+				top: -6px;
+				left: -33px;
+				width: 10px;
+				height: 29px;
+				border-left: 4px solid transparent;
+				border-right: 4px solid transparent;
+				background-color: #E5E7EB;
+				background-clip: padding-box;
+				border-bottom: 2px solid transparent;
+
+				&:after {
+					content: '';
+					display: block;
+					position: absolute;
+					top: 25px;
+					left: 2px;
+					width: 15px;
+					height: 2px;
+					background-color: #E5E7EB;
+				}
+			}
+
+			&-status {
+				margin-bottom: 1.3rem;
+			}
+
+			&-avatar {
+				margin-right: 12px;
+			}
+
+			&-body {
+				&-comment {
+					width: fit-content;
+					padding: 0.4rem 0.7rem;
+					background-color: var(--comment-bg);
+					border-radius: 0.9rem;
+
+					&-username {
+						margin-bottom: 0.25rem !important;
+						font-size: 14px;
+						font-weight: 700 !important;
+						color: #000;
+
+						a {
+							color: #000;
+							text-decoration: none;
+						}
+					}
+
+					&-content {
+						margin-bottom: 0;
+						font-size: 16px;
+					}
+				}
+
+				&-reactions {
+					margin-top: 0.25rem !important;
+					margin-bottom: 0 !important;
+					color: #B8C2CC !important;
+					font-size: 12px;
+				}
+			}
+		}
+
+		.load-more-comments {
+			font-weight: 500;
+		}
+
+		.reply-form {
+			margin-bottom: 2rem;
+
+			&-input {
+				flex: 1;
+				position: relative;
+
+                textarea {
+                    border-radius: 10px;
+                }
+
+                .form-control {
+                    resize: none;
+                    padding-right: 100px;
+                }
+
+				&-actions {
+					position: absolute;
+					right: 10px;
+					top: 50%;
+					transform: translateY(-50%);
+				}
+			}
+
+            .btn {
+                text-decoration: none;
+            }
+
+            &-menu {
+                margin-top: 5px;
+
+                .char-counter {
+                    color: var(--muted);
+                    font-size: 10px;
+                }
+            }
+
+		}
+
+		.bh-comment {
+			width: 100%;
+			height: auto;
+			max-width: 160px !important;
+			max-height: 260px !important;
+
+			span {
+				width: 100%;
+				height: auto;
+				max-width: 160px !important;
+				max-height: 260px !important;
+			}
+
+			img {
+				width: 100%;
+				height: auto;
+				max-width: 160px !important;
+				max-height: 260px !important;
+				object-fit: cover;
+			}
+		}
+	}
+</style>

+ 405 - 0
resources/assets/components/groups/partials/CommentPost.vue

@@ -0,0 +1,405 @@
+<template>
+	<div class="comment-post-component">
+		<div class="media media-status align-items-top mt-3">
+			<div v-if="commentBorderArrow" class="comment-border-arrow"></div>
+			<!-- <a
+				v-if="replyChildId == status.id"
+				href="#comment-1"
+				class="comment-border-link"
+				@click.prevent="replyToChild(status)">
+				<span class="sr-only">Jump to comment-{{ index }}</span>
+			</a> -->
+
+			<a :href="status.account.url">
+				<img class="rounded-circle media-avatar border" :src="status.account.avatar" width="32" height="32" onerror="this.onerror=null;this.src='/storage/avatars/default.png?v=2'">
+			</a>
+
+			<div class="media-body">
+				<div v-if="!status.media_attachments.length" class="media-body-comment">
+					<p class="media-body-comment-username">
+						<a :href="status.account.url">
+							{{status.account.acct}}
+						</a>
+					</p>
+					<read-more :status="status" />
+				</div>
+				<div v-else>
+					<p class="media-body-comment-username">
+						<a :href="status.account.url">
+							{{status.account.acct}}
+						</a>
+					</p>
+					<div class="bh-comment" @click="lightbox(status)">
+						<blur-hash-image
+							:width="blurhashWidth(status)"
+							:height="blurhashHeight(status)"
+							:punch="1"
+							class="img-fluid rounded-lg border shadow"
+							:hash="status.media_attachments[0].blurhash"
+							:src="getMediaSource(status)" />
+					</div>
+				</div>
+				<p class="media-body-reactions">
+					<a
+						v-if="profile"
+						href="#"
+						class="font-weight-bold"
+						:class="[ status.favourited ? 'text-primary' : 'text-muted' ]"
+						@click.prevent="likeComment(status, index, $event)">
+							{{ status.favourited ? 'Liked' : 'Like' }}
+						</a>
+					<!-- <span class="mx-1">·</span> -->
+					<!-- <a href="#" class="text-muted font-weight-bold" @click.prevent="replyToChild(status, index)">Reply</a> -->
+					<span v-if="profile" class="mx-1">·</span>
+					<a
+						class="font-weight-bold text-muted"
+						:href="status.url"
+						v-once>
+						{{ shortTimestamp(status.created_at) }}
+					</a>
+					<span v-if="profile && status.account.id === profile.id">
+						<span class="mx-1">·</span>
+						<a
+							class="font-weight-bold text-lighter"
+							href="#"
+							@click.prevent="deleteComment(index)">
+								Delete
+							</a>
+					</span>
+				</p>
+			</div>
+		</div>
+	</div>
+</template>
+
+<script type="text/javascript">
+	import ReadMore from './ReadMore.vue';
+
+	export default {
+		props: {
+			groupId: {
+				type: String
+			},
+
+			profile: {
+				type: Object
+			},
+
+			status: {
+				type: Object,
+			},
+
+			commentBorderArrow: {
+				type: Boolean,
+				default: false
+			}
+		},
+
+
+		components: {
+			"read-more": ReadMore
+		},
+
+		data() {
+			return {
+				isLoaded: false,
+				hide: false,
+				feed: [],
+				canLoadMore: false,
+				isLoadingMore: false,
+				replyContent: null,
+				maxReplyId: null,
+				readMoreCursor: 200,
+				avatar: '/storage/avatars/default.png',
+				isUploading: false,
+				uploadProgress: 0,
+				lightboxStatus: null,
+				replyChildId: undefined,
+				childReplyContent: null,
+				postingChildComment: false
+			}
+		},
+
+		mounted() {
+			console.log(this.status);
+			// if(this.permalinkMode && this.permalinkStatus) {
+			// 	this.feed.push(this.permalinkStatus);
+			// 	this.isLoaded = true;
+			// 	this.canLoadMore = false;
+			// } else {
+			// 	this.fetchComments();
+			// }
+
+			// if(this.profile && this.profile.hasOwnProperty('avatar')) {
+			// 	this.avatar = this.profile.avatar;
+			// }
+		},
+
+		methods: {
+			fetchComments() {
+				axios.get('/api/v0/groups/comments', {
+					params: {
+						gid: this.groupId,
+						sid: this.status.id,
+						limit: 3
+					}
+				}).then(res => {
+					this.feed = res.data;
+					this.isLoaded = true;
+					this.maxReplyId = res.data[(res.data.length - 1)].id;
+					if(this.feed.length == 3) {
+						this.canLoadMore = true;
+					}
+				}).catch(err => {
+					this.isLoaded = true;
+				})
+			},
+
+			loadMoreComments() {
+				this.isLoadingMore = true;
+
+				axios.get('/api/v0/groups/comments', {
+					params: {
+						gid: this.groupId,
+						sid: this.status.id,
+						limit: 3,
+						max_id: this.maxReplyId
+					}
+				}).then(res => {
+					if(res.data[res.data.length - 1].id == this.maxReplyId) {
+						this.isLoadingMore = false;
+						this.canLoadMore = false;
+						return;
+					}
+					this.feed.push(...res.data);
+					this.isLoadingMore = false;
+					this.maxReplyId = res.data[res.data.length - 1].id;
+					if(res.data.length > 0) {
+						this.canLoadMore = true;
+					} else {
+						this.canLoadMore = false;
+					}
+				}).catch(err => {
+					this.isLoadingMore = false;
+					this.canLoadMore = false;
+				})
+			},
+
+			storeComment() {
+				axios.post('/api/v0/groups/comment', {
+					gid: this.groupId,
+					sid: this.status.id,
+					content: this.replyContent
+				})
+				.then(res => {
+					this.replyContent = null;
+					this.feed.unshift(res.data);
+				}).catch(err => {
+					if(err.response.status == 422) {
+						this.isUploading = false;
+						this.uploadProgress = 0;
+						swal('Oops!', err.response.data.error, 'error');
+					} else {
+						this.isUploading = false;
+						this.uploadProgress = 0;
+						swal('Oops!', 'An error occured while processing your request, please try again later', 'error');
+					}
+				})
+			},
+
+			shortTimestamp(ts) {
+				return window.App.util.format.timeAgo(ts);
+			},
+
+			readMore() {
+				this.readMoreCursor = this.readMoreCursor + 200;
+			},
+
+			likeComment(status, index, $event) {
+				$event.target.blur();
+				let l = status.favourited ? false : true;
+				this.feed[index].favourited = l;
+				status.favourited = l;
+				axios.post('/api/v0/groups/like', {
+					sid: status.id,
+					gid: this.groupId
+				});
+			},
+
+			deleteComment(index) {
+				if(window.confirm('Are you sure you want to delete this post?') == false) {
+					return;
+				}
+
+				axios.post('/api/v0/groups/status/delete', {
+					gid: this.groupId,
+					id: this.feed[index].id
+				}).then(res => {
+					this.feed.splice(index, 1);
+				}).catch(err => {
+					console.log(err.response);
+					swal('Error', 'Something went wrong. Please try again later.', 'error');
+				});
+			},
+
+			uploadImage() {
+				this.$refs.fileInput.click();
+			},
+
+			handleImageUpload() {
+				if(!this.$refs.fileInput.files.length) {
+					return;
+				}
+				this.isUploading = true;
+				let self = this;
+				let data = new FormData();
+				data.append('gid', this.groupId);
+				data.append('sid', this.status.id);
+				data.append('photo', this.$refs.fileInput.files[0]);
+
+				axios.post('/api/v0/groups/comment/photo', data, {
+					onUploadProgress: function(progressEvent) {
+						self.uploadProgress = Math.floor(progressEvent.loaded / progressEvent.total * 100);
+					}
+				})
+				.then(res => {
+					this.isUploading = false;
+					this.uploadProgress = 0;
+					this.feed.unshift(res.data);
+				}).catch(err => {
+					if(err.response.status == 422) {
+						this.isUploading = false;
+						this.uploadProgress = 0;
+						swal('Oops!', err.response.data.error, 'error');
+					} else {
+						this.isUploading = false;
+						this.uploadProgress = 0;
+						swal('Oops!', 'An error occured while processing your request, please try again later', 'error');
+					}
+				});
+			},
+
+			lightbox(status) {
+				this.lightboxStatus = status.media_attachments[0];
+				this.$refs.lightboxModal.show();
+			},
+
+			hideLightbox() {
+				this.lightboxStatus = null;
+				this.$refs.lightboxModal.hide();
+			},
+
+			blurhashWidth(status) {
+				if(!status.media_attachments[0].meta) {
+					return 25;
+				}
+				let aspect = status.media_attachments[0].meta.original.aspect;
+				if(aspect == 1) {
+					return 25;
+				} else if(aspect > 1) {
+					return 30;
+				} else {
+					return 20;
+				}
+			},
+
+			blurhashHeight(status) {
+				if(!status.media_attachments[0].meta) {
+					return 25;
+				}
+				let aspect = status.media_attachments[0].meta.original.aspect;
+				if(aspect == 1) {
+					return 25;
+				} else if(aspect > 1) {
+					return 20;
+				} else {
+					return 30;
+				}
+			},
+
+			getMediaSource(status) {
+				let media = status.media_attachments[0];
+
+				if(media.preview_url.endsWith('storage/no-preview.png')) {
+					return media.url;
+				}
+
+				return media.preview_url;
+			},
+
+			replyToChild(status, index) {
+				if(this.replyChildId == status.id) {
+					this.replyChildId = null;
+					return;
+				} else {
+					this.childReplyContent = null;
+				}
+
+				this.replyChildId = status.id;
+
+				if(!status.hasOwnProperty('replies_loaded') || !status.replies_loaded) {
+					this.fetchChildReplies(status, index);
+				} else {
+
+				}
+			},
+
+			fetchChildReplies(status, index) {
+				axios.get('/api/v0/groups/comments', {
+					params: {
+						gid: this.groupId,
+						sid: status.id,
+						cid: 1,
+						limit: 3
+					}
+				}).then(res => {
+					if(this.feed[index].hasOwnProperty('children')) {
+						this.feed[index].children.feed.push(...res.data);
+						this.feed[index].children.can_load_more = res.data.length == 3;
+					} else {
+						this.feed[index].children = {
+							feed: res.data,
+							can_load_more: res.data.length == 3
+						}
+					}
+					this.feed[index].replies_loaded = true;
+				}).catch(err => {
+				})
+			},
+
+			storeChildComment() {
+				this.postingChildComment = true;
+
+				axios.post('/api/v0/groups/comment', {
+					gid: this.groupId,
+					sid: this.status.id,
+					cid: this.replyChildId,
+					content: this.childReplyContent
+				})
+				.then(res => {
+					this.childReplyContent = null;
+					this.postingChildComment = false;
+					// this.feed.unshift(res.data);
+				}).catch(err => {
+					if(err.response.status == 422) {
+						// this.isUploading = false;
+						// this.uploadProgress = 0;
+						swal('Oops!', err.response.data.error, 'error');
+					} else {
+						// this.isUploading = false;
+						// this.uploadProgress = 0;
+						swal('Oops!', 'An error occured while processing your request, please try again later', 'error');
+					}
+				});
+
+				console.log(this.replyChildId);
+			}
+		}
+	}
+</script>
+
+<style lang="scss">
+	.comment-post-component {
+
+	}
+</style>

+ 692 - 0
resources/assets/components/groups/partials/ContextMenu.vue

@@ -0,0 +1,692 @@
+<template>
+	<div class="context-menu-component modal-stack">
+		<b-modal ref="ctxModal"
+			id="ctx-modal"
+			hide-header
+			hide-footer
+			centered
+			rounded
+			size="sm"
+			body-class="list-group-flush p-0 rounded">
+			<div class="list-group text-center">
+				<!-- <div v-if="status && status.account.id != profile.id && ctxMenuRelationship && ctxMenuRelationship.following" class="list-group-item rounded cursor-pointer font-weight-bold text-danger" @click="ctxMenuUnfollow()">Unfollow</div>
+				<div v-if="status && status.account.id != profile.id && ctxMenuRelationship && !ctxMenuRelationship.following" class="list-group-item rounded cursor-pointer font-weight-bold text-primary" @click="ctxMenuFollow()">Follow</div> -->
+				<div v-if="status.visibility !== 'archived'" class="list-group-item rounded cursor-pointer" @click="ctxMenuGoToPost()">View Post</div>
+				<!-- <div v-if="status.visibility !== 'archived'" class="list-group-item rounded cursor-pointer" @click="ctxMenuGoToProfile()">View Profile</div> -->
+				<!-- <div v-if="status && status.local == true && !status.in_reply_to_id" class="list-group-item rounded cursor-pointer" @click="ctxMenuEmbed()">Embed</div> -->
+				<div class="list-group-item rounded cursor-pointer" @click="ctxMenuCopyLink()">Copy Link</div>
+				<!-- <div v-if="status.visibility !== 'archived'" class="list-group-item rounded cursor-pointer" @click="ctxMenuShare()">Share</div> -->
+				<!-- <div v-if="status && profile && profile.is_admin == true && status.visibility !== 'archived'" class="list-group-item rounded cursor-pointer" @click="ctxModMenuShow()">Moderation Tools</div> -->
+				<div v-if="status && status.account.id != profile.id" class="list-group-item rounded cursor-pointer text-danger" @click="ctxMenuReportPost()">Report</div>
+				<div v-if="status && (profile.is_admin || profile.id == status.account.id) && status.visibility !== 'archived'" class="list-group-item rounded cursor-pointer text-danger" @click="deletePost(status)">Delete</div>
+				<div class="list-group-item rounded cursor-pointer text-lighter" @click="closeCtxMenu()">Cancel</div>
+			</div>
+		</b-modal>
+		<b-modal ref="ctxModModal"
+			id="ctx-mod-modal"
+			hide-header
+			hide-footer
+			centered
+			rounded
+			size="sm"
+			body-class="list-group-flush p-0 rounded">
+			<div class="list-group text-center">
+				<p class="py-2 px-3 mb-0">
+					<div class="text-center font-weight-bold text-danger">Moderation Tools</div>
+					<div class="small text-center text-muted">Select one of the following options</div>
+				</p>
+				<div class="list-group-item rounded cursor-pointer" @click="moderatePost(status, 'unlist')">Unlist from Timelines</div>
+				<div v-if="status.sensitive" class="list-group-item rounded cursor-pointer" @click="moderatePost(status, 'remcw')">Remove Content Warning</div>
+				<div v-else class="list-group-item rounded cursor-pointer" @click="moderatePost(status, 'addcw')">Add Content Warning</div>
+				<div class="list-group-item rounded cursor-pointer" @click="moderatePost(status, 'spammer')">
+					Mark as Spammer<br />
+					<span class="small">Unlist + CW existing and future posts</span>
+				</div>
+				<!-- <div class="list-group-item rounded cursor-pointer" @click="ctxModOtherMenuShow()">Other</div> -->
+				<div class="list-group-item rounded cursor-pointer text-lighter" @click="ctxModMenuClose()">Cancel</div>
+			</div>
+		</b-modal>
+		<b-modal ref="ctxModOtherModal"
+			id="ctx-mod-other-modal"
+			hide-header
+			hide-footer
+			centered
+			rounded
+			size="sm"
+			body-class="list-group-flush p-0 rounded">
+			<div class="list-group text-center">
+				<p class="py-2 px-3 mb-0">
+					<div class="text-center font-weight-bold text-danger">Moderation Tools</div>
+					<div class="small text-center text-muted">Select one of the following options</div>
+				</p>
+				<div class="list-group-item rounded cursor-pointer font-weight-bold" @click="confirmModal()">Unlist Posts</div>
+				<div class="list-group-item rounded cursor-pointer font-weight-bold" @click="confirmModal()">Moderation Log</div>
+				<div class="list-group-item rounded cursor-pointer text-lighter" @click="ctxModOtherMenuClose()">Cancel</div>
+			</div>
+		</b-modal>
+		<b-modal ref="ctxShareModal"
+			id="ctx-share-modal"
+			title="Share"
+			hide-footer
+			hide-header
+			centered
+			rounded
+			size="sm"
+			body-class="list-group-flush p-0 rounded text-center">
+			<div class="list-group-item rounded cursor-pointer" @click="ctxMenuCopyLink()">Copy Link</div>
+			<div class="list-group-item rounded cursor-pointer text-lighter" @click="closeCtxShareMenu()">Cancel</div>
+		</b-modal>
+		<b-modal ref="ctxEmbedModal"
+			id="ctx-embed-modal"
+			hide-header
+			hide-footer
+			centered
+			rounded
+			size="md"
+			body-class="p-2 rounded">
+			<div>
+				<div class="form-group">
+					<textarea class="form-control disabled text-monospace" rows="8" style="overflow-y:hidden;border: 1px solid #efefef; font-size: 12px; line-height: 18px; margin: 0 0 7px;resize:none;" v-model="ctxEmbedPayload" disabled=""></textarea>
+				</div>
+				<div class="form-group pl-2 d-flex justify-content-center">
+					<div class="form-check mr-3">
+						<input class="form-check-input" type="checkbox" v-model="ctxEmbedShowCaption" :disabled="ctxEmbedCompactMode == true">
+						<label class="form-check-label font-weight-light">
+							Show Caption
+						</label>
+					</div>
+					<div class="form-check mr-3">
+						<input class="form-check-input" type="checkbox" v-model="ctxEmbedShowLikes" :disabled="ctxEmbedCompactMode == true">
+						<label class="form-check-label font-weight-light">
+							Show Likes
+						</label>
+					</div>
+					<div class="form-check">
+						<input class="form-check-input" type="checkbox" v-model="ctxEmbedCompactMode">
+						<label class="form-check-label font-weight-light">
+							Compact Mode
+						</label>
+					</div>
+				</div>
+				<hr>
+				<button :class="copiedEmbed ? 'btn btn-primary btn-block btn-sm py-1 font-weight-bold disabed': 'btn btn-primary btn-block btn-sm py-1 font-weight-bold'" @click="ctxCopyEmbed" :disabled="copiedEmbed">{{copiedEmbed ? 'Embed Code Copied!' : 'Copy Embed Code'}}</button>
+				<p class="mb-0 px-2 small text-muted">By using this embed, you agree to our <a href="/site/terms">Terms of Use</a></p>
+			</div>
+		</b-modal>
+		<b-modal ref="ctxReport"
+			id="ctx-report"
+			hide-header
+			hide-footer
+			centered
+			rounded
+			size="sm"
+			body-class="list-group-flush p-0 rounded">
+			<p class="py-2 px-3 mb-0">
+				<div class="text-center font-weight-bold text-danger">Report</div>
+				<div class="small text-center text-muted">Select one of the following options</div>
+			</p>
+			<div class="list-group text-center">
+				<div class="list-group-item rounded cursor-pointer font-weight-bold" @click="sendReport('spam')">Spam</div>
+				<div class="list-group-item rounded cursor-pointer font-weight-bold" @click="sendReport('sensitive')">Sensitive Content</div>
+				<div class="list-group-item rounded cursor-pointer font-weight-bold" @click="sendReport('abusive')">Abusive or Harmful</div>
+				<div class="list-group-item rounded cursor-pointer font-weight-bold" @click="openCtxReportOtherMenu()">Other</div>
+				<!-- <div class="list-group-item rounded cursor-pointer" @click="ctxReportMenuGoBack()">Go Back</div> -->
+				<div class="list-group-item rounded cursor-pointer text-lighter" @click="ctxReportMenuGoBack()">Cancel</div>
+			</div>
+		</b-modal>
+		<b-modal ref="ctxReportOther"
+			id="ctx-report-other"
+			hide-header
+			hide-footer
+			centered
+			rounded
+			size="sm"
+			body-class="list-group-flush p-0 rounded">
+			<p class="py-2 px-3 mb-0">
+				<div class="text-center font-weight-bold text-danger">Report</div>
+				<div class="small text-center text-muted">Select one of the following options</div>
+			</p>
+			<div class="list-group text-center">
+				<div class="list-group-item rounded cursor-pointer font-weight-bold" @click="sendReport('underage')">Underage Account</div>
+				<div class="list-group-item rounded cursor-pointer font-weight-bold" @click="sendReport('copyright')">Copyright Infringement</div>
+				<div class="list-group-item rounded cursor-pointer font-weight-bold" @click="sendReport('impersonation')">Impersonation</div>
+				<div class="list-group-item rounded cursor-pointer font-weight-bold" @click="sendReport('scam')">Scam or Fraud</div>
+				<!-- <div class="list-group-item rounded cursor-pointer font-weight-bold" @click="sendReport('terrorism')">Terrorism Related</div> -->
+				<!-- <div class="list-group-item rounded cursor-pointer font-weight-bold" @click="sendReport('other')">Other or Not listed</div> -->
+				<!-- <div class="list-group-item rounded cursor-pointer" @click="ctxReportOtherMenuGoBack()">Go Back</div> -->
+				<div class="list-group-item rounded cursor-pointer text-lighter" @click="ctxReportOtherMenuGoBack()">Cancel</div>
+			</div>
+		</b-modal>
+		<b-modal ref="ctxConfirm"
+			id="ctx-confirm"
+			hide-header
+			hide-footer
+			centered
+			rounded
+			size="sm"
+			body-class="list-group-flush p-0 rounded">
+			<div class="d-flex align-items-center justify-content-center py-3">
+				<div>{{ this.confirmModalTitle }}</div>
+			</div>
+			<div class="d-flex border-top btn-group btn-group-block rounded-0" role="group">
+				<button type="button" class="btn btn-outline-lighter border-left-0 border-top-0 border-bottom-0 border-right py-2" style="color: rgb(0,122,255) !important;" @click.prevent="confirmModalCancel()">Cancel</button>
+				<button type="button" class="btn btn-outline-lighter border-0" style="color: rgb(0,122,255) !important;" @click.prevent="confirmModalConfirm()">Confirm</button>
+			</div>
+		</b-modal>
+	</div>
+</template>
+
+<script type="text/javascript">
+	export default {
+		props: {
+			status: {
+				type: Object
+			},
+
+			profile: {
+				type: Object
+			},
+
+			type: {
+				type: String,
+				default: 'status',
+				validator: (val) => ['status', 'comment', 'profile'].includes(val)
+			},
+
+			groupId: {
+				type: String
+			}
+		},
+
+		data() {
+			return {
+				ctxMenuStatus: false,
+				ctxMenuRelationship: false,
+				ctxEmbedPayload: false,
+				copiedEmbed: false,
+				replySending: false,
+				ctxEmbedShowCaption: true,
+				ctxEmbedShowLikes: false,
+				ctxEmbedCompactMode: false,
+				confirmModalTitle: 'Are you sure?',
+				confirmModalIdentifer: null,
+				confirmModalType: false,
+			}
+		},
+
+		methods: {
+			open() {
+				this.ctxMenu();
+			},
+
+			ctxMenu() {
+				this.ctxMenuStatus = this.status;
+				this.ctxEmbedPayload = window.App.util.embed.post(this.status.url);
+				if(this.status.account.id == this.profile.id) {
+					this.ctxMenuRelationship = false;
+					this.$refs.ctxModal.show();
+				} else {
+					axios.get('/api/pixelfed/v1/accounts/relationships', {
+						params: {
+							'id[]': this.status.account.id
+						}
+					}).then(res => {
+						this.ctxMenuRelationship = res.data[0];
+						this.$refs.ctxModal.show();
+					});
+				}
+			},
+
+			closeCtxMenu() {
+				this.copiedEmbed = false;
+				this.ctxMenuStatus = false;
+				this.ctxMenuRelationship = false;
+				this.$refs.ctxModal.hide();
+				this.$refs.ctxReport.hide();
+				this.$refs.ctxReportOther.hide();
+				this.closeModals();
+			},
+
+			ctxMenuCopyLink() {
+				let status = this.ctxMenuStatus;
+				navigator.clipboard.writeText(status.url);
+				this.closeModals();
+				return;
+			},
+
+			ctxMenuGoToPost() {
+				let status = this.ctxMenuStatus;
+				window.location.href = this.statusUrl(status);
+				this.closeCtxMenu();
+				return;
+			},
+
+			ctxMenuGoToProfile() {
+				let status = this.ctxMenuStatus;
+				window.location.href = this.profileUrl(status);
+				this.closeCtxMenu();
+				return;
+			},
+
+			ctxMenuFollow() {
+				let id = this.ctxMenuStatus.account.id;
+				axios.post('/i/follow', {
+					item: id
+				}).then(res => {
+					let username = this.ctxMenuStatus.account.acct;
+					this.closeCtxMenu();
+					setTimeout(function() {
+						swal('Follow successful!', 'You are now following ' + username, 'success');
+					}, 500);
+				});
+			},
+
+			ctxMenuUnfollow() {
+				let id = this.ctxMenuStatus.account.id;
+				axios.post('/i/follow', {
+					item: id
+				}).then(res => {
+					let username = this.ctxMenuStatus.account.acct;
+					if(this.scope == 'home') {
+						this.feed = this.feed.filter(s => {
+							return s.account.id != this.ctxMenuStatus.account.id;
+						});
+					}
+					this.closeCtxMenu();
+					setTimeout(function() {
+						swal('Unfollow successful!', 'You are no longer following ' + username, 'success');
+					}, 500);
+				});
+			},
+
+			ctxMenuReportPost() {
+				this.$refs.ctxModal.hide();
+				this.$refs.ctxReport.show();
+				return;
+			},
+
+			ctxMenuEmbed() {
+				this.closeModals();
+				this.$refs.ctxEmbedModal.show();
+			},
+
+			ctxMenuShare() {
+				this.$refs.ctxModal.hide();
+				this.$refs.ctxShareModal.show();
+			},
+
+			closeCtxShareMenu() {
+				this.$refs.ctxShareModal.hide();
+				this.$refs.ctxModal.show();
+			},
+
+			ctxCopyEmbed() {
+				navigator.clipboard.writeText(this.ctxEmbedPayload);
+				this.ctxEmbedShowCaption = true;
+				this.ctxEmbedShowLikes = false;
+				this.ctxEmbedCompactMode = false;
+				this.$refs.ctxEmbedModal.hide();
+			},
+
+			ctxModMenuShow() {
+				this.$refs.ctxModal.hide();
+				this.$refs.ctxModModal.show();
+			},
+
+			ctxModOtherMenuShow() {
+				this.$refs.ctxModal.hide();
+				this.$refs.ctxModModal.hide();
+				this.$refs.ctxModOtherModal.show();
+			},
+
+			ctxModMenu() {
+				this.$refs.ctxModal.hide();
+			},
+
+			ctxModMenuClose() {
+				this.closeModals();
+			},
+
+			ctxModOtherMenuClose() {
+				this.closeModals();
+				this.$refs.ctxModModal.show();
+			},
+
+			formatCount(count) {
+				return App.util.format.count(count);
+			},
+
+			openCtxReportOtherMenu() {
+				let s = this.ctxMenuStatus;
+				this.closeCtxMenu();
+				this.ctxMenuStatus = s;
+				this.$refs.ctxReportOther.show();
+			},
+
+			ctxReportMenuGoBack() {
+				this.$refs.ctxReportOther.hide();
+				this.$refs.ctxReport.hide();
+				this.$refs.ctxModal.show();
+			},
+
+			ctxReportOtherMenuGoBack() {
+				this.$refs.ctxReportOther.hide();
+				this.$refs.ctxModal.hide();
+				this.$refs.ctxReport.show();
+			},
+
+			sendReport(type) {
+				let id = this.ctxMenuStatus.id;
+
+				swal({
+					'title': 'Confirm Report',
+					'text': 'Are you sure you want to report this post?',
+					'icon': 'warning',
+					'buttons': true,
+					'dangerMode': true
+				}).then((res) => {
+					if(res) {
+						axios.post(`/api/v0/groups/${this.groupId}/report/create`, {
+							'type': type,
+							'id': id,
+						}).then(res => {
+							this.closeCtxMenu();
+							swal('Report Sent!', 'We have successfully received your report.', 'success');
+						}).catch(err => {
+							if(err.response.status == 422) {
+								swal('Oops!', err.response.data.error, 'error');
+							} else {
+								swal('Oops!', 'There was an issue reporting this post.', 'error');
+							}
+						})
+					} else {
+						this.closeCtxMenu();
+					}
+				});
+			},
+
+			closeModals() {
+				this.$refs.ctxModal.hide();
+				this.$refs.ctxModModal.hide();
+				this.$refs.ctxModOtherModal.hide();
+				this.$refs.ctxShareModal.hide();
+				this.$refs.ctxEmbedModal.hide();
+				this.$refs.ctxReport.hide();
+				this.$refs.ctxReportOther.hide();
+				this.$refs.ctxConfirm.hide();
+			},
+
+			openCtxStatusModal() {
+				this.closeModals();
+				this.$refs.ctxStatusModal.show();
+			},
+
+			openConfirmModal() {
+				this.closeModals();
+				this.$refs.ctxConfirm.show();
+			},
+
+			closeConfirmModal() {
+				this.closeModals();
+				this.confirmModalTitle = 'Are you sure?';
+				this.confirmModalType = false;
+				this.confirmModalIdentifer = null;
+			},
+
+			confirmModalConfirm() {
+				switch(this.confirmModalType) {
+					case 'post.delete':
+						axios.post('/i/delete', {
+							type: 'status',
+							item: this.confirmModalIdentifer
+						}).then(res => {
+							this.feed = this.feed.filter(s => {
+								return s.id != this.confirmModalIdentifer;
+							});
+							this.closeConfirmModal();
+						}).catch(err => {
+							this.closeConfirmModal();
+							swal('Error', 'Something went wrong. Please try again later.', 'error');
+						});
+					break;
+				}
+
+				this.closeConfirmModal();
+			},
+
+			confirmModalCancel() {
+				this.closeConfirmModal();
+			},
+
+			moderatePost(status, action, $event) {
+				let username = status.account.username;
+				let pid = status.id;
+				let msg = '';
+				let self = this;
+				switch(action) {
+					case 'addcw':
+						msg = 'Are you sure you want to add a content warning to this post?';
+						swal({
+							title: 'Confirm',
+							text: msg,
+							icon: 'warning',
+							buttons: true,
+							dangerMode: true
+						}).then(res =>  {
+							if(res) {
+								axios.post('/api/v2/moderator/action', {
+									action: action,
+									item_id: status.id,
+									item_type: 'status'
+								}).then(res => {
+									swal('Success', 'Successfully added content warning', 'success');
+									status.sensitive = true;
+									self.closeModals();
+									self.ctxModMenuClose();
+								}).catch(err => {
+									swal(
+										'Error',
+										'Something went wrong, please try again later.',
+										'error'
+										);
+									self.closeModals();
+									self.ctxModMenuClose();
+								});
+							}
+						});
+					break;
+
+					case 'remcw':
+						msg = 'Are you sure you want to remove the content warning on this post?';
+						swal({
+							title: 'Confirm',
+							text: msg,
+							icon: 'warning',
+							buttons: true,
+							dangerMode: true
+						}).then(res =>  {
+							if(res) {
+								axios.post('/api/v2/moderator/action', {
+									action: action,
+									item_id: status.id,
+									item_type: 'status'
+								}).then(res => {
+									swal('Success', 'Successfully added content warning', 'success');
+									status.sensitive = false;
+									self.closeModals();
+									self.ctxModMenuClose();
+								}).catch(err => {
+									swal(
+										'Error',
+										'Something went wrong, please try again later.',
+										'error'
+										);
+									self.closeModals();
+									self.ctxModMenuClose();
+								});
+							}
+						});
+					break;
+
+					case 'unlist':
+						msg = 'Are you sure you want to unlist this post?';
+						swal({
+							title: 'Confirm',
+							text: msg,
+							icon: 'warning',
+							buttons: true,
+							dangerMode: true
+						}).then(res =>  {
+							if(res) {
+								axios.post('/api/v2/moderator/action', {
+									action: action,
+									item_id: status.id,
+									item_type: 'status'
+								}).then(res => {
+									this.feed = this.feed.filter(f => {
+										return f.id != status.id;
+									});
+									swal('Success', 'Successfully unlisted post', 'success');
+									self.closeModals();
+									self.ctxModMenuClose();
+								}).catch(err => {
+									self.closeModals();
+									self.ctxModMenuClose();
+									swal(
+										'Error',
+										'Something went wrong, please try again later.',
+										'error'
+										);
+								});
+							}
+						});
+					break;
+
+					case 'spammer':
+						msg = 'Are you sure you want to mark this user as a spammer? All existing and future posts will be unlisted on timelines and a content warning will be applied.';
+						swal({
+							title: 'Confirm',
+							text: msg,
+							icon: 'warning',
+							buttons: true,
+							dangerMode: true
+						}).then(res =>  {
+							if(res) {
+								axios.post('/api/v2/moderator/action', {
+									action: action,
+									item_id: status.id,
+									item_type: 'status'
+								}).then(res => {
+									swal('Success', 'Successfully marked account as spammer', 'success');
+									self.closeModals();
+									self.ctxModMenuClose();
+								}).catch(err => {
+									self.closeModals();
+									self.ctxModMenuClose();
+									swal(
+										'Error',
+										'Something went wrong, please try again later.',
+										'error'
+										);
+								});
+							}
+						});
+					break;
+				}
+			},
+
+			shareStatus(status, $event) {
+				if($('body').hasClass('loggedIn') == false) {
+					return;
+				}
+
+				this.closeModals();
+
+				axios.post('/i/share', {
+					item: status.id
+				}).then(res => {
+					status.reblogs_count = res.data.count;
+					status.reblogged = !status.reblogged;
+					if(status.reblogged) {
+						swal('Success', 'You shared this post', 'success');
+					} else {
+						swal('Success', 'You unshared this post', 'success');
+					}
+				}).catch(err => {
+					swal('Error', 'Something went wrong, please try again later.', 'error');
+				});
+			},
+
+			statusUrl(status) {
+				if(status.local == true) {
+					return status.url;
+				}
+
+				return '/i/web/post/_/' + status.account.id + '/' + status.id;
+			},
+
+			profileUrl(status) {
+				if(status.local == true) {
+					return status.account.url;
+				}
+
+				return '/i/web/profile/_/' + status.account.id;
+			},
+
+			deletePost(status) {
+				if($('body').hasClass('loggedIn') == false || this.ownerOrAdmin(status) == false) {
+					return;
+				}
+
+				if(window.confirm('Are you sure you want to delete this post?') == false) {
+					return;
+				}
+
+				axios.post('/i/delete', {
+					type: 'status',
+					item: status.id
+				}).then(res => {
+					this.$emit('status-delete', status.id);
+					this.closeModals();
+				}).catch(err => {
+					swal('Error', 'Something went wrong. Please try again later.', 'error');
+				});
+			},
+
+			owner(status) {
+				return this.profile.id === status.account.id;
+			},
+
+			admin() {
+				return this.profile.is_admin == true;
+			},
+
+			ownerOrAdmin(status) {
+				return this.owner(status) || this.admin();
+			},
+
+			archivePost(status) {
+				if(window.confirm('Are you sure you want to archive this post?') == false) {
+					return;
+				}
+
+				axios.post('/api/pixelfed/v2/status/' + status.id + '/archive')
+				.then(res => {
+					this.$emit('status-delete', status.id);
+					this.closeModals();
+				});
+			},
+
+			unarchivePost(status) {
+				if(window.confirm('Are you sure you want to unarchive this post?') == false) {
+					return;
+				}
+
+				axios.post('/api/pixelfed/v2/status/' + status.id + '/unarchive')
+				.then(res => {
+					this.closeModals();
+				});
+			}
+		}
+	}
+</script>

+ 59 - 0
resources/assets/components/groups/partials/CreateForm/CheckboxInput.vue

@@ -0,0 +1,59 @@
+<template>
+    <div class="form-group row">
+        <div class="col-sm-3">
+            <label class="col-form-label text-left">{{ label }}</label>
+        </div>
+        <div class="col-sm-9">
+            <div class="form-check">
+                <input class="form-check-input" type="checkbox" v-model="value">
+                <label
+                    class="form-check-label ml-1"
+                    :class="[ strongText ? 'font-weight-bold text-capitalize text-dark' : 'small text-muted' ]"
+                >
+                {{ inputText }}
+                </label>
+            </div>
+
+            <div
+                v-if="helpText"
+                class="help-text small text-muted d-flex flex-row justify-content-between gap-3">
+                <div v-if="helpText">{{ helpText }}</div>
+            </div>
+        </div>
+    </div>
+</template>
+
+<script>
+    export default {
+        props: {
+            label: {
+                type: String
+            },
+            inputText: {
+                type: String
+            },
+            val: {
+                type: String
+            },
+            helpText: {
+                type: String
+            },
+            strongText: {
+                type: Boolean,
+                default: true
+            }
+        },
+
+        data() {
+            return {
+                value: this.val
+            }
+        },
+
+        watch: {
+            value: function(newVal, oldVal) {
+                this.$emit('update', newVal);
+            }
+        }
+    }
+</script>

+ 70 - 0
resources/assets/components/groups/partials/CreateForm/SelectInput.vue

@@ -0,0 +1,70 @@
+<template>
+    <div class="form-group row">
+        <div class="col-sm-3">
+            <label class="col-form-label text-left">{{ label }}</label>
+        </div>
+        <div class="col-sm-9">
+            <select class="custom-select" v-model="value">
+                <option value="" selected="" disabled="">{{ placeholder }}</option>
+                <option v-for="c in categories" :value="c.value">{{ c.key }}</option>
+            </select>
+
+            <div
+                v-if="helpText || hasLimit"
+                class="help-text small text-muted d-flex flex-row justify-content-between gap-3">
+                <div v-if="helpText">{{ helpText }}</div>
+                <div
+                    v-if="hasLimit"
+                    class="font-weight-bold text-dark">
+                    {{ value ? value.length : 0 }}/{{ maxLimit }}
+                </div>
+            </div>
+        </div>
+    </div>
+</template>
+
+<script>
+    export default {
+        props: {
+            label: {
+                type: String
+            },
+            placeholder: {
+                type: String
+            },
+            categories: {
+                type: Array
+            },
+            val: {
+                type: String
+            },
+            helpText: {
+                type: String
+            },
+            hasLimit: {
+                type: Boolean,
+                default: false
+            },
+            maxLimit: {
+                type: Number,
+                default: 40
+            },
+            largeInput: {
+                type: Boolean,
+                default: false
+            }
+        },
+
+        data() {
+            return {
+                value: this.val ? this.val : ""
+            }
+        },
+
+        watch: {
+            value: function(newVal, oldVal) {
+                this.$emit('update', newVal);
+            }
+        }
+    }
+</script>

+ 86 - 0
resources/assets/components/groups/partials/CreateForm/TextAreaInput.vue

@@ -0,0 +1,86 @@
+<template>
+    <div class="form-group row">
+        <div class="col-sm-3">
+            <label class="col-form-label text-left">{{ label }}</label>
+        </div>
+        <div class="col-sm-9">
+            <textarea
+                v-if="hasLimit"
+                type="text"
+                class="form-control"
+                :class="{ 'form-control-lg': largeInput }"
+                style="resize:none;"
+                :placeholder="placeholder"
+                :maxlength="maxLimit"
+                :rows="rows"
+                v-model="value" />
+            <textarea
+                v-else
+                type="text"
+                class="form-control"
+                :class="{ 'form-control-lg': largeInput }"
+                style="resize:none;"
+                :placeholder="placeholder"
+                :rows="rows"
+                v-model="value" />
+
+            <div
+                v-if="helpText || hasLimit"
+                class="help-text small text-muted d-flex flex-row justify-content-between gap-3">
+                <div v-if="helpText">{{ helpText }}</div>
+                <div
+                    v-if="hasLimit"
+                    class="font-weight-bold text-dark">
+                    {{ value ? value.length : 0 }}/{{ maxLimit }}
+                </div>
+            </div>
+        </div>
+    </div>
+</template>
+
+<script>
+    export default {
+        props: {
+            label: {
+                type: String
+            },
+            placeholder: {
+                type: String
+            },
+            val: {
+                type: String
+            },
+            helpText: {
+                type: String
+            },
+            hasLimit: {
+                type: Boolean,
+                default: false
+            },
+            maxLimit: {
+                type: Number,
+                default: 40
+            },
+            largeInput: {
+                type: Boolean,
+                default: false
+            },
+            rows: {
+                type: Number,
+                default: 4
+            },
+        },
+
+        data() {
+            return {
+                value: this.val
+            }
+        },
+
+        watch: {
+            value: function(newVal, oldVal) {
+                this.$emit('update', newVal);
+            }
+        }
+    }
+</script>

+ 78 - 0
resources/assets/components/groups/partials/CreateForm/TextInput.vue

@@ -0,0 +1,78 @@
+<template>
+    <div class="form-group row">
+        <div class="col-sm-3">
+            <label class="col-form-label text-left">{{ label }}</label>
+        </div>
+        <div class="col-sm-9">
+            <input
+                v-if="hasLimit"
+                type="text"
+                class="form-control"
+                :class="{ 'form-control-lg': largeInput }"
+                :placeholder="placeholder"
+                :maxlength="maxLimit"
+                v-model="value">
+            <input
+                v-else
+                type="text"
+                class="form-control"
+                :class="{ 'form-control-lg': largeInput }"
+                :placeholder="placeholder"
+                v-model="value">
+
+            <div
+                v-if="helpText || hasLimit"
+                class="help-text small text-muted d-flex flex-row justify-content-between gap-3">
+                <div v-if="helpText">{{ helpText }}</div>
+                <div
+                    v-if="hasLimit"
+                    class="font-weight-bold text-dark">
+                    {{ value ? value.length : 0 }}/{{ maxLimit }}
+                </div>
+            </div>
+        </div>
+    </div>
+</template>
+
+<script>
+    export default {
+        props: {
+            label: {
+                type: String
+            },
+            placeholder: {
+                type: String
+            },
+            val: {
+                type: String
+            },
+            helpText: {
+                type: String
+            },
+            hasLimit: {
+                type: Boolean,
+                default: false
+            },
+            maxLimit: {
+                type: Number,
+                default: 40
+            },
+            largeInput: {
+                type: Boolean,
+                default: false
+            }
+        },
+
+        data() {
+            return {
+                value: this.val
+            }
+        },
+
+        watch: {
+            value: function(newVal, oldVal) {
+                this.$emit('update', newVal);
+            }
+        }
+    }
+</script>

+ 134 - 0
resources/assets/components/groups/partials/GroupAbout.vue

@@ -0,0 +1,134 @@
+<template>
+	<div class="group-about-component">
+		<div class="row justify-content-center">
+			<div class="col-12 col-md-7">
+				<div class="card shadow-none border mt-3 rounded-lg">
+					<div class="card-header bg-white">
+						<h5 class="mb-0">About This Group</h5>
+					</div>
+					<div class="card-body">
+						<p v-if="group.description && group.description.length > 1" class="description" v-html="group.description"></p>
+						<p v-else class="description">This group does not have a description.</p>
+						<p class="mb-0 font-weight-light text-lighter">Created: {{ timestampFormat(group.created_at) }}</p>
+					</div>
+				</div>
+			</div>
+			<div class="col-12 col-md-5">
+				<div class="card card-body mt-3 shadow-none border rounded-lg">
+					<div v-if="group.membership == 'all'" class="fact">
+						<div class="fact-icon">
+							<i class="fal fa-globe fa-lg"></i>
+						</div>
+						<div class="fact-body">
+							<p class="fact-title">Public</p>
+							<p class="fact-subtitle">Anyone can see who's in the group and what they post.</p>
+						</div>
+					</div>
+
+					<div v-if="group.membership == 'private'" class="fact">
+						<div class="fact-icon">
+							<i class="fal fa-lock fa-lg"></i>
+						</div>
+						<div class="fact-body">
+							<p class="fact-title">Private</p>
+							<p class="fact-subtitle">Only members can see who's in the group and what they post.</p>
+						</div>
+					</div>
+
+					<div class="fact">
+						<div class="fact-icon">
+							<i class="fal fa-eye fa-lg"></i>
+						</div>
+						<div class="fact-body">
+							<p class="fact-title">Visible</p>
+							<p class="fact-subtitle">Anyone can find this group.</p>
+						</div>
+					</div>
+
+					<div class="fact">
+						<div class="fact-icon">
+							<i class="fal fa-map-marker-alt fa-lg"></i>
+						</div>
+						<div class="fact-body">
+							<p class="fact-title">Fediverse</p>
+							<p class="fact-subtitle">This group has not specified a location.</p>
+						</div>
+					</div>
+
+					<div class="fact mb-0">
+						<div class="fact-icon">
+							<i class="fal fa-users fa-lg"></i>
+						</div>
+						<div class="fact-body">
+							<p class="fact-title"">General</p>
+							<p class="fact-subtitle">This group has not specified a category.</p>
+						</div>
+					</div>
+				</div>
+			</div>
+		</div>
+	</div>
+</template>
+
+<script type="text/javascript">
+	export default {
+		props: {
+			group: {
+				type: Object
+			}
+		},
+
+		methods: {
+			timestampFormat(date, showTime = false) {
+				let ts = new Date(date);
+				return showTime ? ts.toDateString() + ' · ' + ts.toLocaleTimeString() : ts.toDateString();
+			}
+		}
+	}
+</script>
+
+<style lang="scss" scoped>
+	.group-about-component {
+		margin-bottom: 50vh;
+
+		.title {
+			font-size: 16px;
+			font-weight: bold;
+		}
+
+		.description {
+			font-size: 15px;
+			font-weight:400;
+			color: #6c757d;
+			margin-bottom: 30px;
+			white-space: break-spaces;
+		}
+
+		.fact {
+			display: flex;
+			align-items: center;
+			margin-bottom: 1.5rem;
+
+			&-body {
+				flex: 1;
+			}
+
+			&-icon {
+				width: 50px;
+				text-align: center;
+			}
+
+			&-title {
+				font-size: 17px;
+				font-weight: 500;
+				margin-bottom: 0;
+			}
+
+			&-subtitle {
+				font-size: 14px;
+				margin-bottom: 0;
+				color: #6c757d;
+			}
+		}
+	}
+</style>

+ 174 - 0
resources/assets/components/groups/partials/GroupCard.vue

@@ -0,0 +1,174 @@
+<template>
+    <div class="col-12 col-md-6 col-xl-4 group-card">
+        <div class="group-card-inner">
+            <img
+                v-if="group.metadata && group.metadata.hasOwnProperty('header')"
+                :src="group.metadata.header.url"
+                class="group-header-img" />
+
+            <div
+                v-else
+                class="group-header-img"
+                :class="{ compact: compact }">
+                <div
+                    class="bg-light d-flex align-items-center justify-content-center rounded"
+                    style="width: 100%; height:100%;">
+                </div>
+            </div>
+
+            <div class="group-card-inner-copy">
+                <p class="font-weight-bold mb-0 text-dark" style="font-size: 16px;">
+                    {{ truncate(group.name || 'Untitled Group', titleLength) }}
+                </p>
+
+                <p class="text-muted mb-1" style="font-size: 12px;">
+                    {{ truncate(group.short_description, descriptionLength) }}
+                </p>
+                <p v-if="showStats" class="mb-0 small text-lighter">
+                    <span>
+                        <i class="fal fa-users"></i>
+                        <span class="small font-weight-bold">{{ prettyCount(group.member_count) }}</span>
+                    </span>
+
+                    <span v-if="!group.local" class="remote-label ml-3">
+                        <i class="fal fa-globe"></i> Remote
+                    </span>
+                </p>
+            </div>
+
+            <div class="group-card-inner-foaf">
+            </div>
+
+            <div class="group-card-inner-cta">
+                <router-link :to="`/groups/${group.id}`" class="btn btn-light btn-block font-weight-bold">
+                    Join Group
+                </router-link>
+            </div>
+        </div>
+    </div>
+</template>
+
+<script>
+    export default {
+        props: {
+            group: {
+                type: Object
+            },
+
+            compact: {
+                type: Boolean,
+                default: false
+            },
+
+            showStats: {
+                type: Boolean,
+                default: true
+            },
+
+            truncateTitleLength: {
+                type: Number,
+                default: 19
+            },
+
+            truncateDescriptionLength: {
+                type: Number,
+                default: 22
+            }
+        },
+
+        data() {
+            return {
+                titleLength: 40,
+                descriptionLength: 60
+            }
+        },
+
+        mounted() {
+            if(this.compact) {
+                this.titleLength = 19;
+                this.descriptionLength = 22;
+            }
+
+            if(this.truncateTitleLength != 19) {
+                this.titleLength = this.truncateTitleLength;
+            }
+
+            if(this.truncateDescriptionLength != 22) {
+                this.descriptionLength = this.truncateDescriptionLength;
+            }
+        },
+
+        methods: {
+            prettyCount(val) {
+                return App.util.format.count(val);
+            },
+
+            truncate(str, limit = 140) {
+                if(str.length <= limit) {
+                    return str;
+                }
+
+                return str.substr(0, limit) + ' ...';
+            }
+        }
+    }
+</script>
+
+<style lang="scss" scoped>
+    .group-card {
+        margin-bottom: 15px;
+
+        &-inner {
+            display: flex;
+            flex-direction: column;
+            border: 1px solid var(--border-color);
+            border-radius: 10px;
+            overflow: hidden;
+
+            &-copy {
+                background-color: var(--light);
+                padding: 1rem;
+            }
+
+            &-foaf {
+                background-color: var(--light);
+                height: 30px;
+            }
+
+            &-cta {
+                background-color: var(--light);
+                padding: 1rem;
+            }
+        }
+
+        .member-label {
+            padding: 2px 5px;
+            font-size: 9px;
+            color: rgba(75, 119, 190, 1);
+            background: rgba(137, 196, 244, 0.2);
+            border: 1px solid rgba(137, 196, 244, 0.3);
+            font-weight: 500;
+            text-transform: capitalize;
+            border-radius: 3px;
+        }
+
+        .remote-label {
+            padding: 2px 5px;
+            font-size: 9px;
+            color: #B45309;
+            background: #FEF3C7;
+            border: 1px solid #FCD34D;
+            font-weight: 500;
+            text-transform: capitalize;
+            border-radius: 3px;
+        }
+
+        .group-header-img {
+            width: 100%;
+            height: 150px;
+            object-fit: cover;
+            padding: 0px;
+            overflow: hidden;
+        }
+    }
+</style>

+ 345 - 0
resources/assets/components/groups/partials/GroupCompose.vue

@@ -0,0 +1,345 @@
+<template>
+	<div class="group-compose-form">
+		<input ref="photoInput" id="photoInput" type="file" class="d-none file-input" accept="image/jpeg,image/png" @change="handlePhotoChange">
+		<input ref="videoInput" id="videoInput" type="file" class="d-none file-input" accept="video/mp4" @change="handleVideoChange">
+		<div class="card card-body border mb-3 shadow-sm rounded-lg">
+			<div class="media align-items-top">
+				<img v-if="profile" :src="profile.avatar" class="rounded-circle border mr-3" width="42px" height="42px" onerror="this.onerror=null;this.src='/storage/avatars/default.png?v=2'">
+				<div class="media-body">
+					<div class="d-block" style="min-height: 80px;">
+						<div v-if="isUploading" class="w-100">
+							<p class="font-weight-light mb-1">Uploading media ...</p>
+							<div class="progress rounded-pill" style="height:4px">
+								<div class="progress-bar" role="progressbar" :aria-valuenow="uploadProgress" aria-valuemin="0" aria-valuemax="100" :style="{ width: uploadProgress + '%'}"></div>
+							</div>
+						</div>
+						<div v-else class="form-group mb-3">
+							<textarea
+								class="form-control"
+								:class="{
+									'form-control-lg': !composeText || composeText.length < 40,
+									'rounded-pill': !composeText || composeText.length < 40,
+									'bg-light': !composeText || composeText.length < 40,
+									'border-0': !composeText || composeText.length < 40
+								}"
+								:rows="!composeText || composeText.length < 40 ? 1 : 5"
+								:placeholder="placeholder"
+								style="resize: none;"
+								v-model="composeText"
+								></textarea>
+
+							<div v-if="composeText" class="small text-muted mt-1" style="min-height: 20px;">
+								<span class="float-right font-weight-bold">
+									{{ composeText ? composeText.length : 0 }}/500
+								</span>
+							</div>
+						</div>
+					</div>
+					<div v-if="tab" class="tab">
+						<div v-if="tab === 'poll'">
+							<p class="font-weight-bold text-muted small">
+								Poll Options
+							</p>
+
+							<div v-if="pollOptions.length < 4" class="form-group mb-4">
+								<input type="text" class="form-control rounded-pill" placeholder="Add a poll option, press enter to save" v-model="pollOptionModel" @keyup.enter="savePollOption">
+							</div>
+
+							<div v-for="(option, index) in pollOptions" class="form-group mb-4 d-flex align-items-center" style="max-width:400px;position: relative;">
+								<span class="font-weight-bold mr-2" style="position: absolute;left: 10px;">{{ index + 1 }}.</span>
+								<input v-if="pollOptions[index].length < 50" type="text" class="form-control rounded-pill" placeholder="Add a poll option, press enter to save" v-model="pollOptions[index]" style="padding-left: 30px;padding-right: 90px;">
+								<textarea v-else class="form-control" v-model="pollOptions[index]" placeholder="Add a poll option, press enter to save" rows="3" style="padding-left: 30px;padding-right:90px;"></textarea>
+								<button class="btn btn-danger btn-sm rounded-pill font-weight-bold" style="position: absolute;right: 5px;" @click="deletePollOption(index)">
+									<i class="fas fa-trash"></i> Delete
+								</button>
+							</div>
+
+							<hr>
+
+							<div class="d-flex justify-content-between">
+								<div>
+									<p class="font-weight-bold text-muted small">
+										Poll Expiry
+									</p>
+
+									<div class="form-group">
+										<select class="form-control rounded-pill" style="width: 200px;" v-model="pollExpiry">
+											<option value="60">1 hour</option>
+											<option value="360">6 hours</option>
+											<option value="1440" selected>24 hours</option>
+											<option value="10080">7 days</option>
+										</select>
+									</div>
+								</div>
+							</div>
+						</div>
+					</div>
+					<div v-if="!isUploading" class="">
+						<div>
+							<div v-if="photoName && photoName.length" class="bg-light rounded-pill mb-4 py-2">
+								<div class="media align-items-center">
+									<span style="width: 40px;height: 40px;border-radius:50px;opacity: 0.6;" class="d-flex align-items-center justify-content-center bg-primary mx-3">
+										<i class="fal fa-image fa-lg text-white"></i>
+									</span>
+									<div class="media-body">
+										<p class="mb-0 font-weight-bold text-muted">
+											{{ photoName }}
+										</p>
+									</div>
+									<button class="btn btn-link font-weight-bold text-decoration-none" @click.prevent="clearFileInputs">
+										Delete
+									</button>
+								</div>
+							</div>
+							<div v-if="videoName && videoName.length" class="bg-light rounded-pill mb-4 py-2">
+								<div class="media align-items-center">
+									<span style="width: 40px;height: 40px;border-radius:50px;opacity: 0.6;" class="d-flex align-items-center justify-content-center bg-primary mx-3">
+										<i class="fal fa-video fa-lg text-white"></i>
+									</span>
+									<div class="media-body">
+										<p class="mb-0 font-weight-bold text-muted">
+											{{ videoName }}
+										</p>
+									</div>
+									<button class="btn btn-link font-weight-bold text-decoration-none" @click.prevent="clearFileInputs">
+										Delete
+									</button>
+								</div>
+							</div>
+						</div>
+
+						<div>
+							<button
+								class="btn btn-light border font-weight-bold py-1 px-2 rounded-lg mr-3"
+								@click="switchTab('photo')"
+								:disabled="photoName || videoName">
+								<i class="fal fa-image mr-2"></i>
+								<span>Add Photo</span>
+							</button>
+							<!-- <button
+								class="btn btn-light border font-weight-bold py-1 px-2 rounded-lg mr-3"
+								@click="switchTab('video')"
+								:disabled="photoName || videoName">
+								<i class="fal fa-video mr-2"></i>
+								<span>Add Video</span>
+							</button>
+							<button
+								v-if="allowPolls"
+								:class="[ tab == 'poll' ? 'btn-primary' : 'btn-light' ]"
+								class="btn border font-weight-bold py-1 px-2 rounded-lg mr-3"
+								@click="switchTab('poll')"
+								:disabled="photoName || videoName">
+
+								<i class="fal fa-poll-h mr-2"></i>
+								<span>Add Poll</span>
+							</button> -->
+							<!-- <button v-if="allowEvent" class="btn btn-light border font-weight-bold py-1 px-2 rounded-lg">
+								<i class="fal fa-calendar-alt mr-1"></i>
+								<span>Create Event</span>
+							</button> -->
+						</div>
+					</div>
+				</div>
+			</div>
+			<p v-if="!isUploading && composeText && composeText.length > 1 || !isUploading && ['photo', 'video'].includes(tab)" class="mb-0">
+				<button class="btn btn-primary font-weight-bold float-right px-5 rounded-pill mt-3" @click="newPost()" :disabled="isPosting">
+					<span v-if="isPosting">
+						<div class="spinner-border text-white spinner-border-sm" role="status">
+							<span class="sr-only">Loading...</span>
+						</div>
+					</span>
+					<span v-else>Post</span>
+				</button>
+			</p>
+		</div>
+	</div>
+</template>
+
+<script type="text/javascript">
+	export default {
+		props: {
+			profile: {
+				type: Object
+			},
+
+			groupId: {
+				type: String
+			}
+		},
+
+		data() {
+			return {
+				config: window.App.config,
+				composeText: undefined,
+				tab: null,
+				placeholder: 'Write something...',
+				allowPhoto: true,
+				allowVideo: true,
+				allowPolls: true,
+				allowEvent: true,
+				pollOptionModel: null,
+				pollOptions: [],
+				pollExpiry: 1440,
+				uploadProgress: 0,
+				isUploading: false,
+				isPosting: false,
+				photoName: undefined,
+				videoName: undefined
+			}
+		},
+
+		methods: {
+			newPost() {
+				if(this.isPosting) {
+					return;
+				}
+
+				this.isPosting = true;
+				let self = this;
+				let type = 'text';
+				let data = new FormData();
+				data.append('group_id', this.groupId);
+				if(this.composeText && this.composeText.length) {
+					data.append('caption', this.composeText);
+				}
+
+				switch(this.tab) {
+					case 'poll':
+						if(!this.pollOptions || this.pollOptions.length < 2 || this.pollOptions.length > 4) {
+							swal('Oops!', 'A poll must have 2-4 choices.', 'error');
+							return;
+						}
+
+						if(!this.composeText || this.composeText.length < 5) {
+							swal('Oops!', 'A poll question must be at least 5 characters.', 'error');
+							return;
+						}
+
+						for (var i = 0; i < this.pollOptions.length; i++) {
+							data.append('pollOptions[]', this.pollOptions[i]);
+						}
+
+						data.append('expiry', this.pollExpiry);
+						type = 'poll';
+					break;
+
+					case 'photo':
+						data.append('photo', this.$refs.photoInput.files[0]);
+						type = 'photo';
+						this.isUploading = true;
+					break;
+
+					case 'video':
+						data.append('video', this.$refs.videoInput.files[0]);
+						type = 'video';
+						this.isUploading = true;
+					break;
+				}
+
+				data.append('type', type);
+
+				axios.post('/api/v0/groups/status/new', data, {
+					onUploadProgress: function(progressEvent) {
+						self.uploadProgress = Math.floor(progressEvent.loaded / progressEvent.total * 100);
+					}
+				})
+				.then(res => {
+					this.isPosting = false;
+					this.isUploading = false;
+					this.uploadProgress = 0;
+					this.composeText = null;
+					this.photo = null;
+					this.tab = null;
+					this.clearFileInputs(false);
+					this.$emit('new-status', res.data);
+				}).catch(err => {
+					if(err.response.status == 422) {
+						this.isPosting = false;
+						this.isUploading = false;
+						this.uploadProgress = 0;
+						this.photo = null;
+						this.tab = null;
+						this.clearFileInputs(false);
+						swal('Oops!', err.response.data.error, 'error');
+					} else {
+						this.isPosting = false;
+						this.isUploading = false;
+						this.uploadProgress = 0;
+						this.photo = null;
+						this.tab = null;
+						this.clearFileInputs(false);
+						swal('Oops!', 'An error occured while processing your request, please try again later', 'error');
+					}
+				});
+			},
+
+			switchTab(newTab) {
+				if(newTab == this.tab) {
+					this.tab = null;
+					this.placeholder = 'Write something...';
+					return;
+				}
+
+				switch(newTab) {
+					case 'poll':
+						this.placeholder = 'Write poll question here...'
+					break;
+
+					case 'photo':
+						this.$refs.photoInput.click();
+					break;
+
+					case 'video':
+						this.$refs.videoInput.click();
+					break;
+
+					default:
+						this.placeholder = 'Write something...';
+				}
+
+				this.tab = newTab;
+			},
+
+			savePollOption() {
+				if(this.pollOptions.indexOf(this.pollOptionModel) != -1) {
+					this.pollOptionModel = null;
+					return;
+				}
+				this.pollOptions.push(this.pollOptionModel);
+				this.pollOptionModel = null;
+			},
+
+			deletePollOption(index) {
+				this.pollOptions.splice(index, 1);
+			},
+
+			handlePhotoChange() {
+				event.currentTarget.blur();
+				this.tab = 'photo';
+				this.photoName = event.target.files[0].name;
+			},
+
+			handleVideoChange() {
+				event.currentTarget.blur();
+				this.tab = 'video';
+				this.videoName = event.target.files[0].name;
+			},
+
+			clearFileInputs(blur = true) {
+				if(blur) {
+ 					event.currentTarget.blur();
+				}
+				this.tab = null;
+				this.$refs.photoInput.value = null;
+				this.photoName = null;
+				this.$refs.videoInput.value = null;
+				this.videoName = null;
+			}
+		}
+	}
+</script>
+
+<style lang="scss">
+	.group-compose-form {
+	}
+</style>

+ 0 - 0
resources/assets/components/groups/partials/GroupEvents.vue


+ 135 - 0
resources/assets/components/groups/partials/GroupInfoCard.vue

@@ -0,0 +1,135 @@
+<template>
+	<div class="group-info-card">
+		<div class="card card-body mt-3 shadow-none border rounded-lg">
+			<p class="title">About</p>
+
+			<p v-if="group.description && group.description.length > 1" class="description" v-html="group.description"></p>
+			<p v-else class="description">This group does not have a description.</p>
+
+		</div>
+		<div class="card card-body mt-3 shadow-none border rounded-lg">
+			<div v-if="group.membership == 'all'" class="fact">
+				<div class="fact-icon">
+					<i class="fal fa-globe fa-lg"></i>
+				</div>
+				<div class="fact-body">
+					<p class="fact-title">Public</p>
+					<p class="fact-subtitle">Anyone can see who's in the group and what they post.</p>
+				</div>
+			</div>
+
+			<div v-if="group.membership == 'private'" class="fact">
+				<div class="fact-icon">
+					<i class="fal fa-lock fa-lg"></i>
+				</div>
+				<div class="fact-body">
+					<p class="fact-title">Private</p>
+					<p class="fact-subtitle">Only members can see who's in the group and what they post.</p>
+				</div>
+			</div>
+
+			<div v-if="group.config.discoverable == true" class="fact">
+				<div class="fact-icon">
+					<i class="fal fa-eye fa-lg"></i>
+				</div>
+				<div class="fact-body">
+					<p class="fact-title">Visible</p>
+					<p class="fact-subtitle">Anyone can find this group.</p>
+				</div>
+			</div>
+
+			<div v-if="group.config.discoverable == false" class="fact">
+				<div class="fact-icon">
+					<i class="fal fa-eye-slash fa-lg"></i>
+				</div>
+				<div class="fact-body">
+					<p class="fact-title">Hidden</p>
+					<p class="fact-subtitle">Only members can find this group.</p>
+				</div>
+			</div>
+
+			<!-- <div class="fact">
+				<div class="fact-icon">
+					<i class="fal fa-map-marker-alt fa-lg"></i>
+				</div>
+				<div class="fact-body">
+					<p class="fact-title">Fediverse</p>
+					<p class="fact-subtitle">This group has not specified a location.</p>
+				</div>
+			</div> -->
+
+			<div class="fact">
+				<div class="fact-icon">
+					<i class="fal fa-users fa-lg"></i>
+				</div>
+				<div class="fact-body">
+					<p class="fact-title"">{{ group.category.name }}</p>
+					<p class="fact-subtitle">Category</p>
+				</div>
+			</div>
+
+			<p class="mb-0 font-weight-light text-lighter">Created: {{ timestampFormat(group.created_at) }}</p>
+		</div>
+	</div>
+</template>
+
+<script type="text/javascript">
+	export default {
+		props: {
+			group: {
+				type: Object
+			}
+		},
+
+		methods: {
+			timestampFormat(date, showTime = false) {
+				let ts = new Date(date);
+				return showTime ? ts.toDateString() + ' · ' + ts.toLocaleTimeString() : ts.toDateString();
+			}
+		}
+	}
+</script>
+
+<style lang="scss" scoped>
+	.group-info-card {
+		.title {
+			font-size: 16px;
+			font-weight: bold;
+		}
+
+		.description {
+			font-size: 15px;
+			font-weight:400;
+			color: #6c757d;
+			margin-bottom: 0;
+			white-space: break-spaces;
+		}
+
+		.fact {
+			display: flex;
+			align-items: center;
+			margin-bottom: 1.5rem;
+
+			&-body {
+				flex: 1;
+			}
+
+			&-icon {
+				width: 50px;
+				text-align: center;
+			}
+
+			&-title {
+				font-size: 17px;
+				font-weight: 500;
+				margin-bottom: 0;
+			}
+
+			&-subtitle {
+				font-size: 14px;
+				margin-bottom: 0;
+				color: #6c757d;
+			}
+		}
+	}
+</style>

+ 60 - 0
resources/assets/components/groups/partials/GroupInsights.vue

@@ -0,0 +1,60 @@
+<template>
+	<div class="group-insights-component">
+		<div class="row justify-content-center">
+			<div class="col-12 col-md-3 mb-3">
+				<div class="bg-light p-3 border rounded d-flex justify-content-between align-items-center">
+					<p class="h3 font-weight-bold mb-0">124K</p>
+					<p class="font-weight-bold text-uppercase text-lighter mb-0">Posts</p>
+				</div>
+			</div>
+
+			<div class="col-12 col-md-3 mb-3">
+				<div class="bg-light p-3 border rounded d-flex justify-content-between align-items-center">
+					<p class="h3 font-weight-bold mb-0">9K</p>
+					<p class="font-weight-bold text-uppercase text-lighter mb-0">Users</p>
+				</div>
+			</div>
+
+			<div class="col-12 col-md-3 mb-3">
+				<div class="bg-light p-3 border rounded d-flex justify-content-between align-items-center">
+					<p class="h3 font-weight-bold mb-0">1.7M</p>
+					<p class="font-weight-bold text-uppercase text-lighter mb-0">Interactions</p>
+				</div>
+			</div>
+		</div>
+		<div class="row justify-content-center">
+			<div class="col-12 col-md-3 mb-3">
+				<div class="bg-light p-3 border rounded d-flex justify-content-between align-items-center">
+					<p class="h3 font-weight-bold mb-0">50</p>
+					<p class="font-weight-bold text-uppercase text-lighter mb-0">Mod Reports</p>
+				</div>
+			</div>
+		</div>
+	</div>
+</template>
+
+<script type="text/javascript">
+	export default {
+		props: {
+			group: {
+				type: Object
+			}
+		},
+
+		data() {
+			return {
+
+			};
+		},
+
+		methods: {
+
+		}
+	}
+</script>
+
+<style lang="scss" scoped>
+	.group-insights-component {
+
+	}
+</style>

+ 190 - 0
resources/assets/components/groups/partials/GroupInviteModal.vue

@@ -0,0 +1,190 @@
+<template>
+	<div class="group-invite-modal">
+		<b-modal
+			ref="modal"
+			hide-header
+			hide-footer
+			centered
+			rounded
+			body-class="rounded group-invite-modal-wrapper">
+
+			<div class="text-center py-3 d-flex align-items-center flex-column">
+				<div class="bg-light rounded-circle d-flex justify-content-center align-items-center mb-3" style="width: 100px;height: 100px;">
+					<i class="far fa-user-plus fa-2x text-lighter"></i>
+				</div>
+				<p class="h4 font-weight-bold mb-0">Invite Friends</p>
+				<!-- <p class="mb-0">Search {{ group.name }} for posts, comments or members.</p> -->
+			</div>
+
+			<transition name="fade">
+				<div v-if="usernames.length < 5" class="d-flex justify-content-between mt-1">
+					<autocomplete
+						:search="autocompleteSearch"
+						placeholder="Search friends by username"
+						aria-label="Search this group"
+						:get-result-value="getSearchResultValue"
+						:debounceTime="700"
+						@submit="onSearchSubmit"
+						style="width: 100%;"
+						ref="autocomplete"
+						>
+							<template #result="{ result, props }">
+							<li
+							v-bind="props"
+							class="autocomplete-result"
+							>
+								<div class="text-truncate">
+									<p class="result-name mb-0 font-weight-bold">
+										{{ result.username }}
+									</p>
+								</div>
+							</li>
+						</template>
+					</autocomplete>
+
+					<button class="btn btn-light border rounded-circle text-lighter ml-3" style="width: 52px;height:50px;" @click="close">
+						<i class="fal fa-times fa-lg"></i>
+					</button>
+				</div>
+			</transition>
+
+			<transition name="fade">
+				<div v-if="usernames.length" class="pt-3">
+					<div v-for="(result, index) in usernames" class="py-1">
+						<div class="media align-items-center">
+							<img src="/storage/avatars/default.jpg" class="rounded-circle border mr-3" width="45" height="45">
+							<div class="media-body">
+								<p class="lead mb-0">{{ result.username }}</p>
+							</div>
+							<button class="btn btn-link text-lighter btn-sm" @click="removeUsername(index)"><i class="far fa-times-circle fa-lg"></i></button>
+						</div>
+					</div>
+				</div>
+			</transition>
+
+			<transition name="fade">
+				<button v-if="usernames && usernames.length" class="btn btn-primary btn-lg btn-block font-weight-bold rounded font-weight-bold mt-3" @click="submitInvites">Invite</button>
+			</transition>
+
+			<div class="text-center pt-3 small">
+				<p class="mb-0">You can invite up to 5 friends at a time, and 20 friends in total.</p>
+			</div>
+		</b-modal>
+	</div>
+</template>
+
+<script type="text/javascript">
+	import Autocomplete from '@trevoreyre/autocomplete-vue'
+	import '@trevoreyre/autocomplete-vue/dist/style.css'
+
+	export default {
+		props: {
+			group: {
+				type: Object
+			},
+
+			profile: {
+				type: Object
+			}
+		},
+
+		components: {
+			'autocomplete-input': Autocomplete,
+		},
+
+		data() {
+			return {
+				query: '',
+				recent: [],
+				loaded: false,
+				usernames: [],
+				isSubmitting: false
+			}
+		},
+
+		methods: {
+			open() {
+				this.$refs.modal.show();
+			},
+
+			close() {
+				this.$refs.modal.hide();
+			},
+
+			autocompleteSearch(q) {
+				if (!q || q.length == 0) {
+					return [];
+				}
+
+				return axios.post(`/api/v0/groups/search/invite/friends`, {
+					q: q,
+					g: this.group.id,
+					v: '0.2'
+				}).then(res => {
+					let data = res.data.filter(r => {
+						return this.usernames.map(u => u.username).indexOf(r.username) == -1;
+					});
+					return data;
+				});
+				return [];
+			},
+
+			getSearchResultValue(result) {
+				return result.username;
+			},
+
+			onSearchSubmit(result) {
+				this.usernames.push(result);
+				this.$refs.autocomplete.value = '';
+				//
+			},
+
+			removeUsername(index) {
+				event.currentTarget.blur();
+				this.usernames.splice(index, 1);
+			},
+
+			submitInvites() {
+				this.isSubmitting = true;
+				event.currentTarget.blur();
+				axios.post('/api/v0/groups/search/invite/friends/send', {
+					g: this.group.id,
+					uids: this.usernames.map(u => u.id)
+				}).then(res => {
+					this.usernames = [];
+					this.isSubmitting = false;
+					this.close();
+					swal('Success', 'Successfully sent invite(s)', 'success');
+				}).catch(err => {
+					this.usernames = [];
+					this.isSubmitting = false;
+					if(err.response.status === 422) {
+						swal('Error', err.response.data.error, 'error');
+					} else {
+						swal('Oops!', 'An error occured, please try again later', 'error');
+					}
+					this.close();
+				})
+			}
+		}
+	}
+</script>
+
+<style lang="scss">
+	.group-invite-modal {
+
+		&-wrapper {
+			.media {
+				height: 60px;
+				padding: 10px;
+				border-radius: 10px;
+				user-select: none;
+				cursor: pointer;
+
+				&:hover {
+					background-color: #E5E7EB;
+				}
+			}
+		}
+	}
+</style>

+ 156 - 0
resources/assets/components/groups/partials/GroupListCard.vue

@@ -0,0 +1,156 @@
+<template>
+	<div class="group-list-card">
+		<div class="media">
+			<div class="media align-items-center">
+				<img
+					v-if="group.metadata && group.metadata.hasOwnProperty('header')"
+					:src="group.metadata.header.url"
+					class="mr-3 border rounded group-header-img"
+					:class="{ compact: compact }">
+
+				<div
+					v-else
+					class="mr-3 border rounded group-header-img"
+					:class="{ compact: compact }">
+					<div
+						class="bg-primary d-flex align-items-center justify-content-center rounded"
+						style="width: 100%; height:100%;">
+						<i class="fal fa-users text-white fa-lg"></i>
+					</div>
+				</div>
+
+				<div class="media-body">
+					<p class="font-weight-bold mb-0 text-dark" style="font-size: 16px;">
+						{{ truncate(group.name || 'Untitled Group', titleLength) }}
+					</p>
+
+					<p class="text-muted mb-1" style="font-size: 12px;">
+						{{ truncate(group.short_description, descriptionLength) }}
+					</p>
+
+					<p v-if="showStats" class="mb-0 small text-lighter">
+						<span>
+							<i class="far fa-users"></i>
+							<span class="small font-weight-bold">{{ prettyCount(group.member_count) }}</span>
+						</span>
+
+						<span v-if="!group.local" class="remote-label ml-3">
+							<i class="fal fa-globe"></i> Remote
+						</span>
+
+						<span v-if="group.hasOwnProperty('admin') && group.admin.hasOwnProperty('username')" class="ml-3">
+							<i class="fal fa-user-crown"></i>
+							<span class="small font-weight-bold">
+								&commat;{{ group.admin.username }}
+							</span>
+						</span>
+					</p>
+				</div>
+			</div>
+		</div>
+	</div>
+</template>
+
+<script type="text/javascript">
+	export default {
+		props: {
+			group: {
+				type: Object
+			},
+
+			compact: {
+				type: Boolean,
+				default: false
+			},
+
+			showStats: {
+				type: Boolean,
+				default: false
+			},
+
+			truncateTitleLength: {
+				type: Number,
+				default: 19
+			},
+
+			truncateDescriptionLength: {
+				type: Number,
+				default: 22
+			}
+		},
+
+		data() {
+			return {
+				titleLength: 40,
+				descriptionLength: 60
+			}
+		},
+
+		mounted() {
+			if(this.compact) {
+				this.titleLength = 19;
+				this.descriptionLength = 22;
+			}
+
+			if(this.truncateTitleLength != 19) {
+				this.titleLength = this.truncateTitleLength;
+			}
+
+			if(this.truncateDescriptionLength != 22) {
+				this.descriptionLength = this.truncateDescriptionLength;
+			}
+		},
+
+		methods: {
+			prettyCount(val) {
+				return App.util.format.count(val);
+			},
+
+			truncate(str, limit = 140) {
+				if(str.length <= limit) {
+					return str;
+				}
+
+				return str.substr(0, limit) + ' ...';
+			}
+		}
+	}
+</script>
+
+<style lang="scss" scoped>
+	.group-list-card {
+		.member-label {
+			padding: 2px 5px;
+			font-size: 9px;
+			color: rgba(75, 119, 190, 1);
+			background: rgba(137, 196, 244, 0.2);
+			border: 1px solid rgba(137, 196, 244, 0.3);
+			font-weight: 500;
+			text-transform: capitalize;
+			border-radius: 3px;
+		}
+
+		.remote-label {
+			padding: 2px 5px;
+			font-size: 9px;
+			color: #B45309;
+			background: #FEF3C7;
+			border: 1px solid #FCD34D;
+			font-weight: 500;
+			text-transform: capitalize;
+			border-radius: 3px;
+		}
+
+		.group-header-img {
+			width: 60px;
+			height: 60px;
+			object-fit: cover;
+			padding:0px;
+
+			&.compact {
+				width: 42.5px;
+				height: 42.5px;
+			}
+		}
+	}
+</style>

+ 262 - 0
resources/assets/components/groups/partials/GroupMedia.vue

@@ -0,0 +1,262 @@
+<template>
+	<div class="group-media-component">
+		<div class="row justify-content-center">
+			<div class="col-12">
+				<div class="card card-body border shadow-sm">
+					<div class="d-flex justify-content-between align-items-center mb-3">
+						<p class="h4 font-weight-bold mb-0">Media</p>
+						<!-- <div>
+							<a href="#" class="font-weight-bold mr-3"><i class="fas fa-plus fa-sm"></i> Create Album</a>
+							<a href="#" class="font-weight-bold">Add Photos/Video</a>
+						</div> -->
+					</div>
+
+					<div v-if="isLoaded">
+						<div class="mb-5">
+							<button
+								:class="[ tab == 'photo' ? 'text-primary font-weight-bold' : 'text-lighter' ]"
+								class="btn btn-light mr-2"
+								@click="switchTab('photo')">
+								Photos
+							</button>
+							<button
+								:class="[ tab == 'video' ? 'text-primary font-weight-bold' : 'text-lighter' ]"
+								class="btn btn-light mr-2"
+								@click="switchTab('video')">
+								Videos
+							</button>
+							<button
+								:class="[ tab == 'album' ? 'text-primary font-weight-bold' : 'text-lighter' ]"
+								class="btn btn-light mr-2"
+								@click="switchTab('album')">
+								Albums
+							</button>
+						</div>
+
+						<div v-if="tab == 'photo'" class="row px-3">
+							<div v-for="(status, index) in photos" class="m-1">
+								<a :href="status.url" class="bh-content">
+									<img :src="getMediaSource(status)" width="205" height="205" style="object-fit: cover;">
+								</a>
+							</div>
+
+							<div v-if="photos.length == 0" class="col-12 py-5 text-center">
+								<p class="lead font-weight-bold mb-0">No photos found</p>
+							</div>
+						</div>
+
+						<div v-if="tab == 'video'" class="row px-3">
+							<div v-for="(status, index) in videos" class="m-1">
+								<a :href="status.url" class="bh-content text-decoration-none">
+									<img v-if="!status.media_attachments[0].preview_url.endsWith('no-preview.png')" :src="getMediaSource(status)" width="205" height="205" style="object-fit: cover;">
+									<div v-else class="bg-light text-dark d-flex align-items-center justify-content-center border" style="width:205px;height:205px;">
+										<p class="font-weight-bold mb-0">No preview available</p>
+									</div>
+								</a>
+							</div>
+
+							<div v-if="videos.length == 0" class="col-12 py-5 text-center">
+								<p class="lead font-weight-bold mb-0">No videos found</p>
+							</div>
+						</div>
+
+						<div v-if="tab == 'album'" class="row px-3">
+							<div v-for="(status, index) in albums" class="m-1">
+								<a :href="status.url" class="bh-content">
+									<img :src="getMediaSource(status)" width="205" height="205" style="object-fit: cover;">
+								</a>
+							</div>
+
+							<div v-if="albums.length == 0" class="col-12 py-5 text-center">
+								<p class="lead font-weight-bold mb-0">No albums found</p>
+							</div>
+						</div>
+
+						<div v-if="hasNextPage[tab]" class="mt-3">
+							<button class="btn btn-light font-weight-bold btn-block border" @click="loadNextPage">Load more</button>
+						</div>
+					</div>
+					<div v-else class="d-flex align-items-center justify-content-center" style="height:500px">
+						<div class="spinner-border text-primary" role="status">
+							<span class="sr-only">Loading...</span>
+						</div>
+					</div>
+				</div>
+			</div>
+		</div>
+	</div>
+</template>
+
+<script type="text/javascript">
+	export default {
+		props: {
+			group: {
+				type: Object
+			}
+		},
+
+		data() {
+			return {
+				isLoaded: false,
+				feed: [],
+				photos: [],
+				videos: [],
+				albums: [],
+				tab: 'photo',
+				tabs: [
+					'photo',
+					'video',
+					'album'
+				],
+				page: {
+					'photo': 1,
+					'video': 1,
+					'album': 1
+				},
+				hasNextPage: {
+					'photo': false,
+					'video': false,
+					'album': false
+				}
+			}
+		},
+
+		mounted() {
+			this.fetchMedia();
+		},
+
+		methods: {
+			fetchMedia() {
+				axios.get('/api/v0/groups/media/list', {
+					params: {
+						gid: this.group.id,
+						page: this.page[this.tab],
+						type: this.tab
+					}
+				}).then(res => {
+					if(res.data.length > 0) {
+						this.hasNextPage[this.tab] = true;
+					}
+
+					this.isLoaded = true;
+
+					res.data.forEach(status => {
+						if(status.pf_type == 'photo') {
+							this.photos.push(status);
+						}
+
+						if(status.pf_type == 'video') {
+							this.videos.push(status);
+						}
+
+						if(status.pf_type == 'photo:album') {
+							this.albums.push(status);
+						}
+					})
+					this.page[this.tab] = this.page[this.tab] + 1;
+				}).catch(err => {
+					this.hasNextPage[this.tab] = false;
+					console.log(err.response);
+				})
+			},
+
+			loadNextPage() {
+				axios.get('/api/v0/groups/media/list', {
+					params: {
+						gid: this.group.id,
+						page: this.page[this.tab],
+						type: this.tab,
+					}
+				}).then(res => {
+					if(res.data.length == 0) {
+						this.hasNextPage[this.tab] = false;
+						return;
+					}
+					res.data.forEach(status => {
+						if(status.pf_type == 'photo') {
+							this.photos.push(status);
+						}
+
+						if(status.pf_type == 'video') {
+							this.videos.push(status);
+						}
+
+						if(status.pf_type == 'photo:album') {
+							this.albums.push(status);
+						}
+					})
+					this.page[this.tab] = this.page[this.tab] + 1;
+				}).catch(err => {
+					this.hasNextPage[this.tab] = false;
+				})
+			},
+
+			formatDate(ts) {
+				return new Date(ts).toDateString();
+			},
+
+			switchTab(tab) {
+				this.tab = tab;
+				this.fetchMedia();
+			},
+
+			lightbox(status) {
+				this.lightboxStatus = status.media_attachments[0];
+				this.$refs.lightboxModal.show();
+			},
+
+			hideLightbox() {
+				this.lightboxStatus = null;
+				this.$refs.lightboxModal.hide();
+			},
+
+			blurhashWidth(status) {
+				if(!status.media_attachments[0].meta) {
+					return 25;
+				}
+				let aspect = status.media_attachments[0].meta.original.aspect;
+				if(aspect == 1) {
+					return 25;
+				} else if(aspect > 1) {
+					return 30;
+				} else {
+					return 20;
+				}
+			},
+
+			blurhashHeight(status) {
+				if(!status.media_attachments[0].meta) {
+					return 25;
+				}
+				let aspect = status.media_attachments[0].meta.original.aspect;
+				if(aspect == 1) {
+					return 25;
+				} else if(aspect > 1) {
+					return 20;
+				} else {
+					return 30;
+				}
+			},
+
+			getMediaSource(status) {
+				let media = status.media_attachments[0];
+
+				if(media.preview_url && media.preview_url.endsWith('storage/no-preview.png')) {
+					return media.url;
+				}
+
+                if(media.preview_url && media.preview_url.length) {
+                    return media.url;
+                }
+
+				return media.url;
+			}
+		}
+	}
+</script>
+
+<style lang="scss">
+	.group-members-component {
+
+	}
+</style>

+ 684 - 0
resources/assets/components/groups/partials/GroupMembers.vue

@@ -0,0 +1,684 @@
+<template>
+	<div class="group-members-component">
+		<div class="row justify-content-center">
+			<div class="col-12 col-md-8 mb-5">
+				<div v-if="isAdmin && requestCount && !hideHeader" class="card card-body border shadow-sm bg-dark text-light mb-4 rounded-pill p-2 pl-3">
+					<div class="d-flex align-items-center justify-content-between">
+						<span class="lead mb-0 text-lighter">
+							<i class="fal fa-exclamation-triangle mr-2 text-warning"></i>
+							You have <strong class="text-white">{{ requestCount }}</strong> member applications to review
+						</span>
+						<span>
+							<button class="btn btn-primary font-weight-bold rounded-pill btn-sm px-3" @click="reviewApplicants()">Review</button>
+						</span>
+					</div>
+				</div>
+
+				<div class="card card-body border shadow-sm">
+					<div v-if="!hideHeader">
+						<p class="d-flex align-items-center mb-0">
+							<span class="lead font-weight-bold">Members</span>
+							<span class="mx-2">·</span>
+							<span class="text-muted">{{group.member_count}}</span>
+						</p>
+						<!-- <p class="text-muted mb-0">
+							New people who join this group will appear here. <a class="font-weight-bold text-dark" href="#">Learn More</a>
+						</p> -->
+						<div class="form-group mt-3" style="position: relative;">
+							<i class="fas fa-search fa-lg text-lighter" style="position: absolute;left:20px; top:50%;transform:translateY(-50%);"></i>
+							<input class="form-control form-control-lg bg-light border rounded-pill" style="padding-left: 50px;padding-right: 50px;" placeholder="Find a member" v-model="memberSearchModel">
+							<button class="btn btn-primary font-weight-bold rounded-pill px-3" style="position: absolute;right: 6px; top: 50%;transform: translateY(-50%);">Search</button>
+						</div>
+
+						<hr>
+					</div>
+
+					<div v-if="tab == 'list'">
+						<div class="group-members-component-paginated-list py-3">
+							<div class="media align-items-center">
+								<a :href="profile.url" class="text-dark text-decoration-none">
+									<img
+                                        :src="profile?.avatar"
+                                        width="56"
+                                        height="56"
+                                        class="rounded-circle border mr-2"
+                                        onerror="this.onerror=null;this.src='/storage/avatars/default.png?v=2'"
+                                    />
+								</a>
+								<div class="media-body">
+									<p class="lead font-weight-bold mb-0">
+										{{profile.username}}
+										<span class="member-label rounded ml-1">Me</span>
+									</p>
+									<p class="text-muted mb-0">Founded group {{formatDate(group.created_at)}}</p>
+								</div>
+								<!-- <button class="btn btn-light border">
+									<i class="fas fa-ellipsis-h text-muted"></i>
+								</button> -->
+							</div>
+						</div>
+
+						<hr v-if="mutual.length > 0">
+
+						<div v-if="mutual.length > 0" class="group-members-component-paginated-list">
+							<p class="font-weight-bold mb-1">Mutual Friends</p>
+
+							<div v-for="(member, index) in mutual" class="media align-items-center py-3">
+								<a :href="member.url" class="text-dark text-decoration-none">
+                                    <img
+                                        :src="member?.avatar"
+                                        width="56"
+                                        height="56"
+                                        class="rounded-circle border mr-2"
+                                        onerror="this.onerror=null;this.src='/storage/avatars/default.png?v=2'"
+                                    />
+								</a>
+
+								<div class="media-body">
+									<p class="lead font-weight-bold mb-0">
+										<a :href="member.url" class="text-dark text-decoration-none" :title="member.acct" data-toggle="tooltip">{{member.username}}</a>
+										<span v-if="member.role == 'founder'" class="member-label rounded ml-1">Admin</span>
+										<span v-if="!member.local" class="remote-label rounded ml-1">Remote</span>
+									</p>
+									<p class="text-muted mb-0">Member since {{formatDate(member.joined)}}</p>
+								</div>
+								<a
+									class="btn btn-light lead font-weight-bolder px-3 border"
+									:href="'/account/direct/t/' + member.id">
+									<i class="far fa-comment-dots mr-1"></i> Message
+								</a>
+								<b-dropdown
+									v-if="isAdmin"
+									toggle-class="btn btn-light font-weight-bold px-3 border ml-2"
+									toggle-tag="a"
+									:lazy="true"
+									right
+									no-caret>
+									<template #button-content>
+										<i class="fas fa-ellipsis-h"></i>
+									</template>
+									<b-dropdown-item :href="member.url">View Profile</b-dropdown-item>
+									<b-dropdown-item :href="'/account/direct/t/'+member.id">Send Message</b-dropdown-item>
+									<b-dropdown-item>View Activity</b-dropdown-item>
+									<b-dropdown-divider></b-dropdown-divider>
+									<b-dropdown-item link-class="font-weight-bold" @click.prevent="openInteractionLimitModal(member)">Limit interactions</b-dropdown-item>
+									<b-dropdown-item link-class="font-weight-bold text-danger">Remove from group</b-dropdown-item>
+								</b-dropdown>
+							</div>
+						</div>
+
+						<hr v-if="members.length > 0">
+
+						<div v-if="members.length > 0" class="group-members-component-paginated-list">
+							<p class="font-weight-bold mb-1">Other Members</p>
+
+							<div v-for="(member, index) in members" class="media align-items-center py-3">
+								<a :href="member.url" class="text-dark text-decoration-none">
+                                    <img
+                                        :src="member?.avatar"
+                                        width="56"
+                                        height="56"
+                                        class="rounded-circle border mr-2"
+                                        onerror="this.onerror=null;this.src='/storage/avatars/default.png?v=2'"
+                                    />
+								</a>
+
+								<div class="media-body">
+									<p class="lead font-weight-bold mb-0">
+										<a :href="member.url" class="text-dark text-decoration-none" :title="member.acct" data-toggle="tooltip">{{member.username}}</a>
+										<span v-if="member.is_admin" class="member-label rounded ml-1">Admin</span>
+										<span v-if="!member.local" class="remote-label rounded ml-1">Remote</span>
+									</p>
+									<p class="text-muted mb-0">Member since {{formatDate(member.joined)}}</p>
+								</div>
+								<button
+									:class="[ member.following ? 'btn-primary' : 'btn-light']"
+									class="btn lead font-weight-bolder px-4 border"
+									@click="follow(index)">
+									<span v-if="member.following">
+										Following
+									</span>
+									<span v-else>
+										<i class="fas fa-user-plus mr-2"></i> Follow
+									</span>
+								</button>
+								<b-dropdown
+									v-if="isAdmin"
+									toggle-class="btn btn-light font-weight-bold px-3 border ml-2"
+									toggle-tag="a"
+									:lazy="true"
+									right
+									no-caret>
+									<template #button-content>
+										<i class="fas fa-ellipsis-h"></i>
+									</template>
+									<b-dropdown-item :href="member.url" link-class="font-weight-bold">View Profile</b-dropdown-item>
+									<b-dropdown-item :href="'/account/direct/t/'+member.id" link-class="font-weight-bold">Send Message</b-dropdown-item>
+									<b-dropdown-item link-class="font-weight-bold">View Activity</b-dropdown-item>
+									<b-dropdown-divider></b-dropdown-divider>
+									<b-dropdown-item link-class="font-weight-bold" @click.prevent="openInteractionLimitModal(member)">Limit interactions</b-dropdown-item>
+									<b-dropdown-item link-class="font-weight-bold text-danger">Remove from group</b-dropdown-item>
+								</b-dropdown>
+							</div>
+						</div>
+
+						<p v-if="members.length > 0 && hasNextPage" class="mt-4">
+							<button class="btn btn-light btn-block border font-weight-bold" @click="loadNextPage">Load more</button>
+						</p>
+					</div>
+
+					<div v-if="tab == 'search'" class="d-flex justify-content-center align-items-center" style="min-height: 100px;">
+						<div class="text-center text-muted">
+							<div class="spinner-border" role="status">
+								<span class="sr-only">Loading...</span>
+							</div>
+							<p class="lead mb-0 mt-2">Loading results ...</p>
+						</div>
+					</div>
+
+					<div v-if="tab == 'results'">
+						<div v-if="results.length > 0" class="group-members-component-paginated-list">
+							<p class="font-weight-bold mb-1">Results</p>
+
+							<div v-for="(member, index) in results" class="media align-items-center py-3">
+								<a :href="member.url" class="text-dark text-decoration-none">
+                                    <img
+                                        :src="member?.avatar"
+                                        width="56"
+                                        height="56"
+                                        class="rounded-circle border mr-2"
+                                        onerror="this.onerror=null;this.src='/storage/avatars/default.png?v=2'"
+                                    />
+								</a>
+
+								<div class="media-body">
+									<p class="lead font-weight-bold mb-0">
+										<a :href="member.url" class="text-dark text-decoration-none" :title="member.acct" data-toggle="tooltip">{{member.username}}</a>
+										<span v-if="member.role == 'founder'" class="member-label rounded ml-1">Admin</span>
+										<span v-if="!member.local" class="remote-label rounded ml-1">Remote</span>
+									</p>
+									<p class="text-muted mb-0">Member since {{formatDate(member.joined)}}</p>
+								</div>
+								<a
+									class="btn btn-light lead font-weight-bolder px-3 border"
+									:href="'/account/direct/t/' + member.id">
+									<i class="far fa-comment-dots mr-1"></i> Message
+								</a>
+								<b-dropdown
+									v-if="isAdmin"
+									toggle-class="btn btn-light font-weight-bold px-3 border ml-2"
+									toggle-tag="a"
+									:lazy="true"
+									right
+									no-caret>
+									<template #button-content>
+										<i class="fas fa-ellipsis-h"></i>
+									</template>
+									<b-dropdown-item :href="member.url">View Profile</b-dropdown-item>
+									<b-dropdown-item :href="'/account/direct/t/'+member.id">Send Message</b-dropdown-item>
+									<b-dropdown-item>View Activity</b-dropdown-item>
+									<b-dropdown-divider></b-dropdown-divider>
+									<b-dropdown-item link-class="font-weight-bold" @click.prevent="openInteractionLimitModal(member)">Limit interactions</b-dropdown-item>
+									<b-dropdown-item link-class="font-weight-bold text-danger">Remove from group</b-dropdown-item>
+								</b-dropdown>
+							</div>
+							<p class="text-center mt-5">
+								<a href="#" class="font-weight-bold" @click="backFromSearch">Go back</a>
+							</p>
+						</div>
+						<div v-else class="text-center text-muted mt-3">
+							<p class="lead">No results found.</p>
+							<p>
+								<a href="#" class="font-weight-bold" @click="backFromSearch">Go back</a>
+							</p>
+						</div>
+					</div>
+
+					<div v-if="tab == 'memberInteractionLimits'">
+						<div v-if="results.length > 0" class="group-members-component-paginated-list">
+							<p class="font-weight-bold mb-1">Interaction Limits</p>
+
+							<div v-for="(member, index) in results" class="media align-items-center py-3">
+								<a :href="member.url" class="text-dark text-decoration-none">
+                                    <img
+                                        :src="member?.avatar"
+                                        width="56"
+                                        height="56"
+                                        class="rounded-circle border mr-2"
+                                        onerror="this.onerror=null;this.src='/storage/avatars/default.png?v=2'"
+                                    />
+								</a>
+
+								<div class="media-body">
+									<p class="lead font-weight-bold mb-0">
+										<a :href="member.url" class="text-dark text-decoration-none">{{member.username}}</a>
+										<span v-if="member.role == 'founder'" class="member-label rounded ml-1">Admin</span>
+									</p>
+									<p class="text-muted mb-0">Member since {{formatDate(member.joined)}}</p>
+								</div>
+								<a
+									class="btn btn-light lead font-weight-bolder px-3 border"
+									:href="'/account/direct/t/' + member.id">
+									<i class="far fa-comment-dots mr-1"></i> Message
+								</a>
+								<b-dropdown
+									v-if="isAdmin"
+									toggle-class="btn btn-light font-weight-bold px-3 border ml-2"
+									toggle-tag="a"
+									:lazy="true"
+									right
+									no-caret>
+									<template #button-content>
+										<i class="fas fa-ellipsis-h"></i>
+									</template>
+									<b-dropdown-item :href="member.url">View Profile</b-dropdown-item>
+									<b-dropdown-item :href="'/account/direct/t/'+member.id">Send Message</b-dropdown-item>
+									<b-dropdown-item>View Activity</b-dropdown-item>
+									<b-dropdown-divider></b-dropdown-divider>
+									<b-dropdown-item link-class="font-weight-bold" @click.prevent="openInteractionLimitModal(member)">Limit interactions</b-dropdown-item>
+									<b-dropdown-item link-class="font-weight-bold text-danger">Remove from group</b-dropdown-item>
+								</b-dropdown>
+							</div>
+							<p class="text-center mt-5">
+								<a href="#" class="font-weight-bold" @click.prevent="backFromSearch">Go back</a>
+							</p>
+						</div>
+						<div v-else class="text-center text-muted mt-3">
+							<p class="lead">No results found.</p>
+							<p>
+								<a href="#" class="font-weight-bold" @click.prevent="backFromSearch">Go back</a>
+							</p>
+						</div>
+					</div>
+
+					<div v-if="tab == 'review'">
+						<div v-if="reviewsLoaded">
+
+							<div class="group-members-component-paginated-list">
+								<div class="d-flex justify-content-between align-items-center">
+									<div class="d-flex">
+										<button class="btn btn-link btn-sm mr-2" @click="backFromReview">
+											<i class="far fa-chevron-left fa-lg"></i>
+										</button>
+
+										<p class="font-weight-bold mb-0">Review Membership Applicants</p>
+									</div>
+								</div>
+								<hr>
+
+								<div v-for="(member, index) in applicants" class="media align-items-center py-3">
+									<a :href="member.url" class="text-dark text-decoration-none">
+                                        <img
+                                            :src="member?.avatar"
+                                            width="56"
+                                            height="56"
+                                            class="rounded-circle border mr-2"
+                                            onerror="this.onerror=null;this.src='/storage/avatars/default.png?v=2'"
+                                        />
+									</a>
+
+									<div class="media-body">
+										<p class="lead font-weight-bold mb-0">
+											<a :href="member.url" class="text-dark text-decoration-none" :title="member.acct" data-toggle="tooltip">{{member.username}}</a>
+											<span v-if="!member.local" class="remote-label rounded ml-1">Remote</span>
+										</p>
+										<p class="text-muted mb-0 small">
+											<span>
+												{{ member.followers_count }} Followers
+											</span>
+
+											<span class="mx-1">·</span>
+
+											<span>
+												Joined {{formatDate(member.created_at)}}
+											</span>
+										</p>
+									</div>
+
+									<button
+										type="button"
+										class="btn btn-light lead font-weight-bolder px-3 border"
+										@click="handleApplicant(index, 'ignore')">
+										<i class="far fa-times mr-1"></i> Ignore
+									</button>
+
+									<button
+										type="button"
+										class="btn btn-danger lead font-weight-bolder px-3 border"
+										@click="handleApplicant(index, 'reject')">
+										<i class="far fa-times mr-1"></i> Reject
+									</button>
+
+									<button
+										type="button"
+										class="btn btn-primary lead font-weight-bolder px-3 border"
+										@click="handleApplicant(index, 'approve')">
+										<i class="far fa-check mr-1"></i> Approve
+									</button>
+								</div>
+
+								<button
+									v-if="applicantsCanLoadMore"
+									class="btn btn-light font-weight-bold btn-block"
+									@click="loadMoreApplicants"
+									:disabled="loadingMoreApplicants">
+									Load More
+								</button>
+
+								<div v-if="!applicants || !applicants.length">
+									<p class="text-center lead mb-0">No content found</p>
+									<p class="text-center mb-0">
+										<a class="font-weight-bold" href="#" @click.prevent="backFromReview">Go back</a>
+									</p>
+								</div>
+							</div>
+
+						</div>
+
+						<div v-else class="d-flex align-items-center justify-content-center" style="min-height: 100px;">
+							<b-spinner variant="muted"  />
+						</div>
+					</div>
+
+					<div v-if="tab == 'loading'" class="d-flex align-items-center justify-content-center" style="min-height: 100px;">
+						<b-spinner variant="muted"  />
+					</div>
+				</div>
+			</div>
+		</div>
+
+		<group-interaction-limits-modal
+			ref="interactionModal"
+			:group="group"
+			:profile="activeProfile"
+			/>
+
+	</div>
+</template>
+
+<script type="text/javascript">
+	import InteractionModal from './MemberLimitInteractionsModal.vue';
+
+	export default {
+		props: {
+			group: {
+				type: Object
+			},
+
+			profile: {
+				type: Object
+			},
+
+			requestCount: {
+				type: Number
+			},
+
+			isAdmin: {
+				type: Boolean
+			}
+		},
+
+		components: {
+			'group-interaction-limits-modal': InteractionModal
+		},
+
+		data() {
+			return {
+				members: [],
+				mutual: [],
+				results: [],
+				page: 1,
+				hasNextPage: true,
+				tab: 'list',
+				memberSearchModel: null,
+				activeProfile: undefined,
+				hideHeader: false,
+				reviewsLoaded: false,
+				applicants: [],
+				applicantsPage: 1,
+				applicantsCanLoadMore: false,
+				loadingMoreApplicants: false
+			}
+		},
+
+		mounted() {
+			this.fetchMembers();
+			let u = new URLSearchParams(window.location.search);
+
+			if(this.group.self.role == 'founder') {
+				this.isAdmin = true;
+
+				if(u.has('a')) {
+					if(u.get('a') == 'il') {
+						this.tab = 'loading';
+						let pid = u.get('pid');
+						axios.get('/api/v0/groups/members/get', {
+							params: {
+								gid: this.group.id,
+								pid: pid
+							}
+						})
+						.then(res => {
+							this.results.push(res.data);
+							this.tab = 'memberInteractionLimits';
+							this.openInteractionLimitModal(res.data);
+						});
+					}
+
+					if(u.get('a') == 'review') {
+						this.reviewApplicants();
+					}
+				}
+			} else if (u.has('a')) {
+				history.pushState(null, null, `/groups/${this.group.id}/members`);
+			}
+
+		},
+
+		watch: {
+			memberSearchModel: function(val) {
+				this.handleSearch();
+			}
+		},
+
+		methods: {
+			fetchMembers() {
+				axios.get('/api/v0/groups/members/list', {
+					params: {
+						gid: this.group.id,
+						page: this.page
+					}
+				}).then(res => {
+					let data = res.data.filter(m => {
+						return m.id != this.profile.id;
+					});
+					this.members = data.filter(m => {
+						return !m.following
+					});
+					this.mutual = data.filter(m => {
+						return m.following
+					});
+					this.page++;
+					this.$nextTick(() => {
+						$('[data-toggle="tooltip"]').tooltip()
+					});
+				}).catch(err => {
+					console.log(res.response);
+				})
+			},
+
+			loadNextPage() {
+				axios.get('/api/v0/groups/members/list', {
+					params: {
+						gid: this.group.id,
+						page: this.page
+					}
+				}).then(res => {
+					if(res.data.length == 0) {
+						this.hasNextPage = false;
+						return;
+					}
+
+					let data = res.data.filter(m => {
+						return m.id != this.profile.id;
+					});
+					this.members.push(...data.filter(m => {
+						return !m.following
+					}));
+					this.mutual.push(...data.filter(m => {
+						return m.following
+					}));
+					this.page++;
+					this.$nextTick(() => {
+						$('[data-toggle="tooltip"]').tooltip()
+					});
+				}).catch(err => {
+					console.log(err.response);
+				})
+			},
+
+			follow(index) {
+				axios.post('/i/follow', {
+					item: this.members[index].id
+				}).then(res => {
+					this.members[index].following = !this.members[index].following;
+				}).catch(err => {
+					console.log(err.response);
+				})
+			},
+
+			formatDate(ts) {
+				return new Date(ts).toDateString();
+			},
+
+			handleSearch() {
+				if(!this.memberSearchModel || this.memberSearchModel == "" || this.memberSearchModel.length == 0) {
+					this.tab == 'list';
+					this.memberSearchModel = null;
+					return;
+				}
+
+				this.tab = 'search';
+				this.results = this.members.concat(this.mutual).filter(profile => {
+					return profile.username.includes(this.memberSearchModel);
+				});
+
+				// if(this.results.length) {
+					this.tab = 'results';
+				// }
+			},
+
+			backFromSearch() {
+				event.currentTarget.blur();
+				this.memberSearchModel = null;
+				this.tab = 'list';
+				history.pushState(null, null, `/groups/${this.group.id}/members`);
+			},
+
+			openInteractionLimitModal(member) {
+				this.activeProfile = member;
+				setTimeout(() => {
+					this.$refs.interactionModal.open();
+				}, 500);
+			},
+
+			reviewApplicants() {
+				this.hideHeader = true;
+				this.tab = 'review';
+				history.pushState(null, null, `/groups/${this.group.id}/members?a=review`);
+
+				axios.get('/api/v0/groups/members/requests', {
+					params: {
+						gid: this.group.id
+					}
+				})
+				.then(res => {
+					this.applicants = res.data;
+					this.reviewsLoaded = true;
+					this.applicantsPage = 2;
+					this.applicantsCanLoadMore = res.data.length == 10;
+				})
+			},
+
+			handleApplicant(index, action) {
+				event.currentTarget.blur();
+
+				if(action == 'ignore') {
+					this.applicants.splice(index, 1);
+					return;
+				}
+
+				this.tab = 'loading';
+
+				if(!window.confirm('Are you sure you want to perform this action?')) {
+					return;
+				}
+
+				axios.post('/api/v0/groups/members/request', {
+					gid: this.group.id,
+					pid: this.applicants[index].id,
+					action: action
+				})
+				.then(res => {
+					this.applicants.splice(index, 1);
+					this.tab = 'review';
+					this.$emit('decrementrc');
+					if(action == 'approve') {
+						this.$emit('incrementMemberCount');
+					}
+				})
+			},
+
+			backFromReview() {
+				event.currentTarget.blur();
+				this.memberSearchModel = null;
+				this.tab = 'list';
+				this.hideHeader = false;
+				history.pushState(null, null, `/groups/${this.group.id}/members`);
+			},
+
+			loadMoreApplicants() {
+				this.loadingMoreApplicants = true;
+
+				axios.get('/api/v0/groups/members/requests', {
+					params: {
+						gid: this.group.id,
+						page: this.applicantsPage
+					}
+				})
+				.then(res => {
+					this.applicants.push(...res.data);
+					this.applicantsCanLoadMore = res.data.length == 10;
+					this.applicantsPage++;
+					this.loadingMoreApplicants = false;
+				})
+			}
+		}
+	}
+</script>
+
+<style lang="scss">
+	.group-members-component {
+		min-height: 100vh;
+
+		.member-label {
+			padding: 2px 5px;
+			font-size: 12px;
+			color: rgba(75, 119, 190, 1);
+			background: rgba(137, 196, 244, 0.2);
+			border: 1px solid rgba(137, 196, 244, 0.3);
+			font-weight: 400;
+			text-transform: capitalize;
+		}
+
+		.remote-label {
+			padding: 2px 5px;
+			font-size: 12px;
+			color: #B45309;
+			background: #FEF3C7;
+			border: 1px solid #FCD34D;
+			font-weight: 400;
+			text-transform: capitalize;
+		}
+	}
+</style>

+ 231 - 0
resources/assets/components/groups/partials/GroupModeration.vue

@@ -0,0 +1,231 @@
+<template>
+	<div class="group-moderation-component">
+		<div v-if="initalLoad">
+			<div v-if="tab === 'home'">
+				<div class="row justify-content-center">
+					<div class="col-12 col-md-6 pt-4">
+						<div class="d-flex justify-content-between align-items-center mb-3">
+							<p class="lead mb-0">Latest Mod Reports</p>
+							<button class="btn btn-light border font-weight-bold btn-sm rounded shadow-sm">
+								<i class="far fa-redo"></i>
+							</button>
+						</div>
+
+						<div v-if="reports.length" class="list-group">
+							<div v-for="(report, index) in reports" class="list-group-item">
+								<div class="media align-items-center">
+									<img :src="report.profile.avatar" width="40" height="40" class="rounded-circle mr-3">
+									<div class="media-body">
+										<p class="mb-0">
+											<a v-if="report.total_count == 1" class="font-weight-bold" :href="report.profile.url">{{ report.profile.username }}</a>
+											<span v-else>
+												<a class="font-weight-bold" :href="report.profile.url">{{ report.profile.username }}</a> and <a class="font-weight-bold" href="#">{{ report.total_count - 1}} others</a>
+											</span>
+											reported
+											<a href="#" class="font-weight-bold" :id="`report_post:${index}`">this post</a>
+											as {{ report.type }}
+										</p>
+										<p class="mb-0 small">
+											<span class="text-muted font-weight-bold">
+												{{ timeago(report.created_at) }}
+											</span>
+											<!-- <span>·</span>
+											<a class="text-muted font-weight-bold" href="#">
+												View Full Report
+											</a> -->
+											<span>·</span>
+											<a class="text-danger font-weight-bold" href="#" @click.prevent="actionMenu(index)">
+												Actions
+											</a>
+										</p>
+									</div>
+
+									<!-- <div class="text-muted">
+										<button class="btn btn-light btn-sm shadow-sm" @click.prevent="actionMenu(index)">
+											<i class="far fa-cog fa-lg text-lighter"></i>
+										</button>
+									</div> -->
+
+									<b-popover :target="`report_post:${index}`" triggers="hover" placement="bottom" custom-class="popover-wide">
+										<template #title>
+											<div class="d-flex justify-content-between">
+												<span>
+													&commat;{{ report.status.account.username }}
+												</span>
+												<span>
+													{{ timeago(report.status.created_at) }}
+												</span>
+											</div>
+										</template>
+
+										<div v-if="report.status.pf_type == 'group:post'">
+											<div v-if="report.status.media_attachments.length">
+												<img :src="report.status.media_attachments[0].url" width="100%" height="300" style="object-fit:cover;">
+											</div>
+											<div v-else>
+												<p v-html="report.status.content"></p>
+											</div>
+										</div>
+										<div v-else-if="report.status.pf_type == 'reply-text'">
+											<p v-html="report.status.content"></p>
+										</div>
+										<div v-else>
+											<p>Cannot generate preview.</p>
+										</div>
+
+										<p class="mb-1 mt-3">
+											<a class="btn btn-primary btn-block font-weight-bold" :href="report.status.url">View Post</a>
+										</p>
+									</b-popover>
+								</div>
+							</div>
+
+							<div v-if="canLoadMore" class="list-group-item">
+								<button class="btn btn-light font-weight-bold btn-block" @click.prevent="loadMoreReports()">Load more</button>
+							</div>
+						</div>
+						<div v-else class="card card-body shadow-none border rounded-pill">
+							<p class="lead font-weight-bold text-center mb-0">No moderation reports found!</p>
+						</div>
+					</div>
+				</div>
+			</div>
+		</div>
+		<div v-else class="col-12 col-md-6 pt-4 d-flex align-items-center justify-content-center">
+			<div class="spinner-border" role="status">
+				<span class="sr-only">Loading...</span>
+			</div>
+		</div>
+	</div>
+</template>
+
+<script type="text/javascript">
+	export default {
+		props: {
+			group: {
+				type: Object
+			}
+		},
+
+		data() {
+			return {
+				initalLoad: false,
+				reports: [],
+				page: 1,
+				canLoadMore: false,
+				tab: 'home'
+			}
+		},
+
+		mounted() {
+			this.getReports();
+		},
+
+		methods: {
+			getReports() {
+				axios.get(`/api/v0/groups/${this.group.id}/reports/list`)
+				.then(res => {
+					this.reports = res.data;
+					this.initalLoad = true;
+					this.page++;
+					this.canLoadMore = res.data.length == 10;
+				})
+			},
+
+			loadMoreReports() {
+				axios.get(`/api/v0/groups/${this.group.id}/reports/list`, {
+					params: {
+						page: this.page
+					}
+				})
+				.then(res => {
+					this.reports.push(...res.data);
+					this.page++;
+					this.canLoadMore = res.data.length == 10;
+				})
+			},
+
+			timeago(ts) {
+				return App.util.format.timeAgo(ts);
+			},
+
+			actionMenu(index) {
+				event.currentTarget.blur();
+
+				swal({
+					title: "Moderator Action",
+					dangerMode: true,
+					text: "Please select an action to take, press ESC to close",
+					buttons: {
+						ignore: {
+							text: "Ignore",
+							className: "btn-warning",
+							value: "ignore"
+						},
+						cw: {
+							text: "NSFW",
+							className: "btn-warning",
+							value: "cw",
+						},
+						delete: {
+							text: "Delete",
+							className: "btn-danger",
+							value: "delete",
+						},
+					},
+				})
+				.then((value) => {
+					switch (value) {
+
+						case "ignore":
+							axios.post(`/api/v0/groups/${this.group.id}/report/action`, {
+								action: 'ignore',
+								id: this.reports[index].id
+							}).then(res => {
+								let report = this.reports[index];
+								this.$emit('decrement', report.total_count);
+								this.reports.splice(index, 1);
+								this.$bvToast.toast(`Ignored and closed moderation report`, {
+									title: 'Moderation Action',
+									autoHideDelay: 5000,
+									appendToast: true
+								});
+							})
+						break;
+
+						case "cw":
+							axios.post(`/api/v0/groups/${this.group.id}/report/action`, {
+								action: 'cw',
+								id: this.reports[index].id
+							}).then(res => {
+								let report = this.reports[index];
+								this.$emit('decrement', report.total_count);
+								this.reports.splice(index, 1);
+								this.$bvToast.toast(`Succesfully applied content warning and closed moderation report`, {
+									title: 'Moderation Action',
+									autoHideDelay: 5000,
+									appendToast: true
+								});
+							})
+						break;
+
+						case "delete":
+							swal('Oops, this is embarassing!', 'We have not implemented this moderation action yet.', 'error');
+						break;
+					}
+				});
+			}
+		}
+	}
+</script>
+
+<style lang="scss">
+	.group-moderation-component {
+		min-height: 80vh;
+		margin-bottom: 100px;
+	}
+
+	.popover-wide {
+		min-width: 200px !important;
+	}
+</style>

+ 0 - 0
resources/assets/components/groups/partials/GroupPolls.vue


+ 152 - 0
resources/assets/components/groups/partials/GroupPostModal.vue

@@ -0,0 +1,152 @@
+<template>
+	<div class="group-post-modal">
+		<b-modal
+			ref="modal"
+			size="xl"
+			hide-footer
+			hide-header
+			centered
+			body-class="gpm p-0">
+			<div class="d-flex">
+				<div class="gpm-media">
+					<img :src="status.media_attachments[0].preview_url">
+				</div>
+				<div class="p-3" style="width: 30%;">
+					<div class="media align-items-center mb-2">
+						<a :href="status.account.url">
+							<img class="rounded-circle media-avatar border mr-2" :src="status.account.avatar" width="32" height="32">
+						</a>
+						<div class="media-body">
+							<div class="media-body-comment">
+								<p class="media-body-comment-username mb-n1">
+									<a :href="status.account.url" class="text-dark text-decoration-none font-weight-bold">
+										{{status.account.acct}}
+									</a>
+								</p>
+								<p class="media-body-comment-timestamp mb-0">
+									<a class="font-weight-light text-muted small" :href="status.url">
+										{{shortTimestamp(status.created_at)}}
+									</a>
+									<span class="text-lighter" style="padding-left:2px;padding-right:2px;">·</span>
+									<span class="text-muted small"><i class="fas fa-globe"></i></span>
+								</p>
+							</div>
+						</div>
+						<div class="text-right" style="flex-grow:1;">
+							<button class="btn btn-link text-dark py-0" type="button">
+								<span class="fas fa-ellipsis-h text-lighter"></span>
+								<span class="sr-only">Post Menu</span>
+							</button>
+						</div>
+					</div>
+
+					<read-more :status="status" />
+
+					<div class="border-top border-bottom mt-3">
+						<div class="d-flex justify-content-between" style="padding: 8px 5px">
+							<button class="btn btn-link py-0 text-decoration-none btn-sm" :class="{ 'font-weight-bold': status.favourited, 'text-primary': status.favourited, 'text-muted': !status.favourited }">
+								<i class="far fa-heart mr-1"></i>
+								{{ status.favourited ? 'Liked' : 'Like' }}
+							</button>
+
+							<button class="btn btn-link py-0 text-decoration-none btn-sm text-muted">
+								<i class="far fa-comment cursor-pointer text-muted mr-1"></i>
+								Comment
+							</button>
+
+							<button class="btn btn-link py-0 text-decoration-none btn-sm" disabled>
+								<i class="fas fa-external-link-alt cursor-pointer text-muted mr-1"></i>
+								Share
+							</button>
+						</div>
+					</div>
+
+					<!-- <comment-drawer
+						:profile="profile"
+						:status="status"
+						:group-id="groupId" /> -->
+				</div>
+			</div>
+		</b-modal>
+	</div>
+</template>
+
+<script type="text/javascript">
+	import ReadMore from './ReadMore.vue';
+	import CommentDrawer from './CommentDrawer.vue';
+
+	export default {
+		props: {
+			groupId: {
+				type: String
+			},
+
+			status: {
+				type: Object
+			},
+
+			profile: {
+				type: Object
+			}
+		},
+
+		components: {
+			"read-more": ReadMore,
+			"comment-drawer": CommentDrawer
+		},
+
+		data() {
+			return {
+				loaded: false
+			}
+		},
+
+		mounted() {
+			this.init();
+		},
+
+		methods: {
+			init() {
+				this.loaded = true;
+				this.$refs.modal.show();
+			},
+
+			shortTimestamp(ts) {
+				return window.App.util.format.timeAgo(ts);
+			},
+		}
+	}
+</script>
+
+<style lang="scss">
+	.gpm {
+		&-media {
+			display: flex;
+			width: 70%;
+
+			img {
+				width: 100%;
+				height: auto;
+				max-height: 70vh;
+				object-fit: contain;
+				background-color: #000;
+			}
+
+		}
+
+		.comment-drawer-component {
+			.my-3 {
+				max-height: 46vh;
+				overflow: auto;
+			}
+		}
+
+		.cdrawer-reply-form {
+			position: absolute;
+			bottom: 0;
+			margin-bottom: 1rem !important;
+			min-width: 310px;
+		}
+	}
+
+</style>

+ 199 - 0
resources/assets/components/groups/partials/GroupSearchModal.vue

@@ -0,0 +1,199 @@
+<template>
+	<div class="group-search-modal">
+		<b-modal
+			ref="modal"
+			hide-header
+			hide-footer
+			centered
+			rounded
+			body-class="rounded group-search-modal-wrapper">
+			<div class="d-flex justify-content-between">
+				<autocomplete
+					:search="autocompleteSearch"
+					placeholder="Search this group"
+					aria-label="Search this group"
+					:get-result-value="getSearchResultValue"
+					:debounceTime="700"
+					@submit="onSearchSubmit"
+					style="width: 100%;"
+					ref="autocomplete"
+					>
+						<template #result="{ result, props }">
+						<li
+						v-bind="props"
+						class="autocomplete-result"
+						>
+							<div class="text-truncate">
+								<p class="result-name mb-0 font-weight-bold">
+									{{ result.username }}
+								</p>
+							</div>
+						</li>
+					</template>
+				</autocomplete>
+
+				<button class="btn btn-light border rounded-circle text-lighter ml-3" style="width: 52px;height:50px;" @click="close">
+					<i class="fal fa-times fa-lg"></i>
+				</button>
+			</div>
+
+			<div v-if="recent && recent.length" class="pt-5">
+				<h5 class="mb-2">Recent Searches</h5>
+				<a v-for="(result, index) in recent" class="media align-items-center text-decoration-none text-dark" :href="result.action">
+					<div class="bg-light rounded-circle mr-3 border d-flex justify-content-center align-items-center" style="width: 40px;height:40px">
+						<i class="far fa-search"></i>
+					</div>
+					<div class="media-body">
+						<p class="mb-0">{{ result.value }}</p>
+					</div>
+				</a>
+			</div>
+
+			<div class="pt-5">
+				<h5 class="mb-2">Explore This Group</h5>
+				<div class="media align-items-center" @click="viewMyActivity">
+					<img
+                        :src="profile?.avatar"
+                        width="40"
+                        height="40"
+                        class="mr-3 border rounded-circle"
+                        onerror="this.onerror=null;this.src='/storage/avatars/default.png?v=2'"
+                    />
+					<div class="media-body">
+						<p class="mb-0">{{ profile?.display_name || profile?.username }}</p>
+						<p class="mb-0 small text-muted">See your group activity.</p>
+					</div>
+				</div>
+				<div class="media align-items-center" @click="viewGroupSearch">
+					<div class="bg-light rounded-circle mr-3 border d-flex justify-content-center align-items-center" style="width: 40px;height:40px">
+						<i class="far fa-search"></i>
+					</div>
+					<div class="media-body">
+						<p class="mb-0">Search all groups</p>
+					</div>
+				</div>
+			</div>
+
+			<!-- <div class="text-center py-3 small">
+				<p class="lead font-weight-normal mb-0">Looking for something?</p>
+				<p class="mb-0">Search {{ group.name }} for posts, comments or members.</p>
+			</div> -->
+		</b-modal>
+	</div>
+</template>
+
+<script type="text/javascript">
+	import Autocomplete from '@trevoreyre/autocomplete-vue'
+	import '@trevoreyre/autocomplete-vue/dist/style.css'
+
+	export default {
+		props: {
+			group: {
+				type: Object
+			},
+
+			profile: {
+				type: Object
+			}
+		},
+
+		components: {
+			'autocomplete': Autocomplete,
+		},
+
+		data() {
+			return {
+				query: '',
+				recent: [],
+				loaded: false
+			}
+		},
+
+		methods: {
+			open() {
+				this.fetchRecent();
+				this.$refs.modal.show();
+			},
+
+			close() {
+				this.$refs.modal.hide();
+			},
+
+			fetchRecent() {
+				axios.get('/api/v0/groups/search/getrec', {
+					params: {
+						g: this.group.id
+					}
+				}).then(res => {
+					this.recent = res.data;
+				})
+			},
+
+			autocompleteSearch(q) {
+				if (!q || q.length < 2) {
+					return [];
+				}
+
+				return axios.post(`/api/v0/groups/search/lac`, {
+					q: q,
+					g: this.group.id,
+					v: '0.2'
+				}).then(res => {
+					return res.data;
+				});
+				return [];
+			},
+
+			getSearchResultValue(result) {
+				return result.username;
+			},
+
+			onSearchSubmit(result) {
+				if (result.length < 1) {
+					return [];
+				}
+
+				axios.post('/api/v0/groups/search/addrec', {
+					g: this.group.id,
+					q: {
+						value: result.username,
+						action: result.url
+					}
+				}).then(res => {
+					location.href = result.url;
+				});
+			},
+
+			viewMyActivity() {
+				location.href = `/groups/${this.group.id}/user/${this.profile.id}?rf=group_search`;
+			},
+
+			viewGroupSearch() {
+				location.href = `/groups/home?ct=gsearch&rf=group_search&rfid=${this.group.id}`;
+			},
+
+			addToRecentSearches() {
+
+			}
+		}
+	}
+</script>
+
+<style lang="scss">
+	.group-search-modal {
+
+		&-wrapper {
+			.media {
+				height: 60px;
+				padding: 10px;
+				border-radius: 10px;
+				user-select: none;
+				cursor: pointer;
+
+				&:hover {
+					background-color: #E5E7EB;
+				}
+			}
+		}
+	}
+</style>

+ 870 - 0
resources/assets/components/groups/partials/GroupStatus.vue

@@ -0,0 +1,870 @@
+<template>
+	<div class="status-card-component" :class="{ 'status-card-sm': loaded && size === 'small' }">
+		 <div v-if="loaded" class="shadow-none mb-3">
+			<div v-if="status.pf_type !== 'poll'" :class="{ 'border-top-0': !hasTopBorder }" class="card shadow-sm" style="border-radius: 18px !important;">
+                <parent-unavailable
+                    v-if="parentUnavailable == true"
+                    :permalink-mode="permalinkMode"
+                    :permalink-status="childContext"
+                    :status="status"
+                    :profile="profile"
+                    :group-id="groupId"
+                />
+				<div v-else class="card-body pb-0">
+                    <group-post-header
+                        :group="group"
+                        :status="status"
+                        :profile="profile"
+                        :showGroupHeader="showGroupHeader"
+                        :showGroupChevron="showGroupChevron"
+                    />
+
+					<div>
+						<div>
+							<div class="pl-2">
+
+								<div v-if="status.sensitive && status.content.length" class="card card-body shadow-none border bg-light py-2 my-2 text-center user-select-none cursor-pointer" @click="status.sensitive = false">
+									<div class="media justify-content-center align-items-center">
+										<div class="mx-3">
+											<i class="far fa-exclamation-triangle fa-2x text-lighter"></i>
+										</div>
+										<div class="media-body">
+											<p class="font-weight-bold mb-0">Warning, may contain sensitive content. </p>
+											<p class="mb-0 text-lighter small text-center font-weight-bold">Click to view</p>
+										</div>
+									</div>
+								</div>
+
+                                <template v-else>
+								    <p v-html="renderedCaption" class="pt-2 text-break" style="font-size:15px;"></p>
+                                </template>
+
+								<photo-presenter
+									v-if="status.pf_type === 'photo'"
+									class="col px-0 border mb-4 rounded"
+									:status="status"
+									v-on:lightbox="showPostModal"
+									v-on:togglecw="status.sensitive = false"
+									@click="showPostModal"/>
+
+								<video-presenter
+									v-else-if="status.pf_type === 'video'"
+									class="col px-0 border mb-4 rounded"
+									:status="status"
+									v-on:togglecw="status.sensitive = false" />
+
+								<photo-album-presenter
+									v-else-if="status.pf_type === 'photo:album'"
+									class="col px-0 border mb-4 rounded"
+									:status="status" v-on:lightbox="lightbox"
+									v-on:togglecw="status.sensitive = false" />
+
+								<video-album-presenter
+									v-else-if="status.pf_type === 'video:album'"
+									class="col px-0 border mb-4 rounded"
+									:status="status"
+									v-on:togglecw="status.sensitive = false" />
+
+								<mixed-album-presenter
+									v-else-if="status.pf_type === 'photo:video:album'"
+									:status="status"
+									class="col px-0 border mb-4 rounded"
+									v-on:lightbox="lightbox"
+									v-on:togglecw="status.sensitive = false" />
+
+								<div v-if="status.favourites_count || status.reply_count" class="border-top my-0">
+									<div class="d-flex justify-content-between py-2" style="font-size: 14px;">
+										<button v-if="status.favourites_count" class="btn btn-light py-0 text-decoration-none text-dark" style="font-size: 12px; font-weight: 600;" @click="showLikesModal($event)">
+											{{ status.favourites_count }} {{ status.favourites_count == 1 ? 'Like' : 'Likes' }}
+										</button>
+
+										<button v-if="status.reply_count" class="btn btn-light py-0 text-decoration-none text-dark" style="font-size: 12px; font-weight: 600;" @click="commentFocus($event)">
+											{{ status.reply_count }} {{ status.reply_count == 1 ? 'Comment' : 'Comments' }}
+										</button>
+									</div>
+								</div>
+
+								<div v-if="profile" class="border-top my-0">
+									<div class="d-flex justify-content-between py-2 px-4">
+										<!-- <button class="btn btn-link py-0 text-decoration-none" :class="{ 'font-weight-bold': status.favourited, 'text-primary': status.favourited, 'text-muted': !status.favourited }"
+												@click="likeStatus(status, $event);">
+											<i class="far fa-heart mr-1">
+											</i>
+											{{ status.favourited ? 'Liked' : 'Like' }}
+										</button> -->
+										<div>
+											<button :id="'lr__'+status.id" class="btn btn-link py-0 text-decoration-none" :class="{ 'font-weight-bold': status.favourited, 'text-primary': status.favourited, 'text-muted': !status.favourited }" @click="likeStatus(status, $event);">
+												<i class="far fa-heart mr-1"></i>
+												{{ status.favourited ? 'Liked' : 'Like' }}
+											</button>
+										</div>
+
+										<button class="btn btn-link py-0 text-decoration-none text-muted" @click="commentFocus($event)">
+											<i class="far fa-comment cursor-pointer text-muted mr-1">
+											</i>
+											Comment
+										</button>
+
+										<button class="btn btn-link py-0 text-decoration-none" disabled>
+											<i class="fas fa-external-link-alt cursor-pointer text-muted mr-1">
+											</i>
+											Share
+										</button>
+									</div>
+								</div>
+
+								<comment-drawer
+									v-if="showCommentDrawer"
+									:permalink-mode="permalinkMode"
+									:permalink-status="childContext"
+									:status="status"
+									:profile="profile"
+									:group-id="groupId" />
+							</div>
+						</div>
+					</div>
+				</div>
+			</div>
+
+			<div v-else class="border">
+				<poll-card :status="status" :profile="profile" v-on:status-delete="statusDeleted" :showBorder="false" />
+
+				<div class="bg-white" style="padding: 0 1.25rem">
+					<div v-if="profile" class="border-top my-0">
+						<div class="d-flex justify-content-between py-2 px-4">
+							<!-- <button class="btn btn-link py-0 text-decoration-none" :class="{ 'font-weight-bold': status.favourited, 'text-primary': status.favourited, 'text-muted': !status.favourited }" @click="likeStatus(status, $event);"> -->
+							<div>
+								<button :id="'lr__'+status.id" class="btn btn-link py-0 text-decoration-none" :class="{ 'font-weight-bold': status.favourited, 'text-primary': status.favourited, 'text-muted': !status.favourited }" @click="likeStatus(status, $event);">
+									<i class="far fa-heart mr-1"></i>
+									{{ status.favourited ? 'Liked' : 'Like' }}
+								</button>
+
+								<b-popover :target="'lr__'+status.id" triggers="hover" placement="top">
+									<template #title>Popover Title</template>
+									I am popover <b>component</b> content!
+								</b-popover>
+							</div>
+
+							<button class="btn btn-link py-0 text-decoration-none text-muted" @click="commentFocus($event)">
+								<i class="far fa-comment cursor-pointer text-muted mr-1"></i>
+								Comment
+							</button>
+
+							<button class="btn btn-link py-0 text-decoration-none" disabled>
+								<i class="fas fa-external-link-alt cursor-pointer text-muted mr-1">
+								</i>
+								Share
+							</button>
+						</div>
+					</div>
+
+					<comment-drawer
+						v-if="showCommentDrawer"
+						:permalink-mode="permalinkMode"
+						:permalink-status="childContext"
+						:profile="profile"
+						:status="status"
+						:group-id="groupId" />
+				</div>
+			</div>
+
+			<!-- <div v-else class="card rounded-0 border-top-0 status-card card-md-rounded-0 shadow-none border">
+				<div v-if="status" class="card-header d-inline-flex align-items-center bg-white">
+					<div>
+						<img class="rounded-circle box-shadow" :src="status.account.avatar" width="32px" height="32px" onerror="this.onerror=null;this.src='/storage/avatars/default.png?v=2'" alt="avatar">
+					</div>
+					<div class="pl-2">
+						<a class="username font-weight-bold text-dark text-decoration-none text-break" v-bind:href="profileUrl(status)" v-html="statusCardUsernameFormat(status)">
+							Loading...
+						</a>
+						<span v-if="status.account.is_admin" class="fa-stack" title="Admin Account" data-toggle="tooltip" style="height:1em; line-height:1em; max-width:19px;">
+							<i class="fas fa-certificate text-danger fa-stack-1x"></i>
+							<i class="fas fa-crown text-white fa-sm fa-stack-1x" style="font-size:7px;"></i>
+						</span>
+						<div class="d-flex align-items-center">
+							<a v-if="status.place" class="small text-decoration-none text-muted" :href="'/discover/places/'+status.place.id+'/'+status.place.slug" title="Location" data-toggle="tooltip"><i class="fas fa-map-marked-alt"></i> {{status.place.name}}, {{status.place.country}}</a>
+						</div>
+					</div>
+					<div class="text-right" style="flex-grow:1;">
+						<button class="btn btn-link text-dark py-0" type="button" @click="ctxMenu()">
+							<span class="fas fa-ellipsis-h text-lighter"></span>
+							<span class="sr-only">Post Menu</span>
+						</button>
+					</div>
+				</div>
+
+				<div class="postPresenterContainer" style="background: #000;">
+
+					<div v-if="status.pf_type === 'photo'" class="w-100">
+						<photo-presenter
+							:status="status"
+							v-on:lightbox="lightbox"
+							v-on:togglecw="status.sensitive = false"/>
+					</div>
+
+					<div v-else-if="status.pf_type === 'video'" class="w-100">
+						<video-presenter :status="status" v-on:togglecw="status.sensitive = false"></video-presenter>
+					</div>
+
+					<div v-else-if="status.pf_type === 'photo:album'" class="w-100">
+						<photo-album-presenter :status="status" v-on:lightbox="lightbox" v-on:togglecw="status.sensitive = false"></photo-album-presenter>
+					</div>
+
+					<div v-else-if="status.pf_type === 'video:album'" class="w-100">
+						<video-album-presenter :status="status" v-on:togglecw="status.sensitive = false"></video-album-presenter>
+					</div>
+
+					<div v-else-if="status.pf_type === 'photo:video:album'" class="w-100">
+						<mixed-album-presenter :status="status" v-on:lightbox="lightbox" v-on:togglecw="status.sensitive = false"></mixed-album-presenter>
+					</div>
+
+					<div v-else class="w-100">
+						<p class="text-center p-0 font-weight-bold text-white">Error: Problem rendering preview.</p>
+					</div>
+
+				</div>
+
+				<div v-if="config.features.label.covid.enabled && status.label && status.label.covid == true" class="card-body border-top border-bottom py-2 cursor-pointer pr-2" @click="labelRedirect()">
+					<p class="font-weight-bold d-flex justify-content-between align-items-center mb-0">
+						<span>
+							<i class="fas fa-info-circle mr-2"></i>
+							For information about COVID-19, {{config.features.label.covid.org}}
+						</span>
+						<span>
+							<i class="fas fa-chevron-right text-lighter"></i>
+						</span>
+					</p>
+				</div>
+
+				<div class="card-body">
+					<div v-if="reactionBar" class="reactions my-1 pb-2">
+						<h3 v-if="status.favourited" class="fas fa-heart text-danger pr-3 m-0 cursor-pointer" title="Like" v-on:click="likeStatus(status, $event);"></h3>
+						<h3 v-else class="far fa-heart pr-3 m-0 like-btn text-dark cursor-pointer" title="Like" v-on:click="likeStatus(status, $event);"></h3>
+						<h3 v-if="!status.comments_disabled" class="far fa-comment text-dark pr-3 m-0 cursor-pointer" title="Comment" v-on:click="commentFocus(status, $event)"></h3>
+						<span v-if="status.taggedPeople.length" class="float-right">
+							<span class="font-weight-light small" style="color:#718096">
+								<i class="far fa-user" data-toggle="tooltip" title="Tagged People"></i>
+								<span v-for="(tag, index) in status.taggedPeople" class="mr-n2">
+									<a :href="'/'+tag.username">
+										<img :src="tag.avatar" width="20px" height="20px" class="border rounded-circle" data-toggle="tooltip" :title="'@'+tag.username" alt="Avatar">
+									</a>
+								</span>
+							</span>
+						</span>
+					</div>
+
+					<div v-if="status.liked_by.username && status.liked_by.username !== profile.username" class="likes mb-1">
+						<span class="like-count">Liked by
+							<a class="font-weight-bold text-dark" :href="status.liked_by.url">{{status.liked_by.username}}</a>
+							<span v-if="status.liked_by.others == true">
+								and <span class="font-weight-bold" v-if="status.liked_by.total_count_pretty">{{status.liked_by.total_count_pretty}}</span> <span class="font-weight-bold">others</span>
+							</span>
+						</span>
+					</div>
+					<div v-if="status.pf_type != 'text'" class="caption">
+						<p v-if="!status.sensitive" class="mb-2 read-more" style="overflow: hidden;">
+							<span class="username font-weight-bold">
+								<bdi><a class="text-dark" :href="profileUrl(status)">{{status.account.username}}</a></bdi>
+							</span>
+							<span class="status-content" v-html="status.content"></span>
+						</p>
+					</div>
+					<div class="timestamp mt-2">
+						<p class="small mb-0">
+							<a v-if="status.visibility != 'archived'" :href="statusUrl(status)" class="text-muted text-uppercase">
+								<timeago :datetime="status.created_at" :auto-update="60" :converter-options="{includeSeconds:true}" :title="timestampFormat(status.created_at)" v-b-tooltip.hover.bottom></timeago>
+							</a>
+							<span v-else class="text-muted text-uppercase">
+								Posted <timeago :datetime="status.created_at" :auto-update="60" :converter-options="{includeSeconds:true}" :title="timestampFormat(status.created_at)" v-b-tooltip.hover.bottom></timeago>
+							</span>
+
+							<span v-if="recommended">
+								<span class="px-1">&middot;</span>
+								<span class="text-muted">Based on popular and trending content</span>
+							</span>
+						</p>
+					</div>
+				</div>
+			</div> -->
+
+			<!-- <div v-else :class="{ 'border-top-0': !hasTopBorder }" class="card shadow-none border rounded-0">
+				<div class="card-body pb-0">
+					<div class="media">
+						<img class="rounded-circle box-shadow mr-2" :src="status.account.avatar" width="42px" height="42px" onerror="this.onerror=null;this.src='/storage/avatars/default.png?v=2'" alt="avatar">
+						<div class="media-body">
+							<div class="pl-2 d-flex align-items-top">
+								<div>
+									<p class="mb-0">
+										<a class="username font-weight-bold text-dark text-decoration-none text-break" v-bind:href="profileUrl(status)" v-html="statusCardUsernameFormat(status)">
+											Loading...
+										</a>
+									</p>
+									<p class="mb-0">
+										<a class="font-weight-light text-muted small"
+										   :href="statusUrl(status)">{{shortTimestamp(status.created_at)}}</a>
+										<span class="text-lighter" style="padding-left:2px;padding-right:2px;">·</span>
+										<span class="text-muted small"><i class="fas fa-globe"></i></span>
+									</p>
+								</div>
+								<span class="text-right" style="flex-grow:1;">
+									<button class="btn btn-link text-dark py-0" type="button" @click="ctxMenu()">
+										<span class="fas fa-ellipsis-h text-lighter"></span>
+										<span class="sr-only">Post Menu</span>
+									</button>
+								</span>
+							</div>
+						</div>
+					</div>
+					<div>
+						<div>
+							<div class="pl-2">
+
+								<details v-if="status.sensitive">
+									<summary class="mb-2 font-weight-bold text-muted">Content Warning</summary>
+									<p v-html="status.content" class="pt-2 text-break status-content"></p>
+								</details>
+
+								<p v-else v-html="status.content" class="pt-2 text-break" style="font-size:15px;"></p>
+
+								<div class="mb-1 row px-3">
+									<photo-presenter
+										v-if="status.pf_type === 'photo'"
+										class="col px-0 border mb-4 rounded"
+										:status="status"
+										v-on:lightbox="lightbox"
+										v-on:togglecw="status.sensitive = false"/>
+
+									<video-presenter
+										v-else-if="status.pf_type === 'video'"
+										class="col px-0 border mb-4 rounded"
+										:status="status"
+										v-on:togglecw="status.sensitive = false" />
+
+									<photo-album-presenter
+										v-else-if="status.pf_type === 'photo:album'"
+										class="col px-0 border mb-4 rounded"
+										:status="status" v-on:lightbox="lightbox"
+										v-on:togglecw="status.sensitive = false" />
+
+									<video-album-presenter
+										v-else-if="status.pf_type === 'video:album'"
+										class="col px-0 border mb-4 rounded"
+										:status="status"
+										v-on:togglecw="status.sensitive = false" />
+
+									<mixed-album-presenter
+										v-else-if="status.pf_type === 'photo:video:album'"
+										:status="status"
+										class="col px-0 border mb-4 rounded"
+										v-on:lightbox="lightbox"
+										v-on:togglecw="status.sensitive = false" />
+								</div>
+
+								<div>
+									<div class="pl-2">
+
+										<div v-if="status.favourites_count || status.reply_count" class="border-top my-0">
+											<div class="d-flex justify-content-between py-2" style="font-size: 14px;font-weight: 400;">
+												<div v-if="status.favourites_count">
+													{{ status.favourites_count }} Likes
+												</div>
+
+												<button v-if="status.reply_count" class="btn btn-link py-0 text-decoration-none text-dark" @click="commentFocus($event)">
+													{{ status.reply_count }} Comments
+												</button>
+											</div>
+										</div>
+
+										<div class="border-top my-0">
+											<div class="d-flex justify-content-between py-2 px-4">
+												<button class="btn btn-link py-0 text-decoration-none" :class="{ 'font-weight-bold': status.favourited, 'text-primary': status.favourited, 'text-muted': !status.favourited }"
+														@click="likeStatus(status, $event);">
+													<i class="far fa-heart mr-1">
+													</i>
+													{{ status.favourited ? 'Liked' : 'Like' }}
+												</button>
+
+												<button class="btn btn-link py-0 text-decoration-none text-muted" @click="commentFocus($event)">
+													<i class="far fa-comment cursor-pointer text-muted mr-1">
+													</i>
+													Comment
+												</button>
+
+												<button class="btn btn-link py-0 text-decoration-none" disabled>
+													<i class="fas fa-external-link-alt cursor-pointer text-muted mr-1">
+													</i>
+													Share
+												</button>
+											</div>
+										</div>
+
+										<comment-drawer
+											v-if="showCommentDrawer"
+											:status="permalinkMode ? parentContext : status"
+											:profile="profile"
+											:permalink-mode="permalinkMode"
+											:permalink-status="childContext"
+											:group-id="groupId" />
+									</div>
+								</div>
+							</div>
+						</div>
+					</div>
+				</div>
+			</div> -->
+
+			<context-menu
+				v-if="profile"
+				ref="contextMenu"
+				:status="status"
+				:profile="profile"
+				:group-id="groupId"
+				v-on:status-delete="statusDeleted"
+			/>
+
+			<post-modal
+				v-if="showModal"
+				ref="modal"
+				:status="status"
+				:profile="profile"
+				:groupId="groupId"
+				/>
+
+		</div>
+
+		<div v-else class="card card-body shadow-none border mb-3" style="height: 200px;">
+			<div class="w-100 h-100 d-flex justify-content-center align-items-center">
+				<div class="spinner-border text-primary" role="status">
+					<span class="sr-only">Loading...</span>
+				</div>
+			</div>
+		</div>
+
+		<!-- <b-modal
+			v-if="likes && likes.length"
+			ref="likeBox"
+			title="Liked by"
+			size="sm"
+			centered
+			hide-footer
+			title="Likes"
+			body-class="list-group-flush py-3 px-0">
+			<div class="list-group">
+				<div class="list-group-item border-0 py-1" v-for="(user, index) in likes" :key="'modal_likes_'+index+status.id">
+					<div class="media">
+						<a :href="user.url">
+							<img class="mr-3 rounded-circle box-shadow" :src="user.avatar" :alt="user.username + '’s avatar'" width="30px" onerror="this.onerror=null;this.src='/storage/avatars/default.jpg';">
+						</a>
+						<div class="media-body">
+							<p class="mb-0" style="font-size: 14px">
+								<a :href="user.url" class="font-weight-bold text-dark">
+									{{user.username}}
+								</a>
+							</p>
+							<p v-if="!user.local" class="text-muted mb-0 text-truncate mr-3" style="font-size: 14px" :title="user.acct" data-toggle="dropdown" data-placement="bottom">
+								<span class="font-weight-bold">{{user.acct.split('@')[0]}}</span><span class="text-lighter">&commat;{{user.acct.split('@')[1]}}</span>
+							</p>
+							<p v-else class="text-muted mb-0 text-truncate" style="font-size: 14px">
+								{{user.display_name}}
+							</p>
+						</div>
+					</div>
+				</div>
+				<infinite-loading v-if="likes && likes.length" @infinite="infiniteLikesHandler" spinner="spiral">
+					<div slot="no-more"></div>
+					<div slot="no-results"></div>
+				</infinite-loading>
+			</div>
+		</b-modal> -->
+	</div>
+</template>
+
+<script type="text/javascript">
+	import CommentDrawer from './CommentDrawer.vue';
+	import ContextMenu from './ContextMenu.vue';
+	import PollCard from '~/partials/PollCard.vue';
+	import MixedAlbumPresenter from '@/presenter/MixedAlbumPresenter.vue';
+	import PhotoAlbumPresenter from '@/presenter/PhotoAlbumPresenter.vue';
+	import PhotoPresenter from '@/presenter/PhotoPresenter.vue';
+	import VideoAlbumPresenter from '@/presenter/VideoAlbumPresenter.vue';
+	import VideoPresenter from '@/presenter/VideoPresenter.vue';
+	import GroupPostModal from './GroupPostModal.vue';
+    import { autoLink } from 'twitter-text';
+    import ParentUnavailable from '@/groups/partials/Status/ParentUnavailable.vue';
+    import GroupPostHeader from '@/groups/partials/Status/GroupHeader.vue';
+
+	export default {
+		props: {
+			groupId: {
+				type: String
+			},
+
+			group: {
+				type: Object
+			},
+
+			profile: {
+				type: Object
+			},
+
+			prestatus: {
+				type: Object
+			},
+
+			recommended: {
+				type: Boolean,
+				default: false
+			},
+
+			reactionBar: {
+				type: Boolean,
+				default: true
+			},
+
+			hasTopBorder: {
+				type: Boolean,
+				default: true
+			},
+
+			size: {
+				type: String,
+				validator: (val) => ['regular', 'small'].includes(val),
+				default: 'regular'
+			},
+
+			permalinkMode: {
+				type: Boolean,
+				default: false
+			},
+
+			showGroupChevron: {
+				type: Boolean,
+				default: false
+			},
+
+			showGroupHeader: {
+				type: Boolean,
+				default: false
+			}
+		},
+
+		components: {
+			"comment-drawer": CommentDrawer,
+			"context-menu": ContextMenu,
+			"poll-card": PollCard,
+			"mixed-album-presenter": MixedAlbumPresenter,
+			"photo-album-presenter": PhotoAlbumPresenter,
+			"photo-presenter": PhotoPresenter,
+			"video-album-presenter": VideoAlbumPresenter,
+			"video-presenter": VideoPresenter,
+			"post-modal": GroupPostModal,
+            "parent-unavailable": ParentUnavailable,
+            "group-post-header": GroupPostHeader
+		},
+
+		data() {
+			return {
+				config: window.App.config,
+				status: {},
+				loaded: false,
+				replies: [],
+				replyId: null,
+				lightboxMedia: false,
+				showSuggestions: true,
+				showReadMore: true,
+				replyStatus: {},
+				replyText: '',
+				replyNsfw: false,
+				emoji: window.App.util.emoji,
+				commentDrawerKey: 0,
+				showCommentDrawer: false,
+				parentContext: undefined,
+				childContext: undefined,
+				parentUnavailable: undefined,
+				showModal: false,
+				likes: [],
+				likesPage: 1,
+				openLikesModal: false,
+			}
+		},
+
+        computed: {
+            renderedCaption: {
+                get() {
+                    if(this.prestatus) {
+                        const gid = this.prestatus.gid;
+                        return autoLink(
+                            this.prestatus.content,
+                            {
+                                hashtagUrlBase: App.config.site.url + `/groups/${gid}/topics/`,
+                                usernameUrlBase: App.config.site.url + `/groups/${gid}/username/`
+                            }
+                        )
+                    }
+
+                    return this.prestatus.content;
+                }
+            }
+        },
+
+		mounted() {
+			this.status = this.prestatus;
+			let self = this;
+			setTimeout(() => {
+				if(this.permalinkMode == true && this.prestatus.in_reply_to_id) {
+					self.childContext = self.status;
+					axios.get('/api/v0/groups/status', {
+						params: {
+							gid: self.groupId,
+							sid: self.status.in_reply_to_id
+						}
+					}).then(res => {
+						self.status = res.data;
+						self.parentUnavailable = false;
+						self.showCommentDrawer = true;
+						self.parentContext = res.data;
+						self.loaded = true;
+					}).catch(err => {
+						self.status = this.prestatus;
+						self.parentUnavailable = true;
+						self.showCommentDrawer = true;
+						self.parentContext = this.prestatus;
+						self.loaded = true;
+					});
+				} else {
+					self.parentUnavailable = false;
+					self.showCommentDrawer = false;
+					self.loaded = true;
+				}
+			}, 100);
+		},
+
+		methods: {
+			formatCount(count) {
+				return App.util.format.count(count);
+			},
+
+			statusUrl(status) {
+				if(status.local == true) {
+					return status.url;
+				}
+
+				return '/i/web/post/_/' + status.account.id + '/' + status.id;
+			},
+
+			profileUrl(status) {
+				if(status.local == true) {
+					return status.account.url;
+				}
+
+				return '/i/web/profile/_/' + status.account.id;
+			},
+
+			timestampFormat(timestamp) {
+				let ts = new Date(timestamp);
+				return ts.toDateString() + ' ' + ts.toLocaleTimeString();
+			},
+
+			shortTimestamp(ts) {
+				return window.App.util.format.timeAgo(ts);
+			},
+
+			statusCardUsernameFormat(status) {
+				if(status.account.local == true) {
+					return status.account.username;
+				}
+
+				let fmt = window.App.config.username.remote.format;
+				let txt = window.App.config.username.remote.custom;
+				let usr = status.account.username;
+				let dom = document.createElement('a');
+				dom.href = status.account.url;
+				dom = dom.hostname;
+
+				switch(fmt) {
+					case '@':
+					return usr + '<span class="text-lighter font-weight-bold">@' + dom + '</span>';
+					break;
+
+					case 'from':
+					return usr + '<span class="text-lighter font-weight-bold"> <span class="font-weight-normal">from</span> ' + dom + '</span>';
+					break;
+
+					case 'custom':
+					return usr + '<span class="text-lighter font-weight-bold"> ' + txt + ' ' + dom + '</span>';
+					break;
+
+					default:
+					return usr + '<span class="text-lighter font-weight-bold">@' + dom + '</span>';
+					break;
+				}
+			},
+
+			lightbox(status) {
+				window.location.href = status.media_attachments[0].url;
+			},
+
+			labelRedirect(type) {
+				let url = '/i/redirect?url=' + encodeURI(this.config.features.label.covid.url);
+				window.location.href = url;
+			},
+
+			likeStatus(status, event) {
+				event.currentTarget.blur();
+				let count = status.favourites_count;
+                let state = status.favourited ? 'unlike' : 'like';
+				axios.post('/api/v0/groups/status/' + state, {
+					sid: status.id,
+					gid: this.groupId
+				}).then(res => {
+					status.favourited = state;
+					status.favourites_count = state? count + 1 : count - 1;
+					status.favourited = state;
+					if(status.favourited) {
+						setTimeout(function() {
+							event.target.classList.add('animate__animated', 'animate__bounce');
+						},100);
+					}
+				}).catch(err => {
+					if(err.response.status == 422) {
+						swal('Error', err.response.data.error, 'error');
+					} else {
+						swal('Error', 'Something went wrong, please try again later.', 'error');
+					}
+				});
+				window.navigator.vibrate(200);
+			},
+
+			commentFocus($event) {
+				$event.target.blur();
+				this.showCommentDrawer = !this.showCommentDrawer;
+			},
+
+			commentSubmit(status, $event) {
+				this.replySending = true;
+				let id = status.id;
+				let comment = this.replyText;
+				let limit = this.config.uploader.max_caption_length;
+				if(comment.length > limit) {
+					this.replySending = false;
+					swal('Comment Too Long', 'Please make sure your comment is '+limit+' characters or less.', 'error');
+					return;
+				}
+				axios.post('/i/comment', {
+					item: id,
+					comment: comment,
+					sensitive: this.replyNsfw
+				}).then(res => {
+					this.replyText = '';
+					this.replies.push(res.data.entity);
+					this.$refs.replyModal.hide();
+				});
+				this.replySending = false;
+			},
+
+			owner(status) {
+				return this.profile.id === status.account.id;
+			},
+
+			admin() {
+				return this.profile.is_admin == true;
+			},
+
+			ownerOrAdmin(status) {
+				return this.owner(status) || this.admin();
+			},
+
+			ctxMenu() {
+				this.$refs.contextMenu.open();
+			},
+
+			timeAgo(ts) {
+				return App.util.format.timeAgo(ts);
+			},
+
+			statusDeleted(status) {
+				this.$emit('status-delete');
+			},
+
+			showPostModal() {
+				this.showModal = true;
+				this.$refs.modal.init();
+			},
+
+			showLikesModal(event) {
+				if(event && event.hasOwnProperty('currentTarget')) {
+					event.currentTarget().blur();
+				}
+
+				this.$emit('likes-modal');
+				return;
+
+				if(this.likes.length) {
+					this.$refs.likeBox.show();
+					return;
+				}
+
+				axios.get('/api/v0/groups/'+this.groupId+'/likes/'+this.status.id)
+				.then(res => {
+					this.likes = res.data.data;
+					this.$refs.likeBox.show();
+				});
+			},
+
+			infiniteLikesHandler($state) {
+				axios.get('/api/v0/groups/'+this.groupId+'/likes/'+this.status.id, {
+					params: {
+						page: this.likesPage,
+					},
+				}).then(({ data }) => {
+					if (data.data.length > 0) {
+						this.likes.push(...data.data);
+						this.likesPage++;
+						$state.loaded();
+					} else {
+						$state.complete();
+					}
+				});
+			},
+		}
+	}
+</script>
+
+<style lang="scss">
+	.status-card-component {
+		.status-content {
+			font-size: 17px;
+		}
+
+		&.status-card-sm {
+			.status-content {
+				font-size: 14px;
+			}
+
+			.fa-lg {
+				font-size: unset;
+				line-height: unset;
+				vertical-align: unset;
+			}
+		}
+	}
+
+	.reaction-bar {
+		width: auto;
+		max-width: unset;
+		left: -50px !important;
+		border: 1px solid #F3F4F6 !important;
+
+		.popover-body {
+			padding: 2px;
+		}
+
+		.arrow {
+			display: none;
+		}
+
+		img {
+			width: 48px;
+		}
+	}
+</style>

+ 73 - 0
resources/assets/components/groups/partials/GroupTopics.vue

@@ -0,0 +1,73 @@
+<template>
+	<div class="group-topics-component">
+		<div class="row justify-content-center">
+			<div class="col-12 col-md-8">
+				<div class="card card-body border shadow-sm">
+					<div class="d-flex justify-content-between align-items-center mb-3">
+						<p class="h4 font-weight-bold mb-0">Group Topics</p>
+						<select class="form-control bg-light rounded-lg border font-weight-bold py-2" style="width:95px;" disabled>
+							<option>All</option>
+							<option>Pinned</option>
+						</select>
+					</div>
+
+					<div v-if="feed.length">
+						<div v-for="(tag, index) in feed" class="">
+							<div class="media py-2">
+								<i class="fas fa-hashtag fa-lg text-lighter mr-3 mt-2"></i>
+								<div :class="{ 'border-bottom': index != feed.length - 1 }" class="media-body">
+									<a :href="tag.url" class="font-weight-bold mb-1 text-dark" style="font-size: 16px;">
+										{{ tag.name }}
+									</a>
+									<p style="font-size: 13px;" class="text-muted">{{ tag.count }} posts in this group</p>
+								</div>
+							</div>
+						</div>
+					</div>
+
+					<div v-else class="py-5">
+						<p class="lead text-center font-weight-bold">No topics found</p>
+					</div>
+				</div>
+			</div>
+		</div>
+	</div>
+</template>
+
+<script type="text/javascript">
+	export default {
+		props: {
+			group: {
+				type: Object
+			}
+		},
+
+		data() {
+			return {
+				feed: []
+			}
+		},
+
+		mounted() {
+			this.fetchTopics();
+		},
+
+		methods: {
+			fetchTopics() {
+				axios.get('/api/v0/groups/topics/list', {
+					params: {
+						gid: this.group.id
+					}
+				}).then(res => {
+					this.feed = res.data;
+				})
+			}
+		}
+	}
+</script>
+
+<style lang="scss">
+	.group-topics-component {
+		margin-bottom: 50vh;
+	}
+</style>

+ 9 - 0
resources/assets/components/groups/partials/LeaveGroup.vue

@@ -0,0 +1,9 @@
+<template>
+	
+</template>
+
+<script type="text/javascript">
+	export default {
+		
+	}
+</script>

+ 172 - 0
resources/assets/components/groups/partials/MemberLimitInteractionsModal.vue

@@ -0,0 +1,172 @@
+<template>
+	<div>
+		<b-modal
+			v-if="profile && profile.hasOwnProperty('avatar')"
+			ref="home"
+			hide-footer
+			centered
+			rounded
+			title="Limit Interactions"
+			body-class="rounded">
+
+			<div class="media mb-3">
+				<a :href="profile.url" class="text-dark text-decoration-none">
+					<img :src="profile.avatar" width="56" height="56" class="rounded-circle border mr-2" />
+				</a>
+
+				<div class="media-body">
+					<p class="lead font-weight-bold mb-0">
+						<a :href="profile.url" class="text-dark text-decoration-none">{{profile.username}}</a>
+						<span v-if="profile.role == 'founder'" class="member-label rounded ml-1">Admin</span>
+					</p>
+					<p class="text-muted mb-0">Member since {{formatDate(profile.joined)}}</p>
+				</div>
+			</div>
+
+			<div class="w-100 bg-light mb-1 font-weight-bold d-flex justify-content-center align-items-center border rounded" style="min-height:240px;">
+				<div v-if="limitsLoaded" class="py-3">
+					<p class="lead mb-0">Interaction Permissions</p>
+					<p class="small text-muted">Last updated: {{ updated ? formatDate(updated) : 'Never' }}</p>
+
+					<div class="form-check">
+						<input class="form-check-input" type="checkbox" v-model="limits.can_post" :disabled="savingChanges">
+						<label class="form-check-label">
+							Can create posts
+						</label>
+					</div>
+
+					<div class="form-check">
+						<input class="form-check-input" type="checkbox" v-model="limits.can_comment" :disabled="savingChanges">
+						<label class="form-check-label">
+							Can create comments
+						</label>
+					</div>
+
+					<div class="form-check">
+						<input class="form-check-input" type="checkbox" v-model="limits.can_like" :disabled="savingChanges">
+						<label class="form-check-label">
+							Can like posts and comments
+						</label>
+					</div>
+
+					<hr>
+
+					<button class="btn btn-primary font-weight-bold float-right" @click.prevent="saveChanges" :disabled="savingChanges" style="width:130px;">
+						<b-spinner v-if="savingChanges" variant="light" small />
+						<span v-else>Save changes</span>
+					</button>
+				</div>
+				<div v-else class="d-flex align-items-center flex-column">
+					 <b-spinner variant="muted"  />
+					 <p class="pt-3 small text-muted font-weight-bold">Loading interaction limits...</p>
+				</div>
+			</div>
+		</b-modal>
+	</div>
+</template>
+
+<script type="text/javascript">
+	export default {
+		props: {
+			group: {
+				type: Object
+			},
+			profile: {
+				type: Object
+			}
+		},
+
+		data() {
+			return {
+				limitsLoaded: false,
+				limits: {
+					can_post: true,
+					can_comment: true,
+					can_like: true
+				},
+				updated: null,
+				savingChanges: false
+			}
+		},
+
+		methods: {
+			fetchInteractionLimits() {
+				axios.get(`/api/v0/groups/${this.group.id}/members/interaction-limits`, {
+					params: {
+						profile_id: this.profile.id
+					}
+				})
+				.then(res => {
+					this.limits = res.data.limits;
+					this.updated = res.data.updated_at;
+					this.limitsLoaded = true;
+				}).catch(err => {
+					this.$refs.home.hide();
+					swal('Oops!', 'Cannot fetch interaction limits at this time, please try again later.', 'error');
+				})
+			},
+
+			open(profile) {
+				this.loaded = true;
+				this.$refs.home.show();
+				this.fetchInteractionLimits();
+			},
+
+			formatDate(ts) {
+				return new Date(ts).toDateString();
+			},
+
+			saveChanges() {
+				event.currentTarget.blur();
+				this.savingChanges = true;
+
+				axios.post(`/api/v0/groups/${this.group.id}/members/interaction-limits`, {
+					profile_id: this.profile.id,
+					can_post: this.limits.can_post,
+					can_comment: this.limits.can_comment,
+					can_like: this.limits.can_like,
+				})
+				.then(res => {
+					this.savingChanges = false;
+					this.$refs.home.hide();
+
+					this.$bvToast.toast(`Updated interaction limits for ${this.profile.username}`, {
+						title: 'Success',
+						variant: 'success',
+						autoHideDelay: 5000
+					});
+				}).catch(err => {
+					this.savingChanges = false;
+					this.$refs.home.hide();
+
+					if(err.response.status == 422 && err.response.data.error == 'limit_reached') {
+							swal('Limit Reached', 'You cannot add any more member limitations', 'info');
+							// swal({
+							// 	title: 'Limit Reached',
+							// 	text: 'You cannot add any more member limitations',
+							// 	icon: 'info',
+							// 	buttons: {
+							// 		info: {
+							// 			className: 'btn-light border',
+							// 			text: 'More info',
+							// 			value: 'more-info'
+							// 		},
+
+							// 		ok: {
+							// 			text: 'Ok',
+							// 			value: null
+							// 		},
+							// 	}
+							// }).then(value => {
+							// 	if(value == 'more-info') {
+							// 		location.href = '/site/kb/groups/interaction-limits';
+							// 	}
+							// });
+					} else {
+						swal('Oops!', 'An error occured while processing this request, please try again later.', 'error');
+					}
+				});
+			}
+		}
+	}
+</script>

+ 38 - 0
resources/assets/components/groups/partials/Membership/MemberOnlyWarning.vue

@@ -0,0 +1,38 @@
+<template>
+    <div class="member-only-warning">
+        <div class="member-only-warning-wrapper">
+            <h3>Content unavailable</h3>
+            <p>You need to join this Group before you can access this content.</p>
+        </div>
+    </div>
+</template>
+
+<style lang="scss" scoped>
+    .member-only-warning {
+        display: flex;
+        justify-content: center;
+
+        &-wrapper {
+            display: flex;
+            justify-content: center;
+            align-items: center;
+            flex-direction: column;
+            width: 100%;
+            max-width: 550px;
+            border-radius: 10px;
+            border: 1px solid var(--border-color);
+            padding: 4rem 1rem;
+
+
+            h3 {
+                font-weight: bold;
+                letter-spacing: -1px;
+            }
+
+            p {
+                font-size: 1.2em;
+                margin-bottom: 0px;
+            }
+        }
+    }
+</style>

+ 44 - 0
resources/assets/components/groups/partials/Page/GroupBanner.vue

@@ -0,0 +1,44 @@
+<template>
+    <div class="px-md-5" style="background-color: #fff;">
+        <img
+            v-if="group.metadata && group.metadata.hasOwnProperty('header')"
+            :src="group.metadata.header.url"
+            class="header-image">
+        <div v-else class="header-jumbotron"></div>
+    </div>
+</template>
+
+<script>
+    export default {
+        props: {
+            group: {
+                type: Object
+            }
+        }
+    }
+</script>
+
+<style lang="scss" scoped>
+    .header-image {
+        width: 100%;
+        height: auto;
+        object-fit: cover;
+        max-height: 220px;
+        border-bottom-left-radius: 5px;
+        border-bottom-right-radius: 5px;
+        margin-top:-1px;
+        border: 1px solid var(--light);
+        margin-bottom: 0px;
+
+        @media(min-width: 768px) {
+            max-height: 420px;
+        }
+    }
+
+    .header-jumbotron {
+        background-color: #F3F4F6;
+        height: 320px;
+        border-bottom-left-radius: 20px;
+        border-bottom-right-radius: 20px;
+    }
+</style>

+ 199 - 0
resources/assets/components/groups/partials/Page/GroupHeaderDetails.vue

@@ -0,0 +1,199 @@
+<template>
+    <div class="col-12 group-feed-component-header px-3 px-md-5">
+        <div class="media align-items-end">
+            <img
+                v-if="group.metadata && group.metadata.hasOwnProperty('avatar')"
+                :src="group.metadata.avatar.url"
+                width="169"
+                height="169"
+                class="bg-white mx-4 rounded-circle border shadow p-1"
+                style="object-fit: cover;"
+                :style="{ 'margin-top': group.metadata && group.metadata.hasOwnProperty('header') && group.metadata.header.url ? '-100px' : '0' }"
+            />
+
+            <div v-if="!group || !group.name" class="media-body">
+                <h3 class="d-flex align-items-start">
+                    <span>Loading...</span>
+                </h3>
+            </div>
+            <div v-else class="media-body px-3">
+                <h3 class="d-flex align-items-start">
+                    <span>{{ group.name.slice(0,118) }}</span>
+                    <sup v-if="group.verified" class="fa-stack ml-n2" title="Verified Group" data-toggle="tooltip">
+                        <i class="fas fa-circle fa-stack-1x fa-lg" style="color: #22a7f0cc;font-size:18px"></i>
+                        <i class="fas fa-check fa-stack-1x text-white" style="font-size:10px"></i>
+                    </sup>
+                </h3>
+                <p class="text-muted mb-0" style="font-weight: 300;">
+                    <span>
+                        <i class="fas fa-globe mr-1"></i>
+                        {{ group.membership == 'all' ? 'Public Group' : 'Private Group'}}
+                    </span>
+                    <span class="mx-2">
+                        ·
+                    </span>
+                    <span>{{ group.member_count == 1 ? group.member_count + ' Member' : group.member_count + ' Members' }}</span>
+                    <span class="mx-2">
+                        ·
+                    </span>
+                    <span v-if="group.local" class="rounded member-label">Local</span>
+                    <span v-else class="rounded remote-label">Remote</span>
+                    <span v-if="group.self && group.self.hasOwnProperty('role') && group.self.role">
+                        <span class="mx-2">
+                            ·
+                        </span>
+                        <span class="rounded member-label">{{ group.self.role }}</span>
+                    </span>
+                </p>
+            </div>
+        </div>
+        <div v-if="group && group.self">
+            <button v-if="!isMember && !group.self.is_requested" class="btn btn-primary cta-btn font-weight-bold" @click="joinGroup" :disabled="requestingMembership">
+                <span v-if="!requestingMembership">
+                    {{ group.membership == 'all' ? 'Join' : 'Request Membership' }}
+                </span>
+                <div
+                    v-else
+                    class="spinner-border spinner-border-sm"
+                    role="status">
+                    <span class="sr-only">Loading...</span>
+                </div>
+            </button>
+
+            <button
+                v-else-if="!isMember && group.self.is_requested"
+                class="btn btn-light border cta-btn font-weight-bold"
+                @click.prevent="cancelJoinRequest">
+                <i class="fas fa-user-clock mr-1"></i> Requested to Join
+            </button>
+
+            <button
+                v-else-if="!isAdmin && isMember && !group.self.is_requested"
+                type="button"
+                class="btn btn-light border cta-btn font-weight-bold"
+                @click.prevent="leaveGroup">
+                <i class="fas sign-out-alt mr-1"></i> Leave Group
+            </button>
+
+            <!-- <div v-if="isAdmin">
+                <a
+                    class="btn btn-light border cta-btn font-weight-bold"
+                    :href="group.url + '/settings'">
+                    Settings
+                </a>
+            </div> -->
+        </div>
+    </div>
+</template>
+
+<script>
+    export default {
+        props: {
+            group: {
+                type: Object
+            },
+            isAdmin: {
+                type: Boolean,
+                default: false
+            },
+            isMember: {
+                type: Boolean,
+                default: false
+            }
+        },
+
+        data() {
+            return {
+                requestingMembership: false,
+            }
+        },
+
+        methods: {
+            joinGroup() {
+                this.requestingMembership = true;
+
+                axios.post('/api/v0/groups/'+this.group.id+'/join')
+                .then(res => {
+                    this.requestingMembership = false;
+                    this.$emit('refresh');
+                }).catch(err => {
+                    let body = err.response;
+
+                    if(body.status == 422) {
+                        this.requestingMembership = false;
+                        swal('Oops!', body.data.error, 'error');
+                    }
+                });
+            },
+
+            cancelJoinRequest() {
+                if(!window.confirm('Are you sure you want to cancel your request to join this group?')) {
+                    return;
+                }
+
+                axios.post('/api/v0/groups/'+this.group.id+'/cjr')
+                .then(res => {
+                    this.requestingMembership = false;
+                    this.$emit('refresh');
+                }).catch(err => {
+                    let body = err.response;
+
+                    if(body.status == 422) {
+                        swal('Oops!', body.data.error, 'error');
+                    }
+                });
+            },
+
+            leaveGroup() {
+                if(!window.confirm('Are you sure you want to leave this group? Any content you shared will remain accessible. You won\'t be able to rejoin for 24 hours.')) {
+                    return;
+                }
+
+                axios.post('/api/v0/groups/'+this.group.id+'/leave')
+                .then(res => {
+                    this.$emit('refresh');
+                });
+            },
+        }
+    }
+</script>
+
+<style lang="scss">
+    .group-feed-component {
+        &-header {
+            display: flex;
+            justify-content: space-between;
+            align-items: flex-end;
+            padding: 1rem 0;
+            background-color: transparent;
+
+            .cta-btn {
+                width: 190px;
+            }
+        }
+
+        .member-label {
+            padding: 2px 5px;
+            font-size: 12px;
+            color: rgba(75, 119, 190, 1);
+            background:rgba(137, 196, 244, 0.2);
+            border:1px solid rgba(137, 196, 244, 0.3);
+            font-weight:400;
+            text-transform: capitalize;
+        }
+
+        .dropdown-item {
+            font-weight: 600;
+        }
+
+        .remote-label {
+            padding: 2px 5px;
+            font-size: 12px;
+            color: #B45309;
+            background: #FEF3C7;
+            border: 1px solid #FCD34D;
+            font-weight: 400;
+            text-transform: capitalize;
+        }
+    }
+</style>

+ 167 - 0
resources/assets/components/groups/partials/Page/GroupNavTabs.vue

@@ -0,0 +1,167 @@
+<template>
+    <div>
+    <div class="col-12 border-top group-feed-component-menu px-5">
+        <ul class="nav font-weight-bold group-feed-component-menu-nav">
+            <li class="nav-item">
+                <router-link :to="`/groups/${group.id}/about`" class="nav-link">About</router-link>
+            </li>
+            <li class="nav-item">
+                <router-link :to="`/groups/${group.id}`" class="nav-link" exact>Feed</router-link>
+            </li>
+            <li v-if="group?.self && group.self.is_member" class="nav-item">
+                <router-link :to="`/groups/${group.id}/topics`" class="nav-link">Topics</router-link>
+            </li>
+            <li v-if="group?.self && group.self.is_member" class="nav-item">
+                <router-link :to="`/groups/${group.id}/members`" class="nav-link">
+                    Members
+                    <span v-if="group.self.is_member && isAdmin && atabs.request_count" class="badge badge-danger rounded-pill ml-2" style="height: 20px;padding:4px 8px;font-size: 11px;">{{atabs.request_count}}</span>
+                </router-link>
+            </li>
+            <!-- <li v-if="group.self.is_member" class="nav-item">
+                <a :class="{active: tab == 'events'}" class="nav-link" href="#" @click.prevent="switchTab('events')">Events</a>
+            </li> -->
+            <li v-if="group?.self && group.self.is_member" class="nav-item">
+                <router-link :to="`/groups/${group.id}/media`" class="nav-link">Media</router-link>
+            </li>
+            <!-- <li v-if="group.self.is_member" class="nav-item">
+                <a class="nav-link" href="#">Popular</a>
+            </li> -->
+            <!-- <li v-if="group.self.is_member" class="nav-item">
+                <a :class="{active: tab == 'polls'}" class="nav-link" href="#" @click.prevent="switchTab('polls')">Polls</a>
+            </li> -->
+
+            <!-- <li v-if="group.self.is_member && isAdmin" class="nav-item">
+                <a class="nav-link" href="#">Messages</a>
+            </li> -->
+
+            <!-- <li v-if="group.self.is_member && isAdmin" class="nav-item">
+                <a :class="{active: tab == 'insights'}" class="nav-link" href="#" @click.prevent="switchTab('insights')">Insights</a>
+            </li> -->
+            <!-- <li v-if="group.self.is_member && isAdmin && group.membership != 'all'" class="nav-item">
+                <a :class="{active: tab == 'requests'}" class="nav-link" href="#" @click.prevent="switchTab('requests')">
+                    <span class="mr-2">
+                        <i class="far fa-user-plus mr-1"></i>
+                        Requests
+                    </span>
+                    <span v-if="atabs.request_count" class="badge badge-danger rounded-pill" style="height: 20px;padding:4px 8px;font-size: 11px;">{{atabs.request_count}}</span>
+                    <span v-if="atabs.request_count" class="badge badge-danger rounded-pill" style="height: 20px;padding:4px 8px;font-size: 11px;">99+</span>
+                </a>
+            </li> -->
+            <li v-if="group?.self && group.self.is_member && isAdmin" class="nav-item">
+                <router-link :to="`/groups/${group.id}/moderation`" class="nav-link d-flex align-items-top">
+                    <span class="mr-2">Moderation</span>
+                    <span v-if="atabs.moderation_count" class="badge badge-danger rounded-pill" style="height: 20px;padding:4px 8px;font-size: 11px;">{{atabs.moderation_count}}</span>
+                </router-link>
+            </li>
+        </ul>
+        <div>
+            <button
+                v-if="group?.self && group.self.is_member"
+                class="btn btn-light btn-sm border px-3 rounded-pill mr-2"
+                @click="showSearchModal">
+                <i class="far fa-search"></i>
+            </button>
+            <div class="dropdown d-inline">
+                <button class="btn btn-light btn-sm border px-3 rounded-pill dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
+                    <i class="far fa-cog"></i>
+                </button>
+                <div class="dropdown-menu dropdown-menu-right">
+                    <a class="dropdown-item" href="#" @click.prevent="copyLink">
+                        Copy Group Link
+                    </a>
+
+                    <a class="dropdown-item" href="#" @click.prevent="showInviteModal">
+                        Invite friends
+                    </a>
+
+                    <a v-if="!isAdmin" class="dropdown-item" href="#" @click.prevent="reportGroup">
+                        Report Group
+                    </a>
+
+                    <a v-if="isAdmin" class="dropdown-item" :href="group.url + '/settings'">
+                        Settings
+                    </a>
+                </div>
+            </div>
+        </div>
+
+    </div>
+        <search-modal
+            ref="searchModal"
+            :group="group"
+            :profile="profile"
+        />
+    </div>
+</template>
+
+<script>
+    import SearchModal from '@/groups/partials/GroupSearchModal.vue';
+    export default {
+        props: {
+            group: {
+                type: Object
+            },
+            isAdmin: {
+                type: Boolean,
+                default: false
+            },
+            isMember: {
+                type: Boolean,
+                default: false
+            },
+            atabs: {
+                type: Object
+            },
+            profile: {
+                type: Object
+            }
+        },
+
+        components: {
+            'search-modal': SearchModal,
+        },
+
+        methods: {
+            showSearchModal() {
+                event.currentTarget.blur();
+                this.$refs.searchModal.open();
+            },
+
+            // showInviteModal() {
+            //     event.currentTarget.blur();
+            //     this.$refs.inviteModal.open();
+            // },
+        }
+    }
+</script>
+<style lang="scss">
+    .group-feed-component {
+        &-menu {
+            display: flex;
+            justify-content: space-between;
+            align-items: center;
+            padding: 0;
+
+            &-nav {
+                .nav-item {
+                    .nav-link {
+                        padding-top: 1rem;
+                        padding-bottom: 1rem;
+                        color: #6c757d;
+
+                        &.active {
+                            color: #2c78bf;
+                            border-bottom: 2px solid #2c78bf;
+                        }
+                    }
+                }
+
+                &:not(last-child) {
+                    .nav-item {
+                        margin-right: 14px;
+                    }
+                }
+            }
+        }
+    }
+</style>

+ 51 - 0
resources/assets/components/groups/partials/ReadMore.vue

@@ -0,0 +1,51 @@
+<template>
+	<div class="read-more-component" style="word-break: break-all;">
+		<div v-if="status.content.length < 200" v-html="content"></div>
+		<div v-else>
+			<span v-html="content"></span>
+			<a
+				v-if="cursor == 200 || fullContent.length > cursor"
+				class="font-weight-bold text-muted" href="#"
+				style="display: block;white-space: nowrap;"
+				@click.prevent="readMore">
+				<i class="d-none fas fa-caret-down"></i> Read more...
+			</a>
+		</div>
+	</div>
+</template>
+
+<script type="text/javascript">
+	export default {
+		props: {
+			status: {
+				type: Object
+			},
+
+			cursorLimit: {
+				type: Number,
+				default: 200
+			}
+		},
+
+		data() {
+			return {
+				fullContent: null,
+				content: null,
+				cursor: 200
+			}
+		},
+
+		mounted() {
+			this.cursor = this.cursorLimit;
+			this.fullContent = this.status.content;
+			this.content = this.status.content.substr(0, this.cursor);
+		},
+
+		methods: {
+			readMore() {
+				this.cursor = this.cursor + 200;
+				this.content = this.fullContent.substr(0, this.cursor);
+			}
+		}
+	}
+</script>

+ 465 - 0
resources/assets/components/groups/partials/SelfDiscover.vue

@@ -0,0 +1,465 @@
+<template>
+	<div class="self-discover-component col-12 col-md-9 bg-lighter border-left mb-4">
+		<div class="px-5">
+            <div class="jumbotron my-4 text-light bg-mantle">
+            	<div class="container">
+            		<h1 class="display-4">Discover</h1>
+                	<p class="lead mb-0">Explore group communities and topics</p>
+            		<!-- <p class="lead">
+            			<button class="btn btn-outline-light">Browse Categories</button>
+            		</p> -->
+            	</div>
+            </div>
+        </div>
+
+		<div v-if="tab === 'home'" class="px-5">
+            <div class="row mb-4 pt-5">
+				<div class="col-12 col-md-4">
+					<h4 class="font-weight-bold">Popular</h4>
+					<div class="list-group list-group-scroll">
+						<a v-for="(group, index) in popularGroups"
+							class="list-group-item p-1"
+							:href="group.url">
+							<group-list-card :group="group" :compact="true" />
+						</a>
+					</div>
+				</div>
+
+				<div class="col-12 col-md-4">
+					<div class="card card-body shadow-none bg-mantle text-light" style="margin-top: 33px;">
+						<h3 class="mb-4 font-weight-lighter">Discover communities and topics based on your interests</h3>
+						<p class="mb-0">
+							<button class="btn btn-outline-light font-weight-light btn-block" @click="toggleTab('categories')">Browse Categories</button>
+						</p>
+					</div>
+
+					<div class="card card-body shadow-none bg-light text-dark border" style="margin-top: 20px;">
+						<p class="lead mb-4 text-muted font-weight-lighter mb-1">Browse Public Groups</p>
+						<!-- <p class="lead mb-4 text-muted font-weight-lighter">Tips for growing your group membership</p> -->
+						<!-- <h5 class="mb-4 text-muted font-weight-lighter">Create and easily organize events</h5> -->
+						<p class="mb-0">
+							<button class="btn btn-light border font-weight-light btn-block">Group Directory</button>
+						</p>
+					</div>
+				</div>
+
+				<div class="col-12 col-md-4">
+					<h4 class="font-weight-bold">New</h4>
+					<div class="list-group list-group-scroll">
+						<a v-for="(group, index) in newGroups"
+							class="list-group-item p-1"
+							:href="group.url">
+							<group-list-card :group="group" :compact="true" />
+						</a>
+					</div>
+				</div>
+			</div>
+
+			<div class="jumbotron mb-4 text-light bg-black" style="margin-top: 5rem;">
+            	<div class="container">
+            		<h1 class="display-4">Across the Fediverse</h1>
+                	<p class="mb-0">
+                		<button
+                			class="btn btn-outline-light"
+                			@click="toggleTab('fediverseGroups')"
+                			>
+                			Explore fediverse groups <i class="fal fa-chevron-right ml-2"></i>
+                		</button>
+                	</p>
+                	<hr class="my-4">
+                	<p class="lead">We're in the early stages of Group federation, and working with other projects to support cross-platform compatibility. <a href="#">Learn more about group federation <i class="fal fa-chevron-right ml-2 fa-sm"></i></a></p>
+            	</div>
+            </div>
+
+            <div class="row my-4 py-5">
+            	<div class="col-12 col-md-4">
+            		<div class="card card-body shadow-none bg-light" style="border:1px solid #E5E7EB;">
+            			<p class="text-center text-lighter">
+            				<i class="fal fa-lightbulb fa-4x"></i>
+            			</p>
+            			<p class="text-center lead mb-0">What's New</p>
+            		</div>
+            	</div>
+
+            	<div class="col-12 col-md-4">
+            		<div class="card card-body shadow-none bg-light" style="border:1px solid #E5E7EB;">
+            			<p class="text-center text-lighter">
+            				<i class="fal fa-clipboard-list-check fa-4x"></i>
+            			</p>
+            			<p class="text-center lead mb-0">User Guide</p>
+            		</div>
+            	</div>
+
+            	<div class="col-12 col-md-4">
+            		<div class="card card-body shadow-none bg-light" style="border:1px solid #E5E7EB;">
+            			<p class="text-center text-lighter">
+            				<i class="fal fa-question-circle fa-4x"></i>
+            			</p>
+            			<p class="text-center lead mb-0">Groups Help</p>
+            		</div>
+            	</div>
+            </div>
+
+            <p class="text-lighter" style="font-size:9px">
+            	<span class="font-weight-bold mr-1">Groups v0.0.1</span>
+            </p>
+
+			<!-- <div class="my-4 pt-5">
+				<p class="h4 font-weight-bold mb-1">Suggested for You</p>
+				<p class="lead text-muted mb-0">Groups you might be interested in</p>
+			</div>
+
+			<div class="row mb-4">
+				<div v-for="(group, index) in recommended.slice(recommendedStart, recommendedEnd)" :key="'rec:'+group.id+':'+index" class="col-12 col-md-4 slide-fade">
+					<div class="card shadow-sm border text-decoration-none text-dark">
+						<img v-if="group.metadata && group.metadata.hasOwnProperty('header')" :src="group.metadata.header.url" class="card-img-top" style="width: 100%; height: auto;object-fit: cover;max-height: 160px;">
+						<div v-else class="bg-primary" style="width:100%;height:160px;"></div>
+						<div class="card-body">
+							<div class="lead font-weight-bold d-flex align-items-top" style="height: 60px;">
+								{{ group.name }}
+								<span v-if="group.verified" class="fa-stack ml-n2 mt-n2">
+									<i class="fas fa-circle fa-stack-1x fa-lg" style="color: #22a7f0cc;font-size:18px"></i>
+									<i class="fas fa-check fa-stack-1x text-white" style="font-size:10px"></i>
+								</span>
+							</div>
+							<div class="text-muted font-weight-light d-flex justify-content-between">
+								<span>{{group.member_count}} Members</span>
+							</div>
+							<hr>
+							<p class="mb-0">
+								<a class="btn btn-light btn-block border rounded-lg font-weight-bold" :href="group.url">View Group</a>
+							</p>
+						</div>
+					</div>
+					<div v-if="index == 0 && recommended.length > 3 && recommendedStart != 0" style="position: absolute; top: 45%; left: 0px;transform:translateY(-55%);">
+						<button class="btn btn-light shadow-lg btn-lg rounded-circle border d-flex align-items-center justify-content-center" style="width: 50px;height:50px;" @click="recommendedPrev()">
+							<i class="fas fa-chevron-left fa-lg"></i>
+						</button>
+					</div>
+					<div v-if="index == 2 && recommended.length > 3" style="position: absolute; top: 45%; right: 0px;transform:translateY(-55%);">
+						<button class="btn btn-light shadow-lg btn-lg rounded-circle border d-flex align-items-center justify-content-center" style="width: 50px;height:50px;" @click="recommendedNext()">
+							<i class="fas fa-chevron-right fa-lg"></i>
+						</button>
+					</div>
+				</div>
+			</div>
+
+			<div class="py-2 mb-2">
+				<hr>
+			</div> -->
+
+			<!-- <div class="px-4 pb-4">
+				<p class="h4 font-weight-bold mb-1">Friends' Groups</p>
+				<p class="lead text-muted mb-0">Groups your mutuals are in.</p>
+			</div> -->
+
+			<!-- <div class="px-4 py-2">
+				<hr>
+			</div> -->
+
+			<!-- <div class="pb-4">
+				<p class="h4 font-weight-bold mb-1">Categories</p>
+				<p class="lead text-muted mb-0">Find a group by browsing top categories</p>
+			</div>
+
+			<div class="row mb-4">
+				<div v-for="(group, index) in categories.slice(categoriesStart, categoriesEnd)" :key="'rec:'+group.id+':'+index" class="col-12 col-md-2 slide-fade">
+					<div class="card card-body rounded-lg shadow-sm border text-decoration-none bg-primary p-2 text-white d-flex justify-content-end" style="width: 150px; height:150px;  background: linear-gradient(45deg, #ff512f, #dd2476);">
+						<p class="mb-0 font-weight-bold" style="font-size:15px">{{group}}</p>
+					</div>
+					<div v-if="index == 0 && categories.length > 3 && categoriesStart != 0" style="position: absolute; top: 50%; left: -10px;transform:translateY(-50%);">
+						<button class="btn btn-light shadow-lg btn-lg rounded-circle border d-flex align-items-center justify-content-center" style="width: 50px;height:50px;" @click="categoriesPrev()">
+							<i class="fas fa-chevron-left fa-lg"></i>
+						</button>
+					</div>
+					<div v-if="index == 5 && categories.length > 3" style="position: absolute; top: 50%; right: -10px;transform:translateY(-50%);">
+						<button class="btn btn-light shadow-lg btn-lg rounded-circle border d-flex align-items-center justify-content-center" style="width: 50px;height:50px;" @click="categoriesNext()">
+							<i class="fas fa-chevron-right fa-lg"></i>
+						</button>
+					</div>
+				</div>
+			</div> -->
+
+			<!-- <div class="py-2 mb-2">
+				<hr>
+			</div> -->
+
+			<!-- <div class="pb-4 my-4">
+				<p class="h4 font-weight-bold mb-1">My Groups</p>
+				<p class="lead text-muted mb-0">Groups you are a member of</p>
+			</div>
+
+			<div class="row mb-4">
+				<div v-for="(group, index) in selfGroups.slice(selfGroupsStart, selfGroupsEnd)" :key="'rec:'+group.id+':'+index" class="col-12 col-md-4 slide-fade">
+					<div class="card shadow-sm border text-decoration-none text-dark">
+						<img v-if="group.metadata && group.metadata.hasOwnProperty('header')" :src="group.metadata.header.url" class="card-img-top" style="width: 100%; height: auto;object-fit: cover;max-height: 160px;">
+						<div v-else class="bg-primary" style="width:100%;height:160px;"></div>
+						<div class="card-body">
+							<div class="lead font-weight-bold d-flex align-items-top" style="height: 60px;">
+								{{ group.name }}
+								<span v-if="group.verified" class="fa-stack ml-n2 mt-n2">
+									<i class="fas fa-circle fa-stack-1x fa-lg" style="color: #22a7f0cc;font-size:18px"></i>
+									<i class="fas fa-check fa-stack-1x text-white" style="font-size:10px"></i>
+								</span>
+							</div>
+							<div class="text-muted font-weight-light d-flex justify-content-between">
+								<span>{{group.member_count}} Members</span>
+							</div>
+							<hr>
+							<p class="mb-0">
+								<a class="btn btn-light btn-block border rounded-lg font-weight-bold" :href="group.url">View Group</a>
+							</p>
+						</div>
+					</div>
+					<div v-if="index == 0 && selfGroups.length > 3 && selfGroupsStart != 0" style="position: absolute; top: 50%; left: -10px;transform:translateY(-50%);">
+						<button class="btn btn-light shadow-lg btn-lg rounded-circle border d-flex align-items-center justify-content-center" style="width: 50px;height:50px;" @click="selfGroupsPrev()">
+							<i class="fas fa-chevron-left fa-lg"></i>
+						</button>
+					</div>
+					<div v-if="index == 2 && selfGroups.length > 3" style="position: absolute; top: 50%; right: -10px;transform:translateY(-50%);">
+						<button class="btn btn-light shadow-lg btn-lg rounded-circle border d-flex align-items-center justify-content-center" style="width: 50px;height:50px;" @click="selfGroupsNext()">
+							<i class="fas fa-chevron-right fa-lg"></i>
+						</button>
+					</div>
+				</div>
+			</div> -->
+		</div>
+
+		<div v-if="tab === 'categories'" class="px-5">
+			<div class="row my-4 justify-content-center">
+				<div class="col-12 col-md-6">
+					<div class="title mb-4">
+						<span>Categories</span>
+						<button class="btn btn-light font-weight-bold" @click="toggleTab('home')">Go Back</button>
+					</div>
+
+					<div class="list-group">
+						<div
+							v-for="(group, index) in categories"
+							:key="'rec:'+group.id+':'+index"
+							class="list-group-item"
+							@click="selectCategory(index)">
+							<p class="mb-0 font-weight-bold">
+								{{group}}
+								<span class="float-right">
+									<i class="fal fa-chevron-right"></i>
+								</span>
+							</p>
+						</div>
+					</div>
+				</div>
+			</div>
+		</div>
+
+		<div v-if="tab === 'category'" class="px-5">
+			<div class="row my-4 justify-content-center">
+				<div class="col-12 col-md-6">
+					<div class="title mb-4">
+						<div>
+							<div class="mb-n2 small text-uppercase text-lighter">Categories</div>
+							<span>{{ categories[activeCategoryIndex] }}</span>
+						</div>
+						<button class="btn btn-light font-weight-bold" @click="toggleTab('categories')">Go Back</button>
+					</div>
+
+					<div v-if="categoryGroupsLoaded">
+						<div class="list-group">
+							<a v-for="(group, index) in categoryGroups"
+								class="list-group-item p-1"
+								:href="group.url">
+								<group-list-card :group="group" :showStats="true" />
+							</a>
+
+							<div
+								v-if="categoryGroupsCanLoadMore"
+								class="list-group-item">
+
+								<button
+									class="btn btn-light font-weight-bold btn-block"
+									@click="fetchCategoryGroups">
+									Load more
+								</button>
+							</div>
+						</div>
+
+						<div v-if="categoryGroups.length === 0" class="mt-3">
+							<div class="bg-white border text-center p-3">
+								<p class="font-weight-light mb-0">No groups found in this category</p>
+							</div>
+						</div>
+					</div>
+					<div v-else>
+						<div class="card card-body shadow-none border justify-content-center flex-row">
+							<b-spinner />
+						</div>
+					</div>
+				</div>
+			</div>
+		</div>
+
+		<div v-if="tab === 'fediverseGroups'" class="px-5">
+			<div class="row my-4 justify-content-center">
+				<div class="col-12 col-md-6">
+					<div class="title mb-4">
+						<span>Fediverse Groups</span>
+						<button class="btn btn-light font-weight-bold" @click="toggleTab('home')">Go Back</button>
+					</div>
+					<div class="mt-3">
+						<div class="bg-white border text-center p-3">
+							<p class="font-weight-light mb-0">No fediverse groups found</p>
+						</div>
+					</div>
+				</div>
+			</div>
+		</div>
+	</div>
+</template>
+
+<script type="text/javascript">
+	import GroupListCard from './GroupListCard.vue';
+
+	export default {
+		props: {
+			profile: {
+				type: Object
+			}
+		},
+
+		components: {
+			"group-list-card": GroupListCard
+		},
+
+		data() {
+			return {
+				isLoaded: false,
+				tab: 'home',
+				popularGroups: [],
+				newGroups: [],
+				activeCategoryIndex: undefined,
+				activeCategoryPage: 1,
+				categories: [],
+				categoriesStart: 0,
+				categoriesEnd: 6,
+				categoryGroups: [],
+				categoryGroupsLoaded: false,
+				categoryGroupsCanLoadMore: false,
+				// selfGroups: [],
+				// selfGroupsStart: 0,
+				// selfGroupsEnd: 3
+			}
+		},
+
+		mounted() {
+			this.fetchPopular();
+			this.fetchCategories();
+			// this.fetchSelf();
+		},
+
+		methods: {
+			fetchPopular() {
+				axios.get('/api/v0/groups/discover/popular')
+				.then(res => {
+					this.popularGroups = res.data;
+					this.fetchNew();
+				})
+			},
+
+			fetchNew() {
+				axios.get('/api/v0/groups/discover/new')
+				.then(res => {
+					this.newGroups = res.data;
+				})
+			},
+
+			fetchCategories() {
+				axios.get('/api/v0/groups/categories/list')
+				.then(res => {
+					this.categories = res.data;
+				})
+			},
+
+			toggleTab(tab) {
+				window.scrollTo(0, 0);
+				this.tab = tab;
+			},
+
+			selectCategory(index) {
+				window.scrollTo(0, 0);
+				if(index !== this.activeCategoryIndex) {
+					this.activeCategoryPage = 1;
+				}
+				this.activeCategoryIndex = index;
+				this.fetchCategoryGroups();
+			},
+
+			fetchCategoryGroups() {
+				if(this.activeCategoryPage == 1) {
+					this.categoryGroupsLoaded = false;
+				}
+
+				axios.get('/api/v0/groups/category/list', {
+					params: {
+						name: this.categories[this.activeCategoryIndex],
+						page: this.activeCategoryPage
+					}
+				})
+				.then(res => {
+					this.tab = 'category';
+					if(this.activeCategoryPage == 1) {
+						this.categoryGroups = res.data;
+					} else {
+						this.categoryGroups.push(...res.data);
+					}
+					if(res.data.length == 6) {
+						this.categoryGroupsCanLoadMore = true;
+						this.activeCategoryPage++;
+					} else {
+						this.categoryGroupsCanLoadMore = false;
+					}
+					setTimeout(() => {
+						this.categoryGroupsLoaded = true;
+					}, 600);
+				})
+			}
+		}
+	}
+</script>
+
+<style lang="scss">
+	.self-discover-component {
+		.list-group-item {
+			text-decoration: none;
+
+			&:hover {
+				background-color: #F3F4F6;
+			}
+		}
+
+		.bg-mantle {
+			background: linear-gradient(45deg, #24c6dc, #514a9d);
+		}
+
+		.bg-black {
+			background-color: #000;
+
+			hr {
+				border-top: 1px solid rgba(255, 255, 255, 0.12);
+			}
+		}
+
+		.title {
+			display: flex;
+			justify-content: space-between;
+			align-items: center;
+
+			span {
+				font-size: 24px;
+				font-weight: 600;
+			}
+
+			.btn {
+				border: 1px solid #E5E7EB;
+			}
+		}
+	}
+</style>

+ 146 - 0
resources/assets/components/groups/partials/SelfFeed.vue

@@ -0,0 +1,146 @@
+<template>
+	<div class="col-12 col-md-9" style="overflow:hidden">
+		<div class="row h-100 bg-light justify-content-center">
+			<div class="col-12 col-md-10 col-lg-7">
+				<div v-if="!initalLoad">
+					<p class="text-center mt-5 pt-5 font-weight-bold">Loading...</p>
+				</div>
+				<div v-else class="px-5">
+					<div v-if="emptyFeed">
+						<div class="jumbotron mt-5">
+							<h1 class="display-4">Hello 👋</h1>
+							<h3 class="font-weight-light">Welcome to Pixelfed Groups!</h3>
+							<p class="lead">Groups are a way to participate in like minded communities and topics.</p>
+							<hr class="my-4">
+							<p>Anyone can create and manage their own group as long as it abides by our <a href="#">community guidelines</a>.</p>
+							<p class="text-center mb-0">
+                                <router-link to="/groups/discover" class="btn btn-primary btn-lg rounded-pill">
+                                    Discover Groups
+                                </router-link>
+							</p>
+						</div>
+					</div>
+
+					<div v-else>
+						<div class="my-4">
+		                    <p class="h1 font-weight-bold mb-1">Groups Feed</p>
+		                    <p class="lead text-muted mb-0">Recent posts from your groups</p>
+		                </div>
+
+						<div class="my-3">
+							<group-status
+								v-for="(status, index) in feed"
+								:key="'gs:' + status.id + index"
+								:prestatus="status"
+								:profile="profile"
+								:show-group-header="true"
+								:group="status.group"
+								:group-id="status.group.id" />
+
+							<div v-if="feed.length > 2">
+								<infinite-loading @infinite="infiniteFeed" :distance="800">
+									<div slot="no-more" class="my-3">
+										<p class="lead font-weight-bold pt-5">You have reached the end of this feed</p>
+										<div style="height: 10rem;"></div>
+									</div>
+									<div slot="no-results"></div>
+								</infinite-loading>
+							</div>
+						</div>
+					</div>
+				</div>
+			</div>
+			<!-- <div class="col-12 col-md-4">
+				<div class="mt-5 media align-items-center">
+					<div class="mr-3">
+						<i class="fas fa-info-circle fa-2x text-lighter"></i>
+					</div>
+					<p class="media-body text-muted mb-0 font-weight-light">Groups are in beta, some <a href="#" class="font-weight-bold">limitations</a> apply.</p>
+				</div>
+			</div> -->
+		</div>
+	</div>
+</template>
+
+<script type="text/javascript">
+	import GroupStatus from './GroupStatus.vue';
+
+	export default {
+		props: {
+			profile: {
+				type: Object
+			}
+		},
+
+		data() {
+			return {
+				feed: [],
+				ids: [],
+				page: 1,
+				tab: 'feed',
+				initalLoad: false,
+				emptyFeed: true
+			};
+		},
+
+		components: {
+			'group-status': GroupStatus
+		},
+
+		mounted() {
+			this.fetchFeed();
+		},
+
+		methods: {
+			fetchFeed() {
+				axios.get('/api/v0/groups/self/feed', {
+					params: {
+						initial: true
+					}
+				})
+				.then(res => {
+					this.page++;
+					this.feed = res.data;
+					this.emptyFeed = this.feed.length === 0;
+					this.initalLoad = true;
+				})
+			},
+
+			infiniteFeed($state) {
+				if(this.feed.length < 2 || this.page > 5) {
+					$state.complete();
+					return;
+				}
+
+				axios.get('/api/v0/groups/self/feed', {
+					params: {
+						page: this.page
+					},
+				}).then(res => {
+					if (res.data.length) {
+						let data = res.data;
+						let self = this;
+						data.forEach(d => {
+							if(self.ids.indexOf(d.id) == -1) {
+								self.ids.push(d.id);
+								self.feed.push(d);
+							}
+						});
+						$state.loaded();
+						this.page++;
+					} else {
+						$state.complete();
+					}
+				});
+			},
+
+			switchTab(tab) {
+				this.tab = tab;
+			},
+
+			gotoDiscover() {
+				this.$emit('switchtab', 'discover');
+			}
+		}
+	}
+</script>

+ 171 - 0
resources/assets/components/groups/partials/SelfGroups.vue

@@ -0,0 +1,171 @@
+<template>
+    <div class="my-groups-component">
+        <div class="list-container">
+            <div v-if="isLoaded">
+                <div class="list-group">
+                    <a
+                        v-for="(group, index) in groups" :key="'rec:'+group.id+':'+index"
+                        class="list-group-item text-decoration-none"
+                        :href="group.url">
+                        <group-list-card
+                        	:group="group"
+                        	:truncateDescriptionLength="140"
+                        	:showStats="true" />
+                        <!-- <div class="media align-items-center">
+                            <img v-if="group.metadata && group.metadata.hasOwnProperty('header')" :src="group.metadata.header.url" class="mr-3 border rounded" style="width: 100px; height: 60px;object-fit: cover;padding:5px;">
+
+                            <div v-else class="mr-3 border rounded" style="width: 100px; height: 60px;padding: 5px;">
+                            	<div class="bg-primary d-flex align-items-center justify-content-center" style="width: 100%; height:100%;">
+                            		<i class="fal fa-users text-white fa-lg"></i>
+                            	</div>
+                            </div>
+
+                            <div class="media-body">
+                            	<p class="h5 font-weight-bold mb-1 text-dark">
+                            		{{ group.name || 'Untitled Group' }}
+                            	</p>
+
+                            	<p class="text-muted small mb-1 read-more">
+                            		{{ truncate(group.description) }}
+                            	</p>
+
+                            	<p class="mb-0">
+                            		<span class="text-muted mr-2">
+                            			<i class="far fa-users"></i>
+                            			<strong class="small">{{ prettyCount(group.member_count) }}</strong>
+                            		</span>
+
+                            		<span class="member-label">
+                            			{{ group.self.role }}
+                            		</span>
+
+                            		<span v-if="!group.local" class="remote-label ml-2">
+                            			<i class="fal fa-globe"></i> Remote
+                            		</span>
+                            	</p>
+                            </div>
+                        </div> -->
+                    </a>
+                </div>
+
+                <p v-if="canLoadMore">
+                	<button
+                		class="btn btn-primary btn-block font-weight-bold mt-3"
+                		@click.prevent="loadMore"
+                		:disabled="loadingMore">
+                		Load more
+                	</button>
+                </p>
+            </div>
+
+            <div v-else class="d-flex justify-content-center">
+            	<b-spinner/>
+            </div>
+        </div>
+    </div>
+</template>
+
+<script type="text/javascript">
+	import GroupListCard from './GroupListCard.vue';
+
+    export default {
+        props: {
+            profile: {
+                type: Object
+            }
+        },
+
+        components: {
+			"group-list-card": GroupListCard
+		},
+
+        data() {
+            return {
+                isLoaded: false,
+                groups: [],
+                canLoadMore: false,
+                loadingMore: false,
+                page: 1
+            }
+        },
+
+        mounted() {
+            this.fetchSelf();
+        },
+
+        methods: {
+            fetchSelf() {
+                axios.get('/api/v0/groups/self/list')
+                .then(res => {
+                	let data = res.data.filter(g => {
+                		return g.hasOwnProperty('id') && g.hasOwnProperty('url');
+                	})
+                    this.groups = data;
+                    this.canLoadMore = res.data.length == 4;
+                    this.page++;
+                    this.isLoaded = true;
+                });
+            },
+
+            loadMore() {
+            	this.loadingMore = true;
+            	axios.get('/api/v0/groups/self/list', {
+            		params: {
+            			page: this.page
+            		}
+            	})
+                .then(res => {
+                	let data = res.data.filter(g => {
+                		return g.hasOwnProperty('id') && g.hasOwnProperty('url');
+                	})
+                    this.groups.push(...data);
+                    this.canLoadMore = res.data.length == 4;
+                    this.page++;
+            		this.loadingMore = false;
+                });
+            },
+
+            prettyCount(val) {
+            	return App.util.format.count(val);
+            },
+
+            truncate(str) {
+            	if(str.length <= 140) {
+            		return str;
+            	}
+
+            	return str.substr(0, 140) + ' ...';
+            }
+        }
+    }
+</script>
+
+<style lang="scss" scoped>
+    .my-groups-component {
+    	.list-container {
+    		margin-bottom: 30vh;
+    	}
+
+		.member-label {
+			padding: 2px 5px;
+			font-size: 12px;
+			color: rgba(75, 119, 190, 1);
+			background:rgba(137, 196, 244, 0.2);
+			border:1px solid rgba(137, 196, 244, 0.3);
+			font-weight:400;
+			text-transform: capitalize;
+			border-radius: 3px;
+		}
+
+		.remote-label {
+			padding: 2px 5px;
+			font-size: 12px;
+			color: #4B5563;
+			background: #F3F4F6;
+			border:1px solid #E5E7EB;
+			font-weight:400;
+			text-transform: capitalize;
+			border-radius: 3px;
+		}
+    }
+</style>

+ 41 - 0
resources/assets/components/groups/partials/SelfInvitations.vue

@@ -0,0 +1,41 @@
+<template>
+	<div class="col-12 col-md-9" style="height: 100vh - 51px !important;overflow:hidden">
+		<div class="row h-100">
+			<div class="col-12 col-md-8 bg-lighter border-left">
+				<div class="p-4 mb-4">
+					<p class="h4 font-weight-bold mb-1 text-center">Group Invitations</p>
+				</div>
+				<div class="p-4 mb-4">
+					<p class="font-weight-bold text-center text-muted">You don't have any group invites</p>
+				</div>
+			</div>
+			<div class="col-12 col-md-4 bg-white border-left">
+				<div class="p-4">
+					<div class="bg-light rounded-lg border p-3">
+						<p class="lead font-weight-bold mb-0">Send Invite</p>
+						<p class="mb-3">Invite friends to your groups</p>
+						<div class="form-group" style="position: relative;">
+							<span style="position: absolute; top:50%;transform: translateY(-50%);left:15px;padding-right:5px;">
+								<i class="fas fa-search text-lighter"></i>
+							</span>
+							<input class="form-control bg-white rounded-pill" placeholder="Search username..." style="padding-left:40px">
+						</div>
+					</div>
+				</div>
+				<hr>
+				<div class="p-4 mb-2">
+					<p class="h4 font-weight-bold mb-1 text-center">Invitations Sent</p>
+				</div>
+				<div class="px-4 mb-4">
+					<p class="font-weight-bold text-center text-muted">You have not sent any group invites</p>
+				</div>
+			</div>
+		</div>
+	</div>
+</template>
+
+<script type="text/javascript">
+	export default {
+
+	}
+</script>

+ 309 - 0
resources/assets/components/groups/partials/SelfNotifications.vue

@@ -0,0 +1,309 @@
+<template>
+	<div class="group-notification-component col-12 col-md-9" style="height: 100vh - 51px !important;overflow:hidden">
+		<div class="row h-100 bg-white">
+			<div class="col-12 col-md-8 border-left">
+                <div class="px-5">
+					<div class="my-4">
+						<p class="h1 font-weight-bold mb-1">Group Notifications</p>
+						<!-- <p class="lead text-muted mb-0">Latest notifications from your groups</p> -->
+					</div>
+					<!-- <div class="p-4 mb-4">
+						<p class="font-weight-bold text-center text-muted">You don't have any notifications</p>
+					</div> -->
+
+					<div v-if="notifications.length > 0" v-for="(n, index) in notifications" class="nitem card card-body shadow-none mb-3 py-2 px-0 rounded-pill" style="background-color: #F3F4F6">
+						<div class="media align-items-center px-3">
+							<img class="mr-3 rounded-circle" style="border:1px solid #ccc" :src="n.account.avatar" alt="" width="32px" height="32px">
+							<div class="media-body">
+								<div v-if="n.type == 'group:like'">
+									<p class="my-0">
+										<a :href="getProfileUrl(n.account)" data-placement="bottom" data-toggle="tooltip" :title="n.account.username">{{n.account.local == false ? '@':''}}{{truncate(n.account.username)}}</a> liked your <a v-bind:href="getPostUrl(n.status)">post</a> in <a :href="n.group.url">{{n.group.name}}</a>
+									</p>
+								</div>
+
+								<div v-else-if="n.type == 'group:comment'">
+									<p class="my-0">
+										<a :href="getProfileUrl(n.account)" class="font-weight-bold text-dark word-break" :title="n.account.username">{{n.account.local == false ? '@':''}}{{truncate(n.account.username)}}</a> commented on your <a class="font-weight-bold" v-bind:href="n.status.url">post</a> in <a :href="n.group.url">{{n.group.name}}</a>
+									</p>
+								</div>
+
+								<div v-else-if="n.type == 'mention'">
+									<p class="my-0">
+										<a :href="getProfileUrl(n.account)" data-placement="bottom" data-toggle="tooltip" :title="n.account.username">{{n.account.local == false ? '@':''}}{{truncate(n.account.username)}}</a> <a v-bind:href="mentionUrl(n.status)">mentioned</a> you.
+									</p>
+								</div>
+
+								<div v-else-if="n.type == 'group.join.approved'">
+									<p class="my-0">
+										Your application to join <a :href="n.group.url" class="font-weight-bold text-dark word-break" :title="n.group.name">{{truncate(n.group.name)}}</a> was approved!
+									</p>
+								</div>
+
+								<div v-else-if="n.type == 'group.join.rejected'">
+									<p class="my-0">
+										Your application to join <a :href="n.group.url" class="font-weight-bold text-dark word-break" :title="n.group.name">{{truncate(n.group.name)}}</a> was rejected. You can re-apply to join in 6 months.
+									</p>
+								</div>
+
+								<div v-else>
+									<p class="my-0">Cannot display notification</p>
+								</div>
+
+								<!-- <div class="align-items-center">
+									<span class="small text-muted" data-toggle="tooltip" data-placement="bottom" :title="n.created_at">{{timeAgo(n.created_at)}}</span>
+								</div> -->
+							</div>
+							<div>
+								<!-- <div v-if="n.status && n.status && n.status.media_attachments && n.status.media_attachments.length">
+									<a :href="getPostUrl(n.status)">
+										<img :src="n.status.media_attachments[0].preview_url" width="32px" height="32px">
+									</a>
+								</div>
+								<div v-else-if="n.status && n.status.parent && n.status.parent.media_attachments && n.status.parent.media_attachments.length">
+									<a :href="n.status.parent.url">
+										<img :src="n.status.parent.media_attachments[0].preview_url" width="32px" height="32px">
+									</a>
+								</div>
+
+								<div v-else>
+									<a v-if="viewContext(n) != '/'" class="btn btn-outline-primary py-0 font-weight-bold" :href="viewContext(n)">View</a>
+								</div> -->
+
+								<div class="align-items-center text-muted">
+									<span class="small" data-toggle="tooltip" data-placement="bottom" :title="n.created_at">{{timeAgo(n.created_at)}}</span>
+									<span>·</span>
+									<div class="dropdown d-inline">
+										<a class="dropdown-toggle text-lighter" href="#" role="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
+											<i class="far fa-cog fa-sm"></i>
+										</a>
+
+										<div class="dropdown-menu dropdown-menu-right">
+											<a class="dropdown-item font-weight-bold" href="#">Dismiss</a>
+											<div class="dropdown-divider"></div>
+											<a class="dropdown-item font-weight-bold" href="#">Help</a>
+											<!-- <a class="dropdown-item font-weight-bold" href="#">Ignore</a> -->
+											<a class="dropdown-item font-weight-bold" href="#">Report</a>
+										</div>
+									</div>
+								</div>
+							</div>
+						</div>
+					</div>
+				</div>
+			</div>
+			<div class="col-12 col-md-4 border-left bg-light">
+			</div>
+		</div>
+	</div>
+</template>
+
+<script type="text/javascript">
+	export default {
+		data() {
+			return {
+				notifications: [],
+				initialLoad: false,
+				loading: true,
+				page: 1
+			}
+		},
+
+		mounted() {
+			this.fetchNotifications();
+		},
+
+		methods: {
+			fetchNotifications() {
+				axios.get('/api/pixelfed/v1/accounts/verify_credentials').then(res => {
+						window._sharedData.curUser = res.data;
+						window.App.util.navatar();
+				});
+				axios.get('/api/v0/groups/self/notifications')
+				.then(res => {
+					let data = res.data.filter(n => {
+						if(n.type == 'share' && !n.status) {
+							return false;
+						}
+						if(n.type == 'comment' && !n.status) {
+							return false;
+						}
+						if(n.type == 'mention' && !n.status) {
+							return false;
+						}
+						if(n.type == 'favourite' && !n.status) {
+							return false;
+						}
+						if(n.type == 'follow' && !n.account) {
+							return false;
+						}
+						return true;
+					});
+					// let ids = res.data.map(n => n.id);
+					// this.notificationMaxId = Math.max(...ids);
+					this.notifications = data;
+					// $('.notification-card .loader').addClass('d-none');
+					// $('.notification-card .contents').removeClass('d-none');
+				});
+			},
+
+			// infiniteNotifications($state) {
+			// 	if(this.notificationCursor > 10) {
+			// 		$state.complete();
+			// 		return;
+			// 	}
+			// 	axios.get('/api/pixelfed/v1/notifications', {
+			// 		params: {
+			// 			max_id: this.notificationMaxId
+			// 		}
+			// 	}).then(res => {
+			// 		if(res.data.length) {
+			// 			let data = res.data.filter(n => {
+			// 				if(n.type == 'share' && !n.status) {
+			// 					return false;
+			// 				}
+			// 				if(n.type == 'comment' && !n.status) {
+			// 					return false;
+			// 				}
+			// 				if(n.type == 'mention' && !n.status) {
+			// 					return false;
+			// 				}
+			// 				if(n.type == 'favourite' && !n.status) {
+			// 					return false;
+			// 				}
+			// 				if(n.type == 'follow' && !n.account) {
+			// 					return false;
+			// 				}
+			// 				if(_.find(this.notifications, {id: n.id})) {
+			// 					return false;
+			// 				}
+			// 				return true;
+			// 			});
+			// 			this.notifications.push(...data);
+			// 			this.notificationCursor++;
+			// 			$state.loaded();
+			// 		} else {
+			// 			$state.complete();
+			// 		}
+			// 	});
+			// },
+
+			truncate(text) {
+				if(text.length <= 15) {
+					return text;
+				}
+
+				return text.slice(0,15) + '...'
+			},
+
+			timeAgo(ts) {
+				let date = Date.parse(ts);
+				let seconds = Math.floor((new Date() - date) / 1000);
+				let interval = Math.floor(seconds / 31536000);
+				if (interval >= 1) {
+					return interval + "y";
+				}
+				interval = Math.floor(seconds / 604800);
+				if (interval >= 1) {
+					return interval + "w";
+				}
+				interval = Math.floor(seconds / 86400);
+				if (interval >= 1) {
+					return interval + "d";
+				}
+				interval = Math.floor(seconds / 3600);
+				if (interval >= 1) {
+					return interval + "h";
+				}
+				interval = Math.floor(seconds / 60);
+				if (interval >= 1) {
+					return interval + "m";
+				}
+				return Math.floor(seconds) + "s";
+			},
+
+			mentionUrl(status) {
+				let username = status.account.username;
+				let id = status.id;
+				return '/p/' + username + '/' + id;
+			},
+
+			followProfile(n) {
+				let self = this;
+				let id = n.account.id;
+				axios.post('/i/follow', {
+						item: id
+				}).then(res => {
+					self.notifications.map(notification => {
+						if(notification.account.id === id) {
+							notification.relationship.following = true;
+						}
+					});
+				}).catch(err => {
+					if(err.response.data.message) {
+						swal('Error', err.response.data.message, 'error');
+					}
+				});
+			},
+
+			viewContext(n) {
+				switch(n.type) {
+					case 'follow':
+						return n.account.url;
+					break;
+					case 'mention':
+						return n.status.url;
+					break;
+					case 'like':
+					case 'favourite':
+					case 'comment':
+						return n.status.url;
+					break;
+					case 'tagged':
+						return n.tagged.post_url;
+					break;
+					case 'direct':
+						return '/account/direct/t/'+n.account.id;
+					break
+				}
+				return '/';
+			},
+
+			getProfileUrl(account) {
+				if(account.local == true) {
+					return account.url;
+				}
+
+				return '/i/web/profile/_/' + account.id;
+			},
+
+			getPostUrl(status) {
+				if(status.local == true) {
+					return status.url;
+				}
+
+				return '/i/web/post/_/' + status.account.id + '/' + status.id;
+			}
+		}
+	}
+</script>
+
+<style lang="scss">
+	.group-notification-component {
+		.dropdown-toggle::after {
+			content: '';
+			display: none;
+		}
+
+		.nitem {
+			a {
+				color: #000;
+				font-weight: 700 !important;
+
+				&:hover,
+				&:focus {
+    				color: #121416 !important;
+    			}
+			}
+		}
+	}
+</style>

+ 47 - 0
resources/assets/components/groups/partials/SelfRemoteSearch.vue

@@ -0,0 +1,47 @@
+<template>
+	<div class="col-12 col-md-9" style="height: 100vh - 51px !important;overflow:hidden">
+		<div class="row h-100 bg-lighter">
+			<div class="col-12 col-md-8 border-left">
+				<div class="d-flex justify-content-center">
+					<div class="p-4 mb-4">
+						<p class="h4 font-weight-bold mb-1">Find a Remote Group</p>
+						<p class="lead text-muted">Search and explore remote federated groups.</p>
+					</div>
+				</div>
+				<div class="mb-5">
+					<div class="p-4 mb-4">
+						<div class="form-group">
+							<label>Group URL</label>
+							<input type="text" class="form-control form-control-lg rounded-pill bg-white border" placeholder="https://pixelfed.social/groups/328323406233735168" v-model="q">
+						</div>
+						<button class="btn btn-primary btn-block btn-lg rounded-pill font-weight-bold">Search</button>
+					</div>
+				</div>
+			</div>
+			<div class="col-12 col-md-4 bg-white border-left">
+				<div class="my-4">
+					<h4 class="font-weight-bold">Tips</h4>
+					<ul class="pl-3">
+						<li class="font-weight-bold">Some remote groups are not supported*</li>
+						<li>Read and comply with group rules defined by group admins</li>
+						<li>Use the full <span class="font-weight-bold">Group URL</span> including <code>https://</code></li>
+						<li>Joining private groups requires manual approval from group admins, you will recieve a notification when your membership is approved</li>
+						<li>Inviting people to remote groups is not supported yet</li>
+						<li>Your group membership may be terminated at any time by group admins</li>
+					</ul>
+					<p class="small">* Some remote groups may not be compatible, we are working to support other group implementations</p>
+				</div>
+			</div>
+		</div>
+	</div>
+</template>
+
+<script type="text/javascript">
+	export default {
+		data() {
+			return {
+				q: undefined
+			}
+		}
+	}
+</script>

+ 11 - 0
resources/assets/components/groups/partials/ShareMenu.vue

@@ -0,0 +1,11 @@
+<template>
+	<div class="share-menu-component">
+
+	</div>
+</template>
+
+<script type="text/javascript">
+	export default {
+
+	}
+</script>

+ 304 - 0
resources/assets/components/groups/partials/Status/GroupHeader.vue

@@ -0,0 +1,304 @@
+<template>
+    <div class="group-post-header media">
+        <div v-if="showGroupHeader" style="position: relative;" class="mb-1">
+            <img
+                v-if="group.hasOwnProperty('metadata') && (group.metadata.hasOwnProperty('avatar') || group.metadata.hasOwnProperty('header'))"
+                class="rounded-lg box-shadow mr-2"
+                :src="group.metadata.hasOwnProperty('header') ? group.metadata.header.url : group.metadata.avatar.url"
+                width="52"
+                height="52"
+                onerror="this.onerror=null;this.src='/storage/avatars/default.png?v=2'"
+                alt="avatar"
+                style="object-fit:cover;"
+            />
+            <span
+                v-else
+                class="d-block rounded-lg box-shadow mr-2 bg-primary"
+                style="width: 52px;height:52px;"
+            ></span>
+            <img
+                class="rounded-circle box-shadow border mr-2"
+                :src="status.account.avatar"
+                width="36"
+                height="36"
+                onerror="this.onerror=null;this.src='/storage/avatars/default.png?v=2'"
+                alt="avatar"
+                style="position: absolute; bottom:-4px; right:-4px;"
+            />
+        </div>
+        <img
+            v-else
+            class="rounded-circle box-shadow mr-2"
+            :src="status.account.avatar"
+            width="42"
+            height="42"
+            onerror="this.onerror=null;this.src='/storage/avatars/default.png?v=2'"
+            alt="avatar"
+        />
+        <div class="media-body">
+            <div class="pl-2 d-flex align-items-top">
+                <div>
+                    <p class="mb-0">
+                        <router-link
+                            v-if="showGroupHeader && group"
+                            :to="`/groups/${status.gid}`"
+                            class="group-name-link username"
+                        >
+                            {{ group.name }}
+                        </router-link>
+
+                        <router-link
+                            v-else
+                            :to="`/groups/${status.gid}/user/${status?.account.id}`"
+                            class="group-name-link username"
+                            v-html="statusCardUsernameFormat(status)"
+                        >
+                            Loading...
+                        </router-link>
+
+                        <span v-if="showGroupChevron">
+                            <span class="text-muted" style="padding-left:2px;padding-right:2px;">
+                                <i class="fas fa-caret-right"></i>
+                            </span>
+                            <span>
+                                <router-link
+                                    :to="`/groups/${status.gid}`"
+                                    class="group-name-link"
+                                >
+                                    {{ group.name }}
+                                </router-link>
+                            </span>
+                        </span>
+                    </p>
+                    <p class="mb-0 mt-n1">
+                        <span
+                            v-if="showGroupHeader && group"
+                            style="font-size:13px"
+                        >
+                            <router-link
+                                :to="`/groups/${status.gid}/user/${status?.account.id}`"
+                                class="group-name-link-small username"
+                                v-html="statusCardUsernameFormat(status)"
+                            >
+                                Loading...
+                            </router-link>
+                            <span class="text-lighter" style="padding-left:2px;padding-right:2px;">·</span>
+                            <router-link
+                                :to="`/groups/${status.gid}/p/${status.id}`"
+                                class="font-weight-light text-muted"
+                            >
+                                {{shortTimestamp(status.created_at)}}
+                            </router-link>
+                            <span class="text-lighter" style="padding-left:2px;padding-right:2px;">·</span>
+                            <span class="text-muted"><i class="fas fa-globe"></i></span>
+                        </span>
+                        <span v-else>
+                            <router-link
+                                :to="`/groups/${status.gid}/p/${status.id}`"
+                                class="font-weight-light text-muted small"
+                            >
+                                {{shortTimestamp(status.created_at)}}
+                            </router-link>
+                            <span class="text-lighter" style="padding-left:2px;padding-right:2px;">·</span>
+                            <span class="text-muted small"><i class="fas fa-globe"></i></span>
+                        </span>
+                    </p>
+                </div>
+                <div v-if="profile" class="text-right" style="flex-grow:1;">
+                    <div class="dropdown">
+                      <button class="btn btn-link dropdown-toggle" type="button" data-toggle="dropdown" aria-expanded="false">
+                        <span class="fas fa-ellipsis-h text-lighter"></span>
+                      </button>
+                      <div class="dropdown-menu  dropdown-menu-right">
+                        <a class="dropdown-item" href="#">View Post</a>
+                        <a class="dropdown-item" href="#">View Profile</a>
+                        <a class="dropdown-item" href="#">Copy Link</a>
+                        <a class="dropdown-item" href="#" @click.prevent="sendReport()">Report</a>
+                        <div class="dropdown-divider"></div>
+                        <a class="dropdown-item text-danger" href="#">Delete</a>
+                      </div>
+                    </div>
+                    <!-- <button class="btn btn-link text-dark py-0" type="button" @click="ctxMenu()">
+                        <span class="fas fa-ellipsis-h text-lighter"></span>
+                        <span class="sr-only">Post Menu</span>
+                    </button> -->
+                </div>
+            </div>
+        </div>
+    </div>
+</template>
+
+<script>
+    export default {
+        props: {
+            group: {
+                type: Object
+            },
+            status: {
+                type: Object
+            },
+            profile: {
+                type: Object
+            },
+            showGroupHeader: {
+                type: Boolean,
+                default: false
+            },
+            showGroupChevron: {
+                type: Boolean,
+                default: false
+            }
+        },
+
+        data() {
+            return {
+                reportTypes: [
+                    { key: "spam", title: "It's spam" },
+                    { key: "sensitive", title: "Nudity or sexual activity" },
+                    { key: "abusive", title: "Bullying or harassment" },
+                    { key: "underage", title: "I think this account is underage" },
+                    { key: "violence", title: "Violence or dangerous organizations" },
+                    { key: "copyright", title: "Copyright infringement" },
+                    { key: "impersonation", title: "Impersonation" },
+                    { key: "scam", title: "Scam or fraud" },
+                    { key: "terrorism", title: "Terrorism or terrorism-related content" }
+                ]
+            }
+        },
+
+        methods: {
+            formatCount(count) {
+                return App.util.format.count(count);
+            },
+
+            statusUrl(status) {
+                return '/groups/' + status.gid + '/p/' + status.id;
+            },
+
+            profileUrl(status) {
+                return '/groups/' + status.gid + '/user/' + status.account.id;
+            },
+
+            timestampFormat(timestamp) {
+                let ts = new Date(timestamp);
+                return ts.toDateString() + ' ' + ts.toLocaleTimeString();
+            },
+
+            shortTimestamp(ts) {
+                return window.App.util.format.timeAgo(ts);
+            },
+
+            statusCardUsernameFormat(status) {
+                if(status.account.local == true) {
+                    return status.account.username;
+                }
+
+                let fmt = window.App.config.username.remote.format;
+                let txt = window.App.config.username.remote.custom;
+                let usr = status.account.username;
+                let dom = document.createElement('a');
+                dom.href = status.account.url;
+                dom = dom.hostname;
+
+                switch(fmt) {
+                    case '@':
+                    return usr + '<span class="text-lighter font-weight-bold">@' + dom + '</span>';
+                    break;
+
+                    case 'from':
+                    return usr + '<span class="text-lighter font-weight-bold"> <span class="font-weight-normal">from</span> ' + dom + '</span>';
+                    break;
+
+                    case 'custom':
+                    return usr + '<span class="text-lighter font-weight-bold"> ' + txt + ' ' + dom + '</span>';
+                    break;
+
+                    default:
+                    return usr + '<span class="text-lighter font-weight-bold">@' + dom + '</span>';
+                    break;
+                }
+            },
+
+            sendReport(type) {
+                let el = document.createElement('div');
+                el.classList.add('list-group');
+                this.reportTypes.forEach(rt => {
+                    let button = document.createElement('button');
+                    button.classList.add('list-group-item', 'small');
+                    button.innerHTML = rt.title;
+                    button.onclick = () => {
+                        document.dispatchEvent(new CustomEvent('reportOption', {
+                            detail: { key: rt.key, title: rt.title }
+                        }));
+                    };
+                    el.appendChild(button);
+                });
+
+                let wrapper = document.createElement('div');
+                wrapper.appendChild(el);
+
+                swal({
+                    title: "Report Content",
+                    icon: "warning",
+                    content: wrapper,
+                    buttons: false
+                });
+
+                document.addEventListener('reportOption', (event) => {
+                    console.log(event.detail);
+                    this.showConfirmation(event.detail);
+                }, { once: true });
+            },
+            showConfirmation(option) {
+                console.log(option)
+              swal({
+                title: "Confirmation",
+                text: `You selected ${option.title}. Do you want to proceed?`,
+                icon: "info",
+                buttons: true
+              }).then((confirm) => {
+                if (confirm) {
+                     axios.post(`/api/v0/groups/${this.status.gid}/report/create`, {
+                        'type': option.key,
+                        'id': this.status.id,
+                    }).then(res => {
+                        swal("Confirmed!", "Your report has been submitted.", "success");
+                    })
+                } else {
+                  swal("Cancelled", "Your report was not submitted.", "error");
+                }
+              });
+            }
+        }
+    }
+</script>
+
+<style lang="scss" scoped>
+.group-post-header {
+    .btn::focus {
+        box-shadow: none;
+    }
+
+    .dropdown-toggle::after {
+        display: none;
+    }
+
+    .group-name-link {
+        color: var(--body-color) !important;
+        word-break: break-word !important;
+        word-wrap: break-word !important;
+        text-decoration: none;
+        font-size: 16px;
+        font-weight: 600;
+    }
+
+    .group-name-link-small {
+        color: var(--body-color) !important;
+        word-break: break-word !important;
+        word-wrap: break-word !important;
+        text-decoration: none;
+        font-size: 14px;
+        font-weight: 600;
+    }
+}
+</style>

+ 58 - 0
resources/assets/components/groups/partials/Status/ParentUnavailable.vue

@@ -0,0 +1,58 @@
+<template>
+    <div class="card shadow-sm" style="border-radius: 18px !important;">
+        <div class="card-body pb-0">
+            <div class="card card-body border shadow-none mb-3" style="background-color: #E5E7EB;">
+                <div class="media p-md-4">
+                    <div class="mr-4 pt-2">
+                        <i class="fas fa-lock fa-2x"></i>
+                    </div>
+                    <div class="media-body" style="max-width: 320px">
+                        <p class="lead font-weight-bold mb-1">This content isn't available right now</p>
+                        <p class="mb-0" style="font-size: 12px;letter-spacing:-0.3px;">When this happens, it's usually because the owner only shared it with a small group of people, changed who can see it, or it's been deleted.</p>
+                    </div>
+                </div>
+            </div>
+            <div>
+                <comment-drawer
+                    v-if="showCommentDrawer"
+                    :permalink-mode="permalinkMode"
+                    :permalink-status="childContext"
+                    :status="status"
+                    :profile="profile"
+                    :group-id="groupId"
+                    :can-reply="false" />
+            </div>
+        </div>
+    </div>
+</template>
+
+<script>
+    import CommentDrawer from '@/groups/partials/CommentDrawer.vue';
+
+    export default {
+        props: {
+            showCommentDrawer: {
+                type: Boolean,
+            },
+            permalinkMode: {
+                type: Boolean,
+            },
+            childContext: {
+                type: Object
+            },
+            status: {
+                type: Object
+            },
+            profile: {
+                type: Object
+            },
+            groupId: {
+                type: String
+            },
+        },
+
+        components: {
+            "comment-drawer": CommentDrawer,
+        }
+    }
+</script>

+ 23 - 0
resources/assets/components/groups/sections/Loader.vue

@@ -0,0 +1,23 @@
+<template>
+    <div class="w-100 h-100">
+        <div v-if="!loaded" class="d-flex w-100 h-100 py-5 justify-content-center align-items-center">
+            <div class="text-center">
+                <div class="spinner-border" role="status">
+                    <span class="sr-only">Loading...</span>
+                </div>
+                <p class="text-center font-weight-bold mt-1">Loading...</p>
+            </div>
+        </div>
+    </div>
+</template>
+
+<script>
+    export default {
+        props: {
+            loaded: {
+                type: Boolean,
+                default: false
+            }
+        }
+    }
+</script>

+ 316 - 0
resources/assets/components/groups/sections/Sidebar.vue

@@ -0,0 +1,316 @@
+<template>
+    <div class="col-3 shadow groups-sidenav">
+        <div class="p-1">
+            <div class="d-flex justify-content-between align-items-center py-3">
+                <p class="h2 font-weight-bold mb-0">Groups</p>
+                <a class="btn btn-light px-2 rounded-circle" href="/settings/home">
+                    <i class="fas fa-cog fa-lg"></i>
+                </a>
+            </div>
+
+            <div class="mb-3">
+                <autocomplete
+                    :search="autocompleteSearch"
+                    placeholder="Search groups by name"
+                    aria-label="Search groups by name"
+                    :get-result-value="getSearchResultValue"
+                    :debounceTime="700"
+                    @submit="onSearchSubmit"
+                    ref="autocomplete"
+                    >
+                    <template #result="{ result, props }">
+                        <li
+                        v-bind="props"
+                        class="autocomplete-result"
+                        >
+
+                            <div class="media align-items-center">
+                                <img v-if="result.local && result.metadata && result.metadata.hasOwnProperty('header') && result.metadata.header.hasOwnProperty('url')" :src="result.metadata.header.url" width="32" height="32">
+                                <div v-else class="icon-placeholder">
+                                    <i class="fal fa-user-friends"></i>
+                                </div>
+                                <div class="media-body text-truncate mr-3">
+                                    <p class="result-name mb-n1 font-weight-bold">
+                                        {{ truncateName(result.name) }}
+                                        <span v-if="result.verified" class="fa-stack ml-n2" title="Verified Group" data-toggle="tooltip">
+                                            <i class="fas fa-circle fa-stack-1x fa-lg" style="color: #22a7f0cc;font-size:18px"></i>
+                                            <i class="fas fa-check fa-stack-1x text-white" style="font-size:10px"></i>
+                                        </span>
+                                    </p>
+                                    <p class="mb-0 text-muted" style="font-size: 10px;">
+                                        <span v-if="!result.local" title="Remote Group">
+                                            <i class="far fa-globe"></i>
+                                        </span>
+                                        <span v-if="!result.local">·</span>
+                                        <span class="font-weight-bold">{{ result.member_count }} members</span>
+                                    </p>
+                                </div>
+                            </div>
+                        </li>
+                    </template>
+                </autocomplete>
+            </div>
+
+            <!-- <button
+                class="btn btn-light group-nav-btn"
+                :class="{ active: tab == 'feed' }"
+                @click="switchTab('feed')">
+                <div class="group-nav-btn-icon">
+                    <i class="fas fa-list"></i>
+                </div>
+                <div class="group-nav-btn-name">
+                    Your Feed
+                </div>
+            </button> -->
+
+            <template v-for="tab in tabs">
+                <router-link
+                    class="btn btn-light group-nav-btn"
+                    :to="tab.path">
+                        <div class="group-nav-btn-icon">
+                            <i :class="tab.icon"></i>
+                        </div>
+                        <div class="group-nav-btn-name">
+                            {{ tab.name }}
+                        </div>
+                    </router-link>
+            </template>
+
+            <router-link
+                to="/groups/create"
+                class="btn btn-primary btn-block rounded-pill font-weight-bold mt-3">
+                <i class="fas fa-plus mr-2"></i> Create New Group
+            </router-link>
+
+            <hr>
+            <!-- <div v-for="group in groups" class="ml-2">
+                <div class="card shadow-sm border text-decoration-none text-dark">
+                    <img v-if="group.metadata && group.metadata.hasOwnProperty('header')" :src="group.metadata.header.url" class="card-img-top" style="width: 100%; height: auto;object-fit: cover;max-height: 160px;">
+                    <div v-else class="bg-primary" style="width:100%;height:160px;"></div>
+                    <div class="card-body">
+                        <div class="lead font-weight-bold d-flex align-items-top" style="height: 60px;">
+                            {{ group.name }}
+                            <span v-if="group.verified" class="fa-stack ml-n2 mt-n2">
+                                <i class="fas fa-circle fa-stack-1x fa-lg" style="color: #22a7f0cc;font-size:18px"></i>
+                                <i class="fas fa-check fa-stack-1x text-white" style="font-size:10px"></i>
+                            </span>
+                        </div>
+                        <div class="text-muted font-weight-light d-flex justify-content-between">
+                            <span>{{group.member_count}} Members</span>
+                            <span style="font-size: 12px;padding: 2px 5px;color: rgba(75, 119, 190, 1);background:rgba(137, 196, 244, 0.2);border:1px solid rgba(137, 196, 244, 0.3);font-weight:400;text-transform: capitalize;" class="rounded">{{ group.self.role }}</span>
+                        </div>
+                        <hr>
+                        <p class="mb-0">
+                            <a class="btn btn-light btn-block border rounded-lg font-weight-bold" :href="group.url">View Group</a>
+                        </p>
+                    </div>
+                </div>
+            </div> -->
+        </div>
+    </div>
+</template>
+
+<script>
+    import Autocomplete from '@trevoreyre/autocomplete-vue'
+    import '@trevoreyre/autocomplete-vue/dist/style.css'
+
+    export default {
+        data() {
+            return {
+                initialLoad: false,
+                tabs: [
+                    { name: 'Your Feed', icon: 'fas fa-list', path: '/groups/feed' },
+                    { name: 'Discover', icon: 'fas fa-compass', path: '/groups/discover' },
+                    { name: 'Your Groups', icon: 'fas fa-list', path: '/groups/joins' },
+                    // { name: 'Notifications', icon: 'far fa-bell', path: '/groups/notifications' },
+                    // { name: 'Find Remote Group', icon: 'far fa-search-plus', path: '/groups/search' },
+                ],
+                config: {},
+                groups: [],
+                profile: {},
+                tab: null,
+                searchQuery: undefined,
+            };
+        },
+
+        components: {
+            'autocomplete': Autocomplete,
+        },
+
+        methods: {
+            autocompleteSearch(input) {
+                if (!input || input.length < 2) {
+                    return [];
+                };
+
+                this.searchQuery = input;
+                // this.tab = 'searchresults';
+
+                if(input.startsWith('http')) {
+                    let url = new URL(input);
+                    if(url.hostname == location.hostname) {
+                        location.href = input;
+                        return [];
+                    }
+                    return [];
+                }
+
+                if(input.startsWith('#')) {
+                    this.$bvToast.toast(input, {
+                        title: 'Hashtag detected',
+                        variant: 'info',
+                        autoHideDelay: 5000
+                    });
+                    return [];
+                }
+
+                return axios.post('/api/v0/groups/search/global', {
+                    q: input,
+                    v: '0.2'
+                })
+                .then(res => {
+                    this.searchLoading = false;
+                    return res.data;
+                }).catch(err => {
+
+                    if(err.response.status === 422) {
+                        this.$bvToast.toast(err.response.data.error.message, {
+                            title: 'Cannot display search results',
+                            variant: 'danger',
+                            autoHideDelay: 5000
+                        });
+                    }
+
+                    return [];
+                })
+            },
+
+            getSearchResultValue(result) {
+                return result.name;
+            },
+
+            onSearchSubmit(result) {
+                if (result.length < 1) {
+                    return [];
+                }
+
+                location.href = result.url;
+            },
+
+            truncateName(val) {
+                if(val.length < 24) {
+                    return val;
+                }
+
+                return val.substr(0, 23) + '...';
+            }
+        }
+    }
+</script>
+
+<style lang="scss">
+    .groups-sidenav {
+        display: none;
+        font-family: var(--font-family-sans-serif);
+
+        @media(min-width: 768px) {
+            display: block;
+            width: 100%;
+            height: 100vh;
+            background: #fff;
+            top: 74px;
+            border: none;
+            overflow: hidden;
+            z-index: 1;
+            position: sticky;
+        }
+
+        .group-nav-btn {
+            display: block;
+            width: 100%;
+            padding-left: 0;
+            padding-top: 0.3rem;
+            padding-bottom: 0.3rem;
+            margin-bottom: 0.3rem;
+            border-radius: 1.5rem;
+            text-align: left;
+            color: #6c757d;
+            background-color: transparent;
+            border-color: transparent;
+            justify-content: flex-start;
+
+            &.active {
+                background-color: #EFF6FF !important;
+                border:1px solid #DBEAFE !important;
+                color: #212529;
+
+                .group-nav-btn-icon {
+                    background-color: #2c78bf !important;
+                    color: #fff !important;
+                }
+            }
+
+            &-icon {
+                display: inline-flex;
+                width: 35px;
+                height: 35px;
+                padding: 12px;
+                background-color: #E5E7EB;
+                border-radius: 17px;
+                margin: auto 0.3rem;
+                align-items: center;
+                justify-content: center;
+            }
+
+            &-name {
+                display: inline-block;
+                margin-left: 0.3rem;
+                font-weight: 700;
+            }
+        }
+
+        .autocomplete-input {
+            height: 2.375rem;
+            background-color: #f8f9fa !important;
+            font-size: 0.9rem;
+            color: #495057;
+            border-radius: 50rem;
+            border-color: transparent;
+
+            &:focus,
+            &[aria-expanded=true] {
+                box-shadow: none;
+            }
+        }
+
+        .autocomplete-result {
+            background: none;
+            padding: 12px;
+
+            &:hover,
+            &:focus {
+                background-color: #EFF6FF !important;
+            }
+
+            .media {
+                img {
+                    object-fit: cover;
+                    border-radius: 4px;
+                    margin-right: 0.6rem;
+                }
+
+                .icon-placeholder {
+                    display: flex;
+                    width: 32px;
+                    height: 32px;
+                    background-color: #2c78bf;
+                    border-radius: 4px;
+                    justify-content: center;
+                    align-items: center;
+                    color: #fff;
+                    margin-right: 0.6rem;
+                }
+            }
+        }
+    }
+</style>

+ 4 - 0
resources/assets/js/group-status.js

@@ -0,0 +1,4 @@
+Vue.component(
+	'gs-permalink',
+	require('./components/GroupStatusPermalink.vue').default
+);

+ 4 - 0
resources/assets/js/group-topic-feed.js

@@ -0,0 +1,4 @@
+Vue.component(
+	'group-topic-feed',
+	require('./../components/groups/GroupTopicFeed.vue').default
+);

+ 29 - 0
resources/assets/js/groups.js

@@ -0,0 +1,29 @@
+Vue.component(
+	'group-component',
+	require('./../components/Groups.vue').default
+);
+
+Vue.component(
+	'groups-home',
+	require('./../components/groups/GroupsHome.vue').default
+);
+
+Vue.component(
+	'group-feed',
+	require('./../components/groups/GroupFeed.vue').default
+);
+
+Vue.component(
+	'group-settings',
+	require('./../components/groups/GroupSettings.vue').default
+);
+
+Vue.component(
+	'group-profile',
+	require('./../components/groups/GroupProfile.vue').default
+);
+
+Vue.component(
+	'groups-invite',
+	require('./../components/groups/GroupInvite.vue').default
+);

+ 3 - 0
webpack.mix.js

@@ -40,6 +40,9 @@ mix.js('resources/assets/js/app.js', 'public/js')
 .js('resources/assets/js/admin_invite.js', 'public/js')
 .js('resources/assets/js/admin_invite.js', 'public/js')
 .js('resources/assets/js/landing.js', 'public/js')
 .js('resources/assets/js/landing.js', 'public/js')
 .js('resources/assets/js/remote_auth.js', 'public/js')
 .js('resources/assets/js/remote_auth.js', 'public/js')
+.js('resources/assets/js/groups.js', 'public/js')
+.js('resources/assets/js/group-status.js', 'public/js')
+.js('resources/assets/js/group-topic-feed.js', 'public/js')
 .vue({ version: 2 });
 .vue({ version: 2 });
 
 
 mix.extract();
 mix.extract();